mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-04 21:12:42 +08:00
207 lines
6.7 KiB
JavaScript
207 lines
6.7 KiB
JavaScript
#!/usr/bin/env node
|
||
import { parseArgs } from "node:util";
|
||
import { createInterface } from "node:readline/promises";
|
||
import { Writable } from "node:stream";
|
||
import {
|
||
serveStdio,
|
||
StdioServerTransport,
|
||
} from "@modelcontextprotocol/server/stdio";
|
||
import { DeviceStore, type Device } from "./store.js";
|
||
import { Sessions } from "./sessions.js";
|
||
import { BrowserPanel, pairBrowser, openURL } from "./browser.js";
|
||
import { createServer } from "./server.js";
|
||
import { TerminalPanel } from "./terminal.js";
|
||
import { terminalView } from "./terminal-view.js";
|
||
import {
|
||
panel,
|
||
parse,
|
||
Failure,
|
||
failure,
|
||
uiConnect,
|
||
videoOptions,
|
||
type Panel,
|
||
} from "./common.js";
|
||
|
||
async function setup(store: DeviceStore, view?: string) {
|
||
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
||
throw new Failure("SETUP_REQUIRES_TERMINAL");
|
||
if (view) {
|
||
const url = await pairBrowser(store.directory, view);
|
||
if (!url) throw new Failure("PANEL_NOT_FOUND");
|
||
if (await openURL(url)) process.stdout.write("浏览器配对入口已打开。\n");
|
||
else
|
||
process.stdout.write(
|
||
`请在本机浏览器打开下方一次性链接(60 秒有效,不要发送到聊天):\n${url}\n`,
|
||
);
|
||
return;
|
||
}
|
||
let hidden = false;
|
||
const output = new Writable({
|
||
write(chunk, _encoding, done) {
|
||
if (!hidden) process.stdout.write(chunk);
|
||
done();
|
||
},
|
||
});
|
||
const rl = createInterface({
|
||
input: process.stdin,
|
||
output,
|
||
terminal: true,
|
||
historySize: 0,
|
||
});
|
||
const question = async (label: string) => (await rl.question(label)).trim();
|
||
try {
|
||
const config = await store.config();
|
||
process.stdout.write(
|
||
"CrossDesk MCP · 本地设备配置\n密码只保存到系统凭据库,不写入设备配置。\n",
|
||
);
|
||
const name = await question("设备别名:");
|
||
const previous = config.devices.find((d) => d.alias === name);
|
||
const device = previous?.device_id ?? (await question("9 位设备 ID:"));
|
||
const server =
|
||
previous?.server ??
|
||
((await question(`服务器 [${config.server}]:`)) || config.server);
|
||
const port =
|
||
previous?.port ??
|
||
Number((await question(`端口 [${config.port}]:`)) || config.port);
|
||
const permission = await question(
|
||
"允许未来自动连接?[n / observe / control]:",
|
||
);
|
||
const environment = await question(
|
||
"使用现有密码环境变量(留空使用系统凭据库):",
|
||
);
|
||
let password: string | undefined;
|
||
if (!environment) {
|
||
hidden = true;
|
||
process.stdout.write("访问密码(隐藏输入,可为空):");
|
||
try {
|
||
password = await rl.question("");
|
||
} finally {
|
||
hidden = false;
|
||
process.stdout.write("\n");
|
||
}
|
||
}
|
||
parse(uiConnect, {
|
||
device_id: device,
|
||
label: name,
|
||
server,
|
||
port,
|
||
password,
|
||
});
|
||
const d: Device = {
|
||
alias: name,
|
||
device_id: device,
|
||
server,
|
||
port,
|
||
...(permission === "observe" || permission === "control"
|
||
? { authorization: permission }
|
||
: {}),
|
||
...(environment
|
||
? { credential: "environment", environment_variable: environment }
|
||
: {}),
|
||
};
|
||
await store.save(d, password);
|
||
password = undefined;
|
||
process.stdout.write(
|
||
"已保存。Agent 可调用 crossdesk_list_devices;自动连接仍需有效授权和凭据。\n",
|
||
);
|
||
} finally {
|
||
hidden = false;
|
||
rl.close();
|
||
}
|
||
}
|
||
async function main() {
|
||
const { values, positionals } = parseArgs({
|
||
allowPositionals: true,
|
||
strict: true,
|
||
options: {
|
||
stdio: { type: "boolean" },
|
||
panel: { type: "string" },
|
||
"video-path": { type: "string" },
|
||
"video-decoder": { type: "string" },
|
||
"config-dir": { type: "string" },
|
||
"native-binary": { type: "string" },
|
||
"open-panel": { type: "boolean" },
|
||
view: { type: "string" },
|
||
"cell-aspect": { type: "string" },
|
||
help: { type: "boolean" },
|
||
version: { type: "boolean" },
|
||
},
|
||
});
|
||
if (values.version) {
|
||
process.stdout.write("0.1.0\n");
|
||
return;
|
||
}
|
||
if (values.help || !positionals.length) {
|
||
process.stdout.write(
|
||
"crossdesk-mcp serve --stdio [--panel auto|apps|browser|terminal|off] [--video-path browser|minirtc] [--video-decoder auto|software] [--config-dir directory]\ncrossdesk-mcp view --view VIEW_ID [--config-dir directory] [--cell-aspect 0.5]\ncrossdesk-mcp setup [--config-dir directory]\ncrossdesk-mcp setup --open-panel --view VIEW_ID [--config-dir directory]\nDevelopment: --native-binary /absolute/path/to/crossdesk-cli\n",
|
||
);
|
||
return;
|
||
}
|
||
if (positionals.length !== 1 || !["serve", "setup", "view"].includes(positionals[0]!))
|
||
throw new Failure("INVALID_COMMAND");
|
||
const store = new DeviceStore(
|
||
values["config-dir"] ?? process.env.CROSSDESK_MCP_CONFIG_DIR,
|
||
);
|
||
await store.initialize();
|
||
if (positionals[0] === "view") {
|
||
if (!values.view) throw new Failure("VIEW_REQUIRED");
|
||
await terminalView(store.directory, values.view, Number(values["cell-aspect"] ?? 0.5));
|
||
return;
|
||
}
|
||
if (positionals[0] === "setup") {
|
||
if (values["open-panel"] && !values.view)
|
||
throw new Failure("VIEW_REQUIRED");
|
||
await setup(store, values["open-panel"] ? values.view : undefined);
|
||
return;
|
||
}
|
||
if (!values.stdio) throw new Failure("STDIO_REQUIRED");
|
||
const config = await store.config();
|
||
const policy: Panel = parse(
|
||
panel,
|
||
values.panel ?? config.panel,
|
||
);
|
||
const sessions = new Sessions(
|
||
store,
|
||
policy,
|
||
values["native-binary"] ?? process.env.CROSSDESK_NATIVE_BINARY,
|
||
parse(videoOptions, {
|
||
decode_path: values["video-path"] ?? config.video.decode_path,
|
||
decoder: values["video-decoder"] ?? config.video.decoder,
|
||
}),
|
||
);
|
||
const browser = new BrowserPanel(sessions);
|
||
const terminal = new TerminalPanel(sessions);
|
||
const handle = serveStdio(() => createServer(sessions, browser, terminal), {
|
||
transport: new StdioServerTransport(),
|
||
onerror: () => process.stderr.write("crossdesk-mcp: protocol error\n"),
|
||
});
|
||
let closing = false;
|
||
const close = async () => {
|
||
if (closing) return;
|
||
closing = true;
|
||
await terminal.close();
|
||
await browser.close();
|
||
await sessions.close();
|
||
await handle.close();
|
||
};
|
||
process.stdin.once("end", () => {
|
||
void close();
|
||
});
|
||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const)
|
||
process.once(signal, () => {
|
||
void close().finally(() => {
|
||
process.exitCode = 0;
|
||
});
|
||
});
|
||
process.once("uncaughtException", () => {
|
||
process.stderr.write("crossdesk-mcp: INTERNAL_ERROR\n");
|
||
void close().finally(() => {
|
||
process.exitCode = 1;
|
||
});
|
||
});
|
||
}
|
||
main().catch((error) => {
|
||
process.stderr.write(`crossdesk-mcp: ${failure(error).code}\n`);
|
||
process.exitCode = 1;
|
||
});
|