writedock-mcp 0.4.15 → 0.4.16
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/README.md +3 -2
- package/extension/manifest.json +1 -1
- package/extension//345/256/211/350/243/205/344/270/216/346/233/264/346/226/260.txt +1 -1
- package/index.cjs +118 -30
- package/package.json +1 -1
- package/source/package.json +1 -1
- package/source/src/__tests__/launcher.test.ts +100 -0
- package/source/src/__tests__/setup.test.ts +50 -7
- package/source/src/launcher.ts +82 -0
- package/source/src/server.ts +1 -1
- package/source/src/setup.ts +35 -22
package/README.md
CHANGED
|
@@ -14,7 +14,8 @@ npx -y writedock-mcp@latest setup
|
|
|
14
14
|
|
|
15
15
|
它会做两件事,并在最后告诉用户下一步:
|
|
16
16
|
|
|
17
|
-
- 给本机已有的 Codex、Claude Code、Cursor、Claude Desktop 添加 MCP 配置,原文件留一份 `.writedock-backup
|
|
17
|
+
- 给本机已有的 Codex、Claude Code、WorkBuddy、Cursor、Claude Desktop 添加 MCP 配置,原文件留一份 `.writedock-backup`。之前 `setup` 写的配置会更新,带自定义端口等设置的配置不会改动。
|
|
18
|
+
- 生成启动脚本 `~/WriteDock/start-mcp`(Windows 是 `.cmd`)。电脑上有多个 Node 时,它依次找:`setup` 用的 Node、系统的 Node、常见安装位置,用第一个 22 或以上的版本。
|
|
18
19
|
- 把内置的扩展放到本机固定目录。
|
|
19
20
|
|
|
20
21
|
然后重启 agent,在扩展管理页打开开发者模式,加载提示中的文件夹,打开扩展点「连接」。
|
|
@@ -32,7 +33,7 @@ npx -y writedock-mcp@latest setup
|
|
|
32
33
|
}
|
|
33
34
|
```
|
|
34
35
|
|
|
35
|
-
Windows 上要写成 `"command": "cmd"`、`"args": ["/c", "npx", "-y", "--prefer-online", "writedock-mcp@latest"]
|
|
36
|
+
Windows 上要写成 `"command": "cmd"`、`"args": ["/c", "npx", "-y", "--prefer-online", "writedock-mcp@latest"]`。手动配置依赖系统默认的 Node,版本要够新,能用 `setup` 就用 `setup`。如果 PowerShell 提示禁止运行脚本,把安装命令换成 `cmd /c npx -y writedock-mcp@latest setup`。
|
|
36
37
|
|
|
37
38
|
首次下载较慢,请把启动超时设为 120 秒。启动后调用 `get_connection_status`;未连接时,让用户打开扩展点「连接」。
|
|
38
39
|
|
package/extension/manifest.json
CHANGED
package/index.cjs
CHANGED
|
@@ -29740,7 +29740,7 @@ var MediaStore = class {
|
|
|
29740
29740
|
// package.json
|
|
29741
29741
|
var package_default = {
|
|
29742
29742
|
name: "@writedock/mcp-server",
|
|
29743
|
-
version: "0.4.
|
|
29743
|
+
version: "0.4.16",
|
|
29744
29744
|
private: true,
|
|
29745
29745
|
type: "module",
|
|
29746
29746
|
description: "WriteDock local MCP server for the browser extension",
|
|
@@ -50411,7 +50411,7 @@ function createMcpServer(bridge) {
|
|
|
50411
50411
|
})).catch((error2) => ({ content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }], isError: true }));
|
|
50412
50412
|
const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
|
|
50413
50413
|
server.registerTool("get_connection_status", {
|
|
50414
|
-
description: "\u68C0\u67E5\u672C\u673A MCP \u4E0E\u6269\u5C55\u7684\u8FDE\u63A5\u3001\u8FD0\u884C\u7248\u672C\u548C npm \u66F4\u65B0\u63D0\u793A\u3002update_available=true \u65F6\u544A\u77E5\u7528\u6237\u91CD\u65B0\u542F\u52A8 WriteDock MCP\uFF0C\u4FDD\u7559\u914D\u7F6E\uFF1B\u53D1\u5E03\u4EFB\u52A1\u8FDB\u884C\u4E2D\u4E0D\u8981\u81EA\u52A8\u91CD\u542F\u3002\u6709 update_notice \u65F6\u8F6C\u544A\u7528\u6237\u3002extension_version \u662F\u7528\u6237\u6B63\u5728\u7528\u7684\u6269\u5C55\u7248\u672C\uFF1B\u6CA1\u6709\u8FD9\u4E2A\u5B57\u6BB5\u8868\u793A\u7248\u672C\u672A\u77E5\uFF0C\u4E0D\u5F97\u58F0\u79F0\u6269\u5C55\u4E0E MCP \u7248\u672C\u4E00\u81F4\u3002\u914D\u7F6E\
|
|
50414
|
+
description: "\u68C0\u67E5\u672C\u673A MCP \u4E0E\u6269\u5C55\u7684\u8FDE\u63A5\u3001\u8FD0\u884C\u7248\u672C\u548C npm \u66F4\u65B0\u63D0\u793A\u3002update_available=true \u65F6\u544A\u77E5\u7528\u6237\u91CD\u65B0\u542F\u52A8 WriteDock MCP\uFF0C\u4FDD\u7559\u914D\u7F6E\uFF1B\u53D1\u5E03\u4EFB\u52A1\u8FDB\u884C\u4E2D\u4E0D\u8981\u81EA\u52A8\u91CD\u542F\u3002\u6709 update_notice \u65F6\u8F6C\u544A\u7528\u6237\u3002extension_version \u662F\u7528\u6237\u6B63\u5728\u7528\u7684\u6269\u5C55\u7248\u672C\uFF1B\u6CA1\u6709\u8FD9\u4E2A\u5B57\u6BB5\u8868\u793A\u7248\u672C\u672A\u77E5\uFF0C\u4E0D\u5F97\u58F0\u79F0\u6269\u5C55\u4E0E MCP \u7248\u672C\u4E00\u81F4\u3002\u8FD9\u6761 MCP \u914D\u7F6E\uFF08\u5305\u62EC start-mcp \u542F\u52A8\u811A\u672C\uFF09\u662F\u6709\u610F\u7684\uFF0C\u7528\u6237\u9760\u5B83\u6536\u5230\u66F4\u65B0\u5E76\u4F7F\u7528\u6B63\u786E\u7684 Node\uFF0C\u4E0D\u8981\u5EFA\u8BAE\u56FA\u5B9A\u7248\u672C\uFF0C\u4E5F\u4E0D\u8981\u4FEE\u6539\u8FD9\u6761\u914D\u7F6E\u3002\u65E7\u7248\u670D\u52A1\u53EF\u80FD\u4E0D\u8FD4\u56DE\u7248\u672C\u5B57\u6BB5\uFF1Blatest_version \u7F3A\u5931\u6216 update_checking=true \u65F6\uFF0C\u4E0D\u5F97\u58F0\u79F0\u5DF2\u662F\u6700\u65B0\u7248\u3002",
|
|
50415
50415
|
inputSchema: {},
|
|
50416
50416
|
annotations: { ...readOnly, openWorldHint: true }
|
|
50417
50417
|
}, () => wrap(() => bridge.status()));
|
|
@@ -50588,48 +50588,133 @@ async function loadIdentity(port, legacyToken) {
|
|
|
50588
50588
|
|
|
50589
50589
|
// src/setup.ts
|
|
50590
50590
|
var import_node_child_process = require("child_process");
|
|
50591
|
-
var
|
|
50591
|
+
var import_promises6 = require("fs/promises");
|
|
50592
50592
|
var import_node_os3 = require("os");
|
|
50593
|
+
var import_node_path6 = require("path");
|
|
50594
|
+
|
|
50595
|
+
// src/launcher.ts
|
|
50596
|
+
var import_promises5 = require("fs/promises");
|
|
50593
50597
|
var import_node_path5 = require("path");
|
|
50594
|
-
var NPX_ARGS =
|
|
50595
|
-
var
|
|
50598
|
+
var NPX_ARGS = "-y --prefer-online writedock-mcp@latest";
|
|
50599
|
+
var VERSION_CHECK = `process.exit(Number(process.versions.node.split('.')[0])>=22?0:1)`;
|
|
50600
|
+
var NO_NODE = "WriteDock \u9700\u8981 Node.js 22 \u6216\u66F4\u9AD8\u7248\u672C\uFF0C\u8FD9\u53F0\u7535\u8111\u4E0A\u6CA1\u6709\u627E\u5230\u3002\u8BF7\u5B89\u88C5 https://nodejs.org \u7684 LTS \u7248\uFF0C\u7136\u540E\u91CD\u542F agent\u3002";
|
|
50601
|
+
function posixLauncher(nodePath) {
|
|
50602
|
+
const recorded = `'${nodePath.replace(/'/g, `'\\''`)}'`;
|
|
50603
|
+
return `#!/bin/sh
|
|
50604
|
+
# Written by writedock-mcp setup. Running setup again replaces this file.
|
|
50605
|
+
found=""
|
|
50606
|
+
check() {
|
|
50607
|
+
[ -n "$found" ] && return 0
|
|
50608
|
+
[ -x "$1" ] || return 0
|
|
50609
|
+
"$1" -e "${VERSION_CHECK}" >/dev/null 2>&1 && found="$1"
|
|
50610
|
+
return 0
|
|
50611
|
+
}
|
|
50612
|
+
check ${recorded}
|
|
50613
|
+
check "$(command -v node 2>/dev/null)"
|
|
50614
|
+
check /opt/homebrew/bin/node
|
|
50615
|
+
check /usr/local/bin/node
|
|
50616
|
+
for candidate in "$HOME"/.workbuddy/binaries/node/versions/*/bin/node "$HOME"/.workbuddy/binaries/node/versions/*/node "$HOME"/.nvm/versions/node/*/bin/node; do
|
|
50617
|
+
check "$candidate"
|
|
50618
|
+
done
|
|
50619
|
+
if [ -z "$found" ]; then
|
|
50620
|
+
echo "${NO_NODE}" >&2
|
|
50621
|
+
exit 1
|
|
50622
|
+
fi
|
|
50623
|
+
PATH="$(dirname "$found"):$PATH"
|
|
50624
|
+
export PATH
|
|
50625
|
+
exec npx ${NPX_ARGS} "$@"
|
|
50626
|
+
`;
|
|
50627
|
+
}
|
|
50628
|
+
function windowsLauncher(nodePath) {
|
|
50629
|
+
const script = `@echo off
|
|
50630
|
+
chcp 65001 >nul
|
|
50631
|
+
rem Written by writedock-mcp setup. Running setup again replaces this file.
|
|
50632
|
+
set "WRITEDOCK_NODE="
|
|
50633
|
+
call :check "${nodePath.replace(/%/g, "%%")}"
|
|
50634
|
+
if defined WRITEDOCK_NODE goto run
|
|
50635
|
+
for /f "delims=" %%n in ('where node 2^>nul') do if not defined WRITEDOCK_NODE call :check "%%n"
|
|
50636
|
+
if defined WRITEDOCK_NODE goto run
|
|
50637
|
+
for /d %%d in ("%USERPROFILE%\\.workbuddy\\binaries\\node\\versions\\*") do if not defined WRITEDOCK_NODE call :check "%%d\\node.exe"
|
|
50638
|
+
if defined WRITEDOCK_NODE goto run
|
|
50639
|
+
for /d %%d in ("%NVM_HOME%\\v*") do if not defined WRITEDOCK_NODE call :check "%%d\\node.exe"
|
|
50640
|
+
if defined WRITEDOCK_NODE goto run
|
|
50641
|
+
echo ${NO_NODE} 1>&2
|
|
50642
|
+
exit /b 1
|
|
50643
|
+
:run
|
|
50644
|
+
for %%d in ("%WRITEDOCK_NODE%") do set "PATH=%%~dpd;%PATH%"
|
|
50645
|
+
call npx ${NPX_ARGS} %*
|
|
50646
|
+
exit /b %errorlevel%
|
|
50647
|
+
:check
|
|
50648
|
+
if not exist "%~1" exit /b 0
|
|
50649
|
+
"%~1" -e "${VERSION_CHECK}" >nul 2>nul
|
|
50650
|
+
if not errorlevel 1 set "WRITEDOCK_NODE=%~1"
|
|
50651
|
+
exit /b 0
|
|
50652
|
+
`;
|
|
50653
|
+
return script.replace(/\n/g, "\r\n");
|
|
50654
|
+
}
|
|
50655
|
+
async function writeLauncher(home, platform, nodePath) {
|
|
50656
|
+
const directory = (0, import_node_path5.join)(home, "WriteDock");
|
|
50657
|
+
await (0, import_promises5.mkdir)(directory, { recursive: true });
|
|
50658
|
+
if (platform === "win32") {
|
|
50659
|
+
const path2 = (0, import_node_path5.join)(directory, "start-mcp.cmd");
|
|
50660
|
+
await (0, import_promises5.writeFile)(path2, windowsLauncher(nodePath));
|
|
50661
|
+
return { path: path2, command: "cmd", args: ["/c", path2] };
|
|
50662
|
+
}
|
|
50663
|
+
const path = (0, import_node_path5.join)(directory, "start-mcp.sh");
|
|
50664
|
+
await (0, import_promises5.writeFile)(path, posixLauncher(nodePath));
|
|
50665
|
+
await (0, import_promises5.chmod)(path, 493);
|
|
50666
|
+
return { path, command: path, args: [] };
|
|
50667
|
+
}
|
|
50668
|
+
|
|
50669
|
+
// src/setup.ts
|
|
50670
|
+
var isOurs = (text) => text.includes("writedock-mcp@latest") || text.includes("start-mcp");
|
|
50596
50671
|
async function readIfPresent(path) {
|
|
50597
50672
|
try {
|
|
50598
|
-
return (await (0,
|
|
50673
|
+
return (await (0, import_promises6.readFile)(path, "utf8")).replace(/^\uFEFF/, "");
|
|
50599
50674
|
} catch (error2) {
|
|
50600
50675
|
if (error2.code === "ENOENT") return void 0;
|
|
50601
50676
|
throw error2;
|
|
50602
50677
|
}
|
|
50603
50678
|
}
|
|
50604
50679
|
async function save(path, content, previous) {
|
|
50605
|
-
await (0,
|
|
50606
|
-
if (previous !== void 0) await (0,
|
|
50607
|
-
await (0,
|
|
50680
|
+
await (0, import_promises6.mkdir)((0, import_node_path6.dirname)(path), { recursive: true });
|
|
50681
|
+
if (previous !== void 0) await (0, import_promises6.copyFile)(path, `${path}.writedock-backup`);
|
|
50682
|
+
await (0, import_promises6.writeFile)(path, content);
|
|
50608
50683
|
}
|
|
50609
|
-
async function configureCodex(path,
|
|
50684
|
+
async function configureCodex(path, server) {
|
|
50610
50685
|
const previous = await readIfPresent(path);
|
|
50611
|
-
|
|
50612
|
-
|
|
50613
|
-
const entry = `[mcp_servers.writedock]
|
|
50614
|
-
command = "${command}"
|
|
50615
|
-
args = ${JSON.stringify(args)}
|
|
50686
|
+
const lines = `command = ${JSON.stringify(server.command)}
|
|
50687
|
+
args = ${JSON.stringify(server.args)}
|
|
50616
50688
|
startup_timeout_sec = 120
|
|
50617
50689
|
`;
|
|
50690
|
+
const section = previous === void 0 ? null : /^[ \t]*\[mcp_servers\.writedock\][^\n]*\n([\s\S]*?)(?=^[ \t]*\[|(?![\s\S]))/m.exec(previous);
|
|
50691
|
+
if (section && previous !== void 0) {
|
|
50692
|
+
const customised = /^[ \t]*\[mcp_servers\.writedock\./m.test(previous) || !isOurs(section[1]);
|
|
50693
|
+
const current = section[1].includes(JSON.stringify(server.args).slice(1, -1)) && section[1].includes(JSON.stringify(server.command));
|
|
50694
|
+
if (customised || current) return "present";
|
|
50695
|
+
const start = section.index + section[0].length - section[1].length;
|
|
50696
|
+
await save(path, `${previous.slice(0, start)}${lines}
|
|
50697
|
+
${previous.slice(start + section[1].length)}`, previous);
|
|
50698
|
+
return "updated";
|
|
50699
|
+
}
|
|
50700
|
+
const entry = `[mcp_servers.writedock]
|
|
50701
|
+
${lines}`;
|
|
50618
50702
|
await save(path, previous ? `${previous.replace(/\s*$/, "")}
|
|
50619
50703
|
|
|
50620
50704
|
${entry}` : entry, previous);
|
|
50621
50705
|
return "added";
|
|
50622
50706
|
}
|
|
50623
|
-
async function configureJsonClient(path,
|
|
50707
|
+
async function configureJsonClient(path, server) {
|
|
50624
50708
|
const previous = await readIfPresent(path);
|
|
50625
50709
|
const config2 = previous?.trim() ? JSON.parse(previous) : {};
|
|
50626
|
-
|
|
50627
|
-
|
|
50710
|
+
const existing = config2.mcpServers?.writedock;
|
|
50711
|
+
if (existing && (existing.env || !isOurs(JSON.stringify(existing.args ?? [])) || JSON.stringify([existing.command, existing.args]) === JSON.stringify([server.command, server.args]))) return "present";
|
|
50712
|
+
config2.mcpServers = { ...config2.mcpServers, writedock: server };
|
|
50628
50713
|
await save(path, `${JSON.stringify(config2, null, 2)}
|
|
50629
50714
|
`, previous);
|
|
50630
|
-
return "added";
|
|
50715
|
+
return existing ? "updated" : "added";
|
|
50631
50716
|
}
|
|
50632
|
-
var exists =
|
|
50717
|
+
var exists = (path) => (0, import_promises6.access)(path).then(() => true, () => false);
|
|
50633
50718
|
function revealFolder(path) {
|
|
50634
50719
|
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
50635
50720
|
try {
|
|
@@ -50640,12 +50725,15 @@ function revealFolder(path) {
|
|
|
50640
50725
|
} catch {
|
|
50641
50726
|
}
|
|
50642
50727
|
}
|
|
50643
|
-
async function runSetup(home = (0, import_node_os3.homedir)(), log = console.log, platform = process.platform) {
|
|
50644
|
-
const
|
|
50728
|
+
async function runSetup(home = (0, import_node_os3.homedir)(), log = console.log, platform = process.platform, nodePath = process.execPath) {
|
|
50729
|
+
const server = await writeLauncher(home, platform, nodePath);
|
|
50730
|
+
const entry = { command: server.command, args: server.args };
|
|
50731
|
+
const claudeDesktop = platform === "win32" ? (0, import_node_path6.join)(process.env.APPDATA || (0, import_node_path6.join)(home, "AppData/Roaming"), "Claude/claude_desktop_config.json") : platform === "darwin" ? (0, import_node_path6.join)(home, "Library/Application Support/Claude/claude_desktop_config.json") : (0, import_node_path6.join)(home, ".config/Claude/claude_desktop_config.json");
|
|
50645
50732
|
const clients = [
|
|
50646
|
-
{ name: "Codex", path: (0,
|
|
50647
|
-
{ name: "Claude Code", path: (0,
|
|
50648
|
-
{ name: "
|
|
50733
|
+
{ name: "Codex", path: (0, import_node_path6.join)(home, ".codex/config.toml"), detect: (0, import_node_path6.join)(home, ".codex/config.toml"), configure: configureCodex },
|
|
50734
|
+
{ name: "Claude Code", path: (0, import_node_path6.join)(home, ".claude.json"), detect: (0, import_node_path6.join)(home, ".claude.json"), configure: configureJsonClient },
|
|
50735
|
+
{ name: "WorkBuddy", path: (0, import_node_path6.join)(home, ".workbuddy/mcp.json"), detect: (0, import_node_path6.join)(home, ".workbuddy"), configure: configureJsonClient },
|
|
50736
|
+
{ name: "Cursor", path: (0, import_node_path6.join)(home, ".cursor/mcp.json"), detect: (0, import_node_path6.join)(home, ".cursor/mcp.json"), configure: configureJsonClient },
|
|
50649
50737
|
{ name: "Claude Desktop", path: claudeDesktop, detect: claudeDesktop, configure: configureJsonClient }
|
|
50650
50738
|
];
|
|
50651
50739
|
let found = 0;
|
|
@@ -50653,15 +50741,15 @@ async function runSetup(home = (0, import_node_os3.homedir)(), log = console.log
|
|
|
50653
50741
|
if (!await exists(client.detect)) continue;
|
|
50654
50742
|
found++;
|
|
50655
50743
|
try {
|
|
50656
|
-
const state = await client.configure(client.path,
|
|
50657
|
-
log(state === "
|
|
50744
|
+
const state = await client.configure(client.path, entry);
|
|
50745
|
+
log(state === "present" ? `\u2713 ${client.name}\uFF1A\u5DF2\u6709 WriteDock \u914D\u7F6E\uFF0C\u672A\u6539\u52A8` : `\u2713 ${client.name}\uFF1A\u5DF2${state === "added" ? "\u6DFB\u52A0" : "\u66F4\u65B0"} WriteDock\uFF08\u539F\u914D\u7F6E\u5907\u4EFD\u4E3A .writedock-backup\uFF09`);
|
|
50658
50746
|
} catch (error2) {
|
|
50659
50747
|
log(`\u2717 ${client.name}\uFF1A\u914D\u7F6E\u5931\u8D25\uFF08${error2 instanceof Error ? error2.message : String(error2)}\uFF09\uFF0C\u539F\u6587\u4EF6\u672A\u6539\u52A8`);
|
|
50660
50748
|
}
|
|
50661
50749
|
}
|
|
50662
50750
|
if (!found) {
|
|
50663
50751
|
log("\u6CA1\u6709\u627E\u5230\u5DF2\u77E5\u7684 agent \u5BA2\u6237\u7AEF\u3002\u8BF7\u5728\u4F60\u7684\u5BA2\u6237\u7AEF\u91CC\u6DFB\u52A0\u8FD9\u4E2A MCP \u670D\u52A1\uFF1A");
|
|
50664
|
-
log(JSON.stringify({ mcpServers: { writedock:
|
|
50752
|
+
log(JSON.stringify({ mcpServers: { writedock: entry } }, null, 2));
|
|
50665
50753
|
}
|
|
50666
50754
|
const bundle = bundledExtension();
|
|
50667
50755
|
const loaded = bundle ? await findInstalledExtension().catch(() => void 0) : void 0;
|
|
@@ -50691,7 +50779,7 @@ async function runSetup(home = (0, import_node_os3.homedir)(), log = console.log
|
|
|
50691
50779
|
var import_node_child_process2 = require("child_process");
|
|
50692
50780
|
var import_node_fs2 = require("fs");
|
|
50693
50781
|
var import_node_os4 = require("os");
|
|
50694
|
-
var
|
|
50782
|
+
var import_node_path7 = require("path");
|
|
50695
50783
|
var sleep = (ms) => new Promise((resolve4) => setTimeout(resolve4, ms));
|
|
50696
50784
|
async function probe(port) {
|
|
50697
50785
|
try {
|
|
@@ -50713,7 +50801,7 @@ async function until(check2, timeoutMs) {
|
|
|
50713
50801
|
function startDetached() {
|
|
50714
50802
|
const directory = dataDirectory();
|
|
50715
50803
|
(0, import_node_fs2.mkdirSync)(directory, { recursive: true, mode: 448 });
|
|
50716
|
-
const log = (0, import_node_fs2.openSync)((0,
|
|
50804
|
+
const log = (0, import_node_fs2.openSync)((0, import_node_path7.join)(directory, "service.log"), "w");
|
|
50717
50805
|
try {
|
|
50718
50806
|
const child = (0, import_node_child_process2.spawn)(process.execPath, [__filename, "--service"], { detached: true, stdio: ["ignore", "ignore", log], windowsHide: true, cwd: (0, import_node_os4.homedir)() });
|
|
50719
50807
|
child.on("error", () => {
|
package/package.json
CHANGED
package/source/package.json
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
7
|
+
import { windowsLauncher, writeLauncher } from '../launcher'
|
|
8
|
+
|
|
9
|
+
const systemNodeElsewhere = ['/opt/homebrew/bin/node', '/usr/local/bin/node'].some(existsSync)
|
|
10
|
+
|
|
11
|
+
describe.skipIf(process.platform === 'win32')('Mac and Linux launcher, actually run', () => {
|
|
12
|
+
let home: string, fakeBin: string
|
|
13
|
+
const tool = async (path: string, body: string) => { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `#!/bin/sh\n${body}\n`); await chmod(path, 0o755) }
|
|
14
|
+
const oldNode = (path: string) => tool(path, 'exit 1')
|
|
15
|
+
const goodNode = async (path: string) => { await tool(path, 'exit 0'); return path }
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
home = await mkdtemp(join(tmpdir(), 'writedock-launcher-'))
|
|
18
|
+
fakeBin = join(home, 'fake-bin')
|
|
19
|
+
// Stands in for npx, so nothing is downloaded: it reports what it was started with.
|
|
20
|
+
await tool(join(fakeBin, 'npx'), 'echo "first-path=${PATH%%:*}"; echo "args=$*"')
|
|
21
|
+
})
|
|
22
|
+
afterEach(() => rm(home, { recursive: true, force: true }))
|
|
23
|
+
const run = async (recorded: string, pathDirs: string[] = []) => {
|
|
24
|
+
const { path } = await writeLauncher(home, 'darwin', recorded)
|
|
25
|
+
return spawnSync(path, ['--extra'], { encoding: 'utf8', env: { HOME: home, PATH: [fakeBin, ...pathDirs, '/usr/bin', '/bin'].join(':') } })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
it('uses the Node setup ran with, ahead of an old system Node, and puts it first on PATH', async () => {
|
|
29
|
+
const system = join(home, 'system')
|
|
30
|
+
await oldNode(join(system, 'node'))
|
|
31
|
+
const recorded = await goodNode(join(home, 'agent/node'))
|
|
32
|
+
const result = await run(recorded, [system])
|
|
33
|
+
expect(result.status).toBe(0)
|
|
34
|
+
expect(result.stdout).toContain(`first-path=${dirname(recorded)}`)
|
|
35
|
+
expect(result.stdout).toContain('args=-y --prefer-online writedock-mcp@latest --extra')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('falls back to the system Node when the recorded one is gone', async () => {
|
|
39
|
+
const system = await goodNode(join(home, 'system/node'))
|
|
40
|
+
const result = await run(join(home, 'removed-by-agent-update/node'), [dirname(system)])
|
|
41
|
+
expect(result.status).toBe(0)
|
|
42
|
+
expect(result.stdout).toContain(`first-path=${dirname(system)}`)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('finds a newer WorkBuddy Node when its version folder changed, past an old system Node', async () => {
|
|
46
|
+
const system = join(home, 'system')
|
|
47
|
+
await oldNode(join(system, 'node'))
|
|
48
|
+
const moved = join(home, '.workbuddy/binaries/node/versions/23.0.0-1/bin')
|
|
49
|
+
await goodNode(join(moved, 'node'))
|
|
50
|
+
const result = await run(join(home, '.workbuddy/binaries/node/versions/22.22.2-6/bin/node'), [system])
|
|
51
|
+
expect(result.status).toBe(0)
|
|
52
|
+
if (!systemNodeElsewhere) expect(result.stdout).toContain(`first-path=${moved}`)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it.skipIf(systemNodeElsewhere)('says what to do when no usable Node exists, and starts nothing', async () => {
|
|
56
|
+
const system = join(home, 'system')
|
|
57
|
+
await oldNode(join(system, 'node'))
|
|
58
|
+
const result = await run(join(home, 'gone/node'), [system])
|
|
59
|
+
expect(result.status).toBe(1)
|
|
60
|
+
expect(result.stderr).toContain('nodejs.org')
|
|
61
|
+
expect(result.stdout).not.toContain('args=')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('copes with spaces and quotes in the recorded path', async () => {
|
|
65
|
+
const odd = join(home, "it's a folder")
|
|
66
|
+
await goodNode(join(odd, 'node'))
|
|
67
|
+
const result = await run(join(odd, 'node'))
|
|
68
|
+
expect(result.status).toBe(0)
|
|
69
|
+
expect(result.stdout).toContain(`first-path=${odd}`)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('Windows launcher text (cannot run on this machine)', () => {
|
|
74
|
+
const script = windowsLauncher('C:\\Users\\张三\\.workbuddy\\binaries\\node\\versions\\22.22.2-6\\node.exe')
|
|
75
|
+
it('handles non-English user names and uses Windows line endings', () => {
|
|
76
|
+
expect(script.startsWith('@echo off\r\nchcp 65001 >nul\r\n')).toBe(true)
|
|
77
|
+
expect(script).toContain('张三')
|
|
78
|
+
expect(script.replace(/\r\n/g, '')).not.toContain('\n')
|
|
79
|
+
})
|
|
80
|
+
it('checks the recorded Node, then the system, then known folders, in that order', () => {
|
|
81
|
+
const order = ['call :check "C:\\Users', 'where node', '%USERPROFILE%', 'NVM_HOME', 'nodejs.org'].map(text => script.indexOf(text))
|
|
82
|
+
expect(order.every(index => index >= 0)).toBe(true)
|
|
83
|
+
expect([...order].sort((a, b) => a - b)).toEqual(order)
|
|
84
|
+
})
|
|
85
|
+
it('puts the chosen Node first on PATH and starts npx with call, or the script would stop there', () => {
|
|
86
|
+
expect(script).toContain('set "PATH=%%~dpd;%PATH%"')
|
|
87
|
+
expect(script).toContain('call npx -y --prefer-online writedock-mcp@latest %*')
|
|
88
|
+
})
|
|
89
|
+
it('escapes percent signs in the path', () => {
|
|
90
|
+
expect(windowsLauncher('C:\\50%\\node.exe')).toContain('C:\\50%%\\node.exe')
|
|
91
|
+
})
|
|
92
|
+
it('is what setup writes for Windows, started through cmd', async () => {
|
|
93
|
+
const home = await mkdtemp(join(tmpdir(), 'writedock-launcher-'))
|
|
94
|
+
const written = await writeLauncher(home, 'win32', 'C:\\node.exe')
|
|
95
|
+
expect(written.command).toBe('cmd')
|
|
96
|
+
expect(written.args).toEqual(['/c', written.path])
|
|
97
|
+
expect(await readFile(written.path, 'utf8')).toContain('C:\\node.exe')
|
|
98
|
+
await rm(home, { recursive: true, force: true })
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -18,11 +18,11 @@ describe('setup', () => {
|
|
|
18
18
|
const result = await readFile(join(home, '.codex/config.toml'), 'utf8')
|
|
19
19
|
expect(result.startsWith(original.trimEnd())).toBe(true)
|
|
20
20
|
expect(result).toContain('[mcp_servers.writedock]')
|
|
21
|
-
expect(result).toContain('
|
|
21
|
+
expect(result).toContain('start-mcp.sh')
|
|
22
22
|
expect(await readFile(join(home, '.codex/config.toml.writedock-backup'), 'utf8')).toBe(original)
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
it('
|
|
25
|
+
it('points clients at the launcher on Windows, and finds Claude Desktop in its Windows folder', async () => {
|
|
26
26
|
const appData = process.env.APPDATA
|
|
27
27
|
delete process.env.APPDATA
|
|
28
28
|
try {
|
|
@@ -31,16 +31,59 @@ describe('setup', () => {
|
|
|
31
31
|
const desktop = join(home, 'AppData/Roaming/Claude')
|
|
32
32
|
await mkdir(desktop, { recursive: true })
|
|
33
33
|
await writeFile(join(desktop, 'claude_desktop_config.json'), '\uFEFF{"theme":"dark"}')
|
|
34
|
-
expect(await runSetup(home, line => log.push(line), 'win32')).toBe(2)
|
|
34
|
+
expect(await runSetup(home, line => log.push(line), 'win32', 'C:\\node.exe')).toBe(2)
|
|
35
|
+
const launcher = join(home, 'WriteDock/start-mcp.cmd')
|
|
35
36
|
const codex = await readFile(join(home, '.codex/config.toml'), 'utf8')
|
|
36
37
|
expect(codex).toContain('command = "cmd"')
|
|
37
|
-
expect(codex).toContain('
|
|
38
|
+
expect(codex).toContain(JSON.stringify(['/c', launcher]))
|
|
38
39
|
const config = JSON.parse(await readFile(join(desktop, 'claude_desktop_config.json'), 'utf8'))
|
|
39
40
|
expect(config.theme).toBe('dark')
|
|
40
|
-
expect(config.mcpServers.writedock).toEqual({ command: 'cmd', args: ['/c',
|
|
41
|
+
expect(config.mcpServers.writedock).toEqual({ command: 'cmd', args: ['/c', launcher] })
|
|
42
|
+
expect(await readFile(launcher, 'utf8')).toContain('C:\\node.exe')
|
|
41
43
|
} finally { if (appData !== undefined) process.env.APPDATA = appData }
|
|
42
44
|
})
|
|
43
45
|
|
|
46
|
+
it('configures WorkBuddy when it is installed, even before it has an MCP file', async () => {
|
|
47
|
+
await mkdir(join(home, '.workbuddy'))
|
|
48
|
+
expect(await runSetup(home, line => log.push(line), 'win32', 'C:\\node.exe')).toBe(1)
|
|
49
|
+
const config = JSON.parse(await readFile(join(home, '.workbuddy/mcp.json'), 'utf8'))
|
|
50
|
+
expect(config.mcpServers.writedock.command).toBe('cmd')
|
|
51
|
+
expect(log.join()).toContain('WorkBuddy')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('moves an entry from an earlier setup to the launcher, in JSON and in Codex, with a backup', async () => {
|
|
55
|
+
await mkdir(join(home, '.workbuddy'))
|
|
56
|
+
const old = { mcpServers: { writedock: { command: 'cmd', args: ['/c', 'npx', '-y', '--prefer-online', 'writedock-mcp@latest'] }, other: { command: 'x' } } }
|
|
57
|
+
await writeFile(join(home, '.workbuddy/mcp.json'), JSON.stringify(old))
|
|
58
|
+
await mkdir(join(home, '.codex'))
|
|
59
|
+
const toml = 'model = "x"\n\n[mcp_servers.writedock]\ncommand = "npx"\nargs = ["-y","--prefer-online","writedock-mcp@latest"]\nstartup_timeout_sec = 120\n\n[mcp_servers.other]\ncommand = "other"\n'
|
|
60
|
+
await writeFile(join(home, '.codex/config.toml'), toml)
|
|
61
|
+
await runSetup(home, line => log.push(line), 'win32', 'C:\\node.exe')
|
|
62
|
+
const config = JSON.parse(await readFile(join(home, '.workbuddy/mcp.json'), 'utf8'))
|
|
63
|
+
expect(config.mcpServers.writedock.args[1]).toContain('start-mcp')
|
|
64
|
+
expect(config.mcpServers.other).toEqual({ command: 'x' })
|
|
65
|
+
const codex = await readFile(join(home, '.codex/config.toml'), 'utf8')
|
|
66
|
+
expect(codex).toContain('start-mcp')
|
|
67
|
+
expect(codex).not.toContain('writedock-mcp@latest')
|
|
68
|
+
expect(codex).toContain('[mcp_servers.other]\ncommand = "other"')
|
|
69
|
+
expect(codex).toContain('model = "x"')
|
|
70
|
+
expect(await readFile(join(home, '.codex/config.toml.writedock-backup'), 'utf8')).toBe(toml)
|
|
71
|
+
expect(log.join()).toContain('已更新')
|
|
72
|
+
// A second run changes nothing.
|
|
73
|
+
log.length = 0
|
|
74
|
+
await runSetup(home, line => log.push(line), 'win32', 'C:\\node.exe')
|
|
75
|
+
expect(log.join()).not.toContain('已更新')
|
|
76
|
+
expect(await readFile(join(home, '.codex/config.toml'), 'utf8')).toBe(codex)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('leaves a JSON entry with custom settings alone', async () => {
|
|
80
|
+
await mkdir(join(home, '.cursor'))
|
|
81
|
+
const original = JSON.stringify({ mcpServers: { writedock: { command: 'npx', args: ['writedock-mcp@latest'], env: { WRITEDOCK_PORT: '9700' } } } })
|
|
82
|
+
await writeFile(join(home, '.cursor/mcp.json'), original)
|
|
83
|
+
await runSetup(home, line => log.push(line))
|
|
84
|
+
expect(await readFile(join(home, '.cursor/mcp.json'), 'utf8')).toBe(original)
|
|
85
|
+
})
|
|
86
|
+
|
|
44
87
|
it('leaves an existing entry alone, including its custom port and token', async () => {
|
|
45
88
|
await mkdir(join(home, '.codex'))
|
|
46
89
|
const original = '[mcp_servers.writedock]\ncommand = "node"\n\n[mcp_servers.writedock.env]\nWRITEDOCK_PORT = "9700"\n'
|
|
@@ -62,8 +105,8 @@ describe('setup', () => {
|
|
|
62
105
|
expect(log.join()).toContain('配置失败')
|
|
63
106
|
})
|
|
64
107
|
|
|
65
|
-
it('
|
|
108
|
+
it('changes no client when none exists, and prints the configuration', async () => {
|
|
66
109
|
expect(await runSetup(home, line => log.push(line))).toBe(0)
|
|
67
|
-
expect(log.join()).toContain('
|
|
110
|
+
expect(log.join()).toContain('start-mcp')
|
|
68
111
|
})
|
|
69
112
|
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { chmod, mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const NPX_ARGS = '-y --prefer-online writedock-mcp@latest'
|
|
5
|
+
const VERSION_CHECK = `process.exit(Number(process.versions.node.split('.')[0])>=22?0:1)`
|
|
6
|
+
const NO_NODE = 'WriteDock 需要 Node.js 22 或更高版本,这台电脑上没有找到。请安装 https://nodejs.org 的 LTS 版,然后重启 agent。'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Seen live: an agent ran setup with its own Node 22, then started MCP with the system's Node 16, which cannot run it.
|
|
10
|
+
* So the agent's config points at this script. It looks for a usable Node in order: the one setup ran with (an agent
|
|
11
|
+
* update can remove that folder), the system's, then the places they are usually kept. It puts that Node first on
|
|
12
|
+
* PATH, because npx starts the package through whichever node comes first there.
|
|
13
|
+
*/
|
|
14
|
+
export function posixLauncher(nodePath: string) {
|
|
15
|
+
const recorded = `'${nodePath.replace(/'/g, `'\\''`)}'`
|
|
16
|
+
return `#!/bin/sh
|
|
17
|
+
# Written by writedock-mcp setup. Running setup again replaces this file.
|
|
18
|
+
found=""
|
|
19
|
+
check() {
|
|
20
|
+
[ -n "$found" ] && return 0
|
|
21
|
+
[ -x "$1" ] || return 0
|
|
22
|
+
"$1" -e "${VERSION_CHECK}" >/dev/null 2>&1 && found="$1"
|
|
23
|
+
return 0
|
|
24
|
+
}
|
|
25
|
+
check ${recorded}
|
|
26
|
+
check "$(command -v node 2>/dev/null)"
|
|
27
|
+
check /opt/homebrew/bin/node
|
|
28
|
+
check /usr/local/bin/node
|
|
29
|
+
for candidate in "$HOME"/.workbuddy/binaries/node/versions/*/bin/node "$HOME"/.workbuddy/binaries/node/versions/*/node "$HOME"/.nvm/versions/node/*/bin/node; do
|
|
30
|
+
check "$candidate"
|
|
31
|
+
done
|
|
32
|
+
if [ -z "$found" ]; then
|
|
33
|
+
echo "${NO_NODE}" >&2
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
PATH="$(dirname "$found"):$PATH"
|
|
37
|
+
export PATH
|
|
38
|
+
exec npx ${NPX_ARGS} "$@"
|
|
39
|
+
`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function windowsLauncher(nodePath: string) {
|
|
43
|
+
const script = `@echo off
|
|
44
|
+
chcp 65001 >nul
|
|
45
|
+
rem Written by writedock-mcp setup. Running setup again replaces this file.
|
|
46
|
+
set "WRITEDOCK_NODE="
|
|
47
|
+
call :check "${nodePath.replace(/%/g, '%%')}"
|
|
48
|
+
if defined WRITEDOCK_NODE goto run
|
|
49
|
+
for /f "delims=" %%n in ('where node 2^>nul') do if not defined WRITEDOCK_NODE call :check "%%n"
|
|
50
|
+
if defined WRITEDOCK_NODE goto run
|
|
51
|
+
for /d %%d in ("%USERPROFILE%\\.workbuddy\\binaries\\node\\versions\\*") do if not defined WRITEDOCK_NODE call :check "%%d\\node.exe"
|
|
52
|
+
if defined WRITEDOCK_NODE goto run
|
|
53
|
+
for /d %%d in ("%NVM_HOME%\\v*") do if not defined WRITEDOCK_NODE call :check "%%d\\node.exe"
|
|
54
|
+
if defined WRITEDOCK_NODE goto run
|
|
55
|
+
echo ${NO_NODE} 1>&2
|
|
56
|
+
exit /b 1
|
|
57
|
+
:run
|
|
58
|
+
for %%d in ("%WRITEDOCK_NODE%") do set "PATH=%%~dpd;%PATH%"
|
|
59
|
+
call npx ${NPX_ARGS} %*
|
|
60
|
+
exit /b %errorlevel%
|
|
61
|
+
:check
|
|
62
|
+
if not exist "%~1" exit /b 0
|
|
63
|
+
"%~1" -e "${VERSION_CHECK}" >nul 2>nul
|
|
64
|
+
if not errorlevel 1 set "WRITEDOCK_NODE=%~1"
|
|
65
|
+
exit /b 0
|
|
66
|
+
`
|
|
67
|
+
return script.replace(/\n/g, '\r\n')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function writeLauncher(home: string, platform: NodeJS.Platform, nodePath: string) {
|
|
71
|
+
const directory = join(home, 'WriteDock')
|
|
72
|
+
await mkdir(directory, { recursive: true })
|
|
73
|
+
if (platform === 'win32') {
|
|
74
|
+
const path = join(directory, 'start-mcp.cmd')
|
|
75
|
+
await writeFile(path, windowsLauncher(nodePath))
|
|
76
|
+
return { path, command: 'cmd', args: ['/c', path] }
|
|
77
|
+
}
|
|
78
|
+
const path = join(directory, 'start-mcp.sh')
|
|
79
|
+
await writeFile(path, posixLauncher(nodePath))
|
|
80
|
+
await chmod(path, 0o755)
|
|
81
|
+
return { path, command: path, args: [] as string[] }
|
|
82
|
+
}
|
package/source/src/server.ts
CHANGED
|
@@ -38,7 +38,7 @@ export function createMcpServer(bridge: LocalBridge) {
|
|
|
38
38
|
const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
39
39
|
|
|
40
40
|
server.registerTool('get_connection_status', {
|
|
41
|
-
description: '检查本机 MCP 与扩展的连接、运行版本和 npm 更新提示。update_available=true 时告知用户重新启动 WriteDock MCP,保留配置;发布任务进行中不要自动重启。有 update_notice 时转告用户。extension_version 是用户正在用的扩展版本;没有这个字段表示版本未知,不得声称扩展与 MCP
|
|
41
|
+
description: '检查本机 MCP 与扩展的连接、运行版本和 npm 更新提示。update_available=true 时告知用户重新启动 WriteDock MCP,保留配置;发布任务进行中不要自动重启。有 update_notice 时转告用户。extension_version 是用户正在用的扩展版本;没有这个字段表示版本未知,不得声称扩展与 MCP 版本一致。这条 MCP 配置(包括 start-mcp 启动脚本)是有意的,用户靠它收到更新并使用正确的 Node,不要建议固定版本,也不要修改这条配置。旧版服务可能不返回版本字段;latest_version 缺失或 update_checking=true 时,不得声称已是最新版。', inputSchema: {}, annotations: { ...readOnly, openWorldHint: true },
|
|
42
42
|
}, () => wrap(() => bridge.status()))
|
|
43
43
|
server.registerTool('list_platforms', {
|
|
44
44
|
description: '列出扩展实际支持的平台、登录状态、publishModes、对应 tools 及 modeOptions 限制;小红书 article 对应 publish_article,image/video 对应 publish_post。不会返回 Cookie。',
|
package/source/src/setup.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
-
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
|
+
import { writeLauncher } from './launcher'
|
|
5
6
|
import { bundledExtension, defaultExtensionDirectory, findInstalledExtension, installExtensionFiles } from './extension-update'
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const serverCommand = (platform: NodeJS.Platform) => platform === 'win32'
|
|
10
|
-
? { command: 'cmd', args: ['/c', 'npx', ...NPX_ARGS] }
|
|
11
|
-
: { command: 'npx', args: NPX_ARGS }
|
|
8
|
+
type ServerCommand = { command: string; args: string[] }
|
|
9
|
+
const isOurs = (text: string) => text.includes('writedock-mcp@latest') || text.includes('start-mcp')
|
|
12
10
|
|
|
13
11
|
async function readIfPresent(path: string) {
|
|
14
12
|
// Windows editors may save a BOM, which JSON.parse rejects.
|
|
@@ -22,26 +20,38 @@ async function save(path: string, content: string, previous: string | undefined)
|
|
|
22
20
|
await writeFile(path, content)
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Keeps the user's other settings. An entry from an earlier setup moves to the launcher. Anything else stays,
|
|
25
|
+
* because it may hold a custom command, port or data directory.
|
|
26
|
+
*/
|
|
27
|
+
export async function configureCodex(path: string, server: ServerCommand): Promise<'added' | 'updated' | 'present'> {
|
|
27
28
|
const previous = await readIfPresent(path)
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
29
|
+
const lines = `command = ${JSON.stringify(server.command)}\nargs = ${JSON.stringify(server.args)}\nstartup_timeout_sec = 120\n`
|
|
30
|
+
const section = previous === undefined ? null : /^[ \t]*\[mcp_servers\.writedock\][^\n]*\n([\s\S]*?)(?=^[ \t]*\[|(?![\s\S]))/m.exec(previous)
|
|
31
|
+
if (section && previous !== undefined) {
|
|
32
|
+
const customised = /^[ \t]*\[mcp_servers\.writedock\./m.test(previous) || !isOurs(section[1])
|
|
33
|
+
const current = section[1].includes(JSON.stringify(server.args).slice(1, -1)) && section[1].includes(JSON.stringify(server.command))
|
|
34
|
+
if (customised || current) return 'present'
|
|
35
|
+
const start = section.index + section[0].length - section[1].length
|
|
36
|
+
await save(path, `${previous.slice(0, start)}${lines}\n${previous.slice(start + section[1].length)}`, previous)
|
|
37
|
+
return 'updated'
|
|
38
|
+
}
|
|
39
|
+
const entry = `[mcp_servers.writedock]\n${lines}`
|
|
31
40
|
await save(path, previous ? `${previous.replace(/\s*$/, '')}\n\n${entry}` : entry, previous)
|
|
32
41
|
return 'added'
|
|
33
42
|
}
|
|
34
43
|
|
|
35
|
-
export async function configureJsonClient(path: string,
|
|
44
|
+
export async function configureJsonClient(path: string, server: ServerCommand): Promise<'added' | 'updated' | 'present'> {
|
|
36
45
|
const previous = await readIfPresent(path)
|
|
37
|
-
const config = previous?.trim() ? JSON.parse(previous) as { mcpServers?: Record<string, unknown> } : {}
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
const config = previous?.trim() ? JSON.parse(previous) as { mcpServers?: Record<string, { command?: unknown; args?: unknown; env?: unknown }> } : {}
|
|
47
|
+
const existing = config.mcpServers?.writedock
|
|
48
|
+
if (existing && (existing.env || !isOurs(JSON.stringify(existing.args ?? [])) || JSON.stringify([existing.command, existing.args]) === JSON.stringify([server.command, server.args]))) return 'present'
|
|
49
|
+
config.mcpServers = { ...config.mcpServers, writedock: server }
|
|
40
50
|
await save(path, `${JSON.stringify(config, null, 2)}\n`, previous)
|
|
41
|
-
return 'added'
|
|
51
|
+
return existing ? 'updated' : 'added'
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
const exists =
|
|
54
|
+
const exists = (path: string) => access(path).then(() => true, () => false)
|
|
45
55
|
|
|
46
56
|
/** Best effort: shows the folder so the user can pick it, or drag it onto the extensions page. */
|
|
47
57
|
function revealFolder(path: string) {
|
|
@@ -50,15 +60,18 @@ function revealFolder(path: string) {
|
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
/** Only clients already on this computer are configured, so setup never creates settings for an app the user does not have. */
|
|
53
|
-
export async function runSetup(home = homedir(), log: (line: string) => void = console.log, platform: NodeJS.Platform = process.platform) {
|
|
63
|
+
export async function runSetup(home = homedir(), log: (line: string) => void = console.log, platform: NodeJS.Platform = process.platform, nodePath = process.execPath) {
|
|
64
|
+
const server = await writeLauncher(home, platform, nodePath)
|
|
65
|
+
const entry = { command: server.command, args: server.args }
|
|
54
66
|
const claudeDesktop = platform === 'win32'
|
|
55
67
|
? join(process.env.APPDATA || join(home, 'AppData/Roaming'), 'Claude/claude_desktop_config.json')
|
|
56
68
|
: platform === 'darwin'
|
|
57
69
|
? join(home, 'Library/Application Support/Claude/claude_desktop_config.json')
|
|
58
70
|
: join(home, '.config/Claude/claude_desktop_config.json')
|
|
59
|
-
const clients: { name: string; path: string; detect: string; configure: (path: string,
|
|
71
|
+
const clients: { name: string; path: string; detect: string; configure: (path: string, server: ServerCommand) => Promise<'added' | 'updated' | 'present'> }[] = [
|
|
60
72
|
{ name: 'Codex', path: join(home, '.codex/config.toml'), detect: join(home, '.codex/config.toml'), configure: configureCodex },
|
|
61
73
|
{ name: 'Claude Code', path: join(home, '.claude.json'), detect: join(home, '.claude.json'), configure: configureJsonClient },
|
|
74
|
+
{ name: 'WorkBuddy', path: join(home, '.workbuddy/mcp.json'), detect: join(home, '.workbuddy'), configure: configureJsonClient },
|
|
62
75
|
{ name: 'Cursor', path: join(home, '.cursor/mcp.json'), detect: join(home, '.cursor/mcp.json'), configure: configureJsonClient },
|
|
63
76
|
{ name: 'Claude Desktop', path: claudeDesktop, detect: claudeDesktop, configure: configureJsonClient },
|
|
64
77
|
]
|
|
@@ -67,13 +80,13 @@ export async function runSetup(home = homedir(), log: (line: string) => void = c
|
|
|
67
80
|
if (!await exists(client.detect)) continue
|
|
68
81
|
found++
|
|
69
82
|
try {
|
|
70
|
-
const state = await client.configure(client.path,
|
|
71
|
-
log(state === '
|
|
83
|
+
const state = await client.configure(client.path, entry)
|
|
84
|
+
log(state === 'present' ? `✓ ${client.name}:已有 WriteDock 配置,未改动` : `✓ ${client.name}:已${state === 'added' ? '添加' : '更新'} WriteDock(原配置备份为 .writedock-backup)`)
|
|
72
85
|
} catch (error) { log(`✗ ${client.name}:配置失败(${error instanceof Error ? error.message : String(error)}),原文件未改动`) }
|
|
73
86
|
}
|
|
74
87
|
if (!found) {
|
|
75
88
|
log('没有找到已知的 agent 客户端。请在你的客户端里添加这个 MCP 服务:')
|
|
76
|
-
log(JSON.stringify({ mcpServers: { writedock:
|
|
89
|
+
log(JSON.stringify({ mcpServers: { writedock: entry } }, null, 2))
|
|
77
90
|
}
|
|
78
91
|
// Keep whichever folder the browser already loads, so a repeat setup does not ask for another manual load.
|
|
79
92
|
const bundle = bundledExtension()
|