@luckychen1993/pm-mcp 0.1.5 → 0.3.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.
@@ -0,0 +1,146 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { atomicWrite } from "./store.js";
5
+ import { PACKAGE_SPEC, SERVER_NAME } from "./version.js";
6
+ export function canonicalProjectRoot(root) {
7
+ const absolute = path.resolve(root);
8
+ let real;
9
+ try {
10
+ real = fs.realpathSync.native(absolute);
11
+ }
12
+ catch (error) {
13
+ if (error.code !== "ENOENT")
14
+ throw error;
15
+ real = absolute;
16
+ }
17
+ return process.platform === "win32" ? real.toLowerCase() : real;
18
+ }
19
+ export function codexProjectServerKey(root) {
20
+ const canonical = canonicalProjectRoot(root);
21
+ const slug = path.basename(canonical).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
22
+ return `${SERVER_NAME}-${slug}-${createHash("sha256").update(canonical).digest("hex").slice(0, 12)}`;
23
+ }
24
+ function stringValue(value) {
25
+ return value.startsWith("'") ? value.slice(1, -1) : JSON.parse(value);
26
+ }
27
+ /** 仅处理本安装器的单行参数形式;复杂 TOML 明确拒绝,不猜测或输出配置原文。 */
28
+ export function findCodexProjectEntry(text, root) {
29
+ if (text.includes('"""') || text.includes("'''"))
30
+ throw new Error("配置包含多行字符串,自动升级无法安全确认段落边界;未修改配置。");
31
+ const canonical = canonicalProjectRoot(root), expected = codexProjectServerKey(root);
32
+ const matches = [];
33
+ const headers = [...text.matchAll(/^(?:\uFEFF)?[ \t]*\[mcp_servers\.(?:([A-Za-z0-9_-]+)|"([^"\r\n]+)"|'([^'\r\n]+)')\][ \t]*(?:#.*)?\r?$/gm)];
34
+ for (const header of headers) {
35
+ const name = header[1] ?? header[2] ?? header[3];
36
+ if (name !== SERVER_NAME && !name.startsWith(SERVER_NAME + "-"))
37
+ continue;
38
+ const start = header.index + header[0].length;
39
+ const next = text.slice(start).search(/^[ \t]*\[/m);
40
+ const end = next < 0 ? text.length : start + next;
41
+ const section = text.slice(start, end);
42
+ const commands = [...section.matchAll(/^[ \t]*command[ \t]*=[ \t]*("[^\r\n]*"|'[^\r\n]*')[ \t]*(?:#.*)?\r?$/gm)];
43
+ const arrays = [...section.matchAll(/^[ \t]*args[ \t]*=[ \t]*(\[[^\r\n]*\])[ \t]*(?:#.*)?\r?$/gm)];
44
+ let entry;
45
+ try {
46
+ if (commands.length !== 1 || arrays.length !== 1)
47
+ throw new Error();
48
+ const command = stringValue(commands[0][1]), args = JSON.parse(arrays[0][1]);
49
+ if (typeof command !== "string" || !Array.isArray(args) || !args.every((value) => typeof value === "string"))
50
+ throw new Error();
51
+ const positions = args.flatMap((value, index) => value === "--root" ? [index] : []);
52
+ if (positions.length !== 1 || !path.isAbsolute(args[positions[0] + 1] ?? ""))
53
+ throw new Error();
54
+ const filters = [...section.matchAll(/^[ \t]*(enabled_tools|disabled_tools)[ \t]*=[ \t]*(\[[^\r\n]*\])/gm)];
55
+ entry = { name, command, args, root: canonicalProjectRoot(args[positions[0] + 1]), start, end,
56
+ enabled: !/^[ \t]*enabled[ \t]*=[ \t]*false\b/m.test(section),
57
+ toolFilters: [...section.matchAll(/^[ \t]*(?:enabled_tools|disabled_tools)[ \t]*=/gm)].length !== filters.length || filters.some((filter) => filter[1] === "enabled_tools" || JSON.parse(filter[2]).length > 0) };
58
+ }
59
+ catch { /* 无法识别的条目不会被覆盖 */ }
60
+ if (name === expected && entry?.root !== canonical)
61
+ throw new Error(`已有 [mcp_servers.${name}] 指向不同项目或无法确认 --root;未修改配置。`);
62
+ if (!entry && (name === SERVER_NAME || name === expected.slice(0, -13)))
63
+ throw new Error("旧 pm-mcp 条目的项目根无法确认,拒绝创建可能重复的启动项。");
64
+ if (entry?.root === canonical)
65
+ matches.push(entry);
66
+ }
67
+ if (matches.length > 1)
68
+ throw new Error("多个 pm-mcp 条目指向同一项目,需先明确保留哪个;未修改配置。");
69
+ return matches[0];
70
+ }
71
+ export function readCodexConfig(file) {
72
+ if (!fs.existsSync(file))
73
+ return "";
74
+ const stat = fs.lstatSync(file);
75
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2 * 1024 * 1024)
76
+ throw new Error("Codex 配置必须为有界普通文件,禁止链接。");
77
+ return fs.readFileSync(file, "utf8");
78
+ }
79
+ export function isPackageLaunch(entry) {
80
+ return /^npx(?:\.(?:cmd|exe))?$/i.test(path.basename(entry.command)) && entry.args.length === 4 && entry.args[0] === "-y" &&
81
+ /^@luckychen1993\/pm-mcp(?:@[^\s]+)?$/.test(entry.args[1]) && entry.args[2] === "--root";
82
+ }
83
+ /**
84
+ * 校验启动项指向本包已构建的本地入口(`dist/index.js` 或 `src/index.ts`)。
85
+ *
86
+ * 与 `isLocalLaunch` 的区别:**不要求 `--root`**。WorkBuddy 等多项目宿主
87
+ * 以会话工作区作为子进程 cwd,服务端按 cwd 解析项目根,此时参数里没有 `--root`。
88
+ */
89
+ export function isLocalServerEntry(entry) {
90
+ if (!/^node(?:\.exe)?$/i.test(path.basename(entry.command)) || entry.args.length < 1 || !path.isAbsolute(entry.args[0]))
91
+ return false;
92
+ const file = path.resolve(entry.args[0]);
93
+ if (!/^(?:index|cli)\.(?:js|ts)$/.test(path.basename(file)))
94
+ return false;
95
+ try {
96
+ const stat = fs.lstatSync(file);
97
+ return stat.isFile() && !stat.isSymbolicLink() && JSON.parse(fs.readFileSync(path.join(path.dirname(file), "..", "package.json"), "utf8")).name === "@luckychen1993/pm-mcp";
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ export function isLocalLaunch(entry) {
104
+ return entry.args.length === 3 && entry.args[1] === "--root" && isLocalServerEntry(entry);
105
+ }
106
+ export function appendCodexProjectConfig(file, root, force, dryRun, log, local) {
107
+ const absolute = path.resolve(file), existing = readCodexConfig(absolute);
108
+ const entry = findCodexProjectEntry(existing, root);
109
+ const desired = local ?? { command: "npx", args: ["-y", PACKAGE_SPEC, "--root", path.resolve(root).replace(/\\/g, "/")] };
110
+ let updated;
111
+ if (entry) {
112
+ if (!force) {
113
+ log(`[skip] Codex already has [mcp_servers.${entry.name}] for this project; --force 可升级,现有配置保留。`);
114
+ return;
115
+ }
116
+ if (!isPackageLaunch(entry) && !isLocalLaunch(entry))
117
+ throw new Error("当前启动项不是可识别的 pm-mcp npx/node 形式;未修改配置。");
118
+ if (entry.command === desired.command && JSON.stringify(entry.args) === JSON.stringify(desired.args)) {
119
+ log("[skip] 启动配置已是目标版本;仍需通过 doctor 核验连接。");
120
+ return;
121
+ }
122
+ const section = existing.slice(entry.start, entry.end);
123
+ const replace = (name, value, input) => input.replace(new RegExp(`^([ \\t]*${name}[ \\t]*=[ \\t]*)(?:\\[[^\\r\\n]*\\]|"[^\\r\\n]*"|'[^\\r\\n]*')([ \\t]*(?:#.*)?)(\\r?)$`, "m"), (_all, prefix, suffix, cr) => prefix + value + suffix + cr);
124
+ const patched = replace("args", JSON.stringify(desired.args), replace("command", JSON.stringify(desired.command), section));
125
+ updated = existing.slice(0, entry.start) + patched + existing.slice(entry.end);
126
+ const checked = findCodexProjectEntry(updated, root);
127
+ if (!checked || checked.command !== desired.command || JSON.stringify(checked.args) !== JSON.stringify(desired.args))
128
+ throw new Error("启动配置替换验证失败,未写入。");
129
+ log(`[${dryRun ? "plan" : "upgrade"}] Codex [mcp_servers.${entry.name}];仅替换 command/args,保留其余设置。`);
130
+ }
131
+ else {
132
+ const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
133
+ const name = codexProjectServerKey(root);
134
+ updated = existing + `${separator}\n[mcp_servers.${name}]\ncommand = ${JSON.stringify(desired.command)}\nargs = ${JSON.stringify(desired.args)}\n`;
135
+ log(`[${dryRun ? "plan" : "write"}] Codex: ${absolute}`);
136
+ log(`[entry] [mcp_servers.${name}] → --root ${path.resolve(root)}`);
137
+ }
138
+ if (dryRun)
139
+ return;
140
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
141
+ if (readCodexConfig(absolute) !== existing)
142
+ throw new Error("配置在准备期间发生变化;未写入,请重新预览。");
143
+ if (fs.existsSync(absolute))
144
+ fs.copyFileSync(absolute, `${absolute}.backup-${Date.now()}-${randomUUID()}`, fs.constants.COPYFILE_EXCL);
145
+ atomicWrite(absolute, updated);
146
+ }
package/dist/setup.js CHANGED
@@ -2,21 +2,24 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
- import { createHash, randomUUID } from "node:crypto";
5
+ import { randomUUID } from "node:crypto";
6
+ import { fileURLToPath } from "node:url";
6
7
  import { initProject } from "./init.js";
7
8
  import { isInitialized } from "./paths.js";
8
9
  import { refreshDerived } from "./dashboard.js";
9
- export const PACKAGE_SPEC = "@luckychen1993/pm-mcp@0.1.5";
10
- export const SERVER_NAME = "pm-mcp";
11
- const CLIENTS = ["codex", "claude", "zcode", "cursor", "vscode"];
10
+ import { PACKAGE_SPEC, SERVER_NAME } from "./version.js";
11
+ export { PACKAGE_SPEC, SERVER_NAME } from "./version.js";
12
+ import { appendCodexProjectConfig } from "./setup-project.js";
13
+ export { appendCodexProjectConfig, codexProjectServerKey } from "./setup-project.js";
14
+ const CLIENTS = ["codex", "claude", "zcode", "cursor", "vscode", "workbuddy"];
12
15
  export function parseSetupArgs(argv) {
13
16
  const options = { client: "auto", force: false, dryRun: false, help: false, project: false };
14
17
  for (let index = 0; index < argv.length; index += 1) {
15
18
  const arg = argv[index];
16
19
  if (arg === "--client") {
17
20
  const value = argv[index + 1];
18
- if (!value || !["auto", "all", "codex", "claude", "zcode", "cursor", "vscode", "print"].includes(value)) {
19
- throw new Error("--client must be auto, all, codex, claude, zcode, cursor, vscode, or print");
21
+ if (!value || !["auto", "all", "codex", "claude", "zcode", "cursor", "vscode", "workbuddy", "print"].includes(value)) {
22
+ throw new Error("--client must be auto, all, codex, claude, zcode, cursor, vscode, workbuddy, or print");
20
23
  }
21
24
  options.client = value;
22
25
  index += 1;
@@ -32,6 +35,9 @@ export function parseSetupArgs(argv) {
32
35
  else if (arg === "--force") {
33
36
  options.force = true;
34
37
  }
38
+ else if (arg === "--local") {
39
+ options.local = true;
40
+ }
35
41
  else if (arg === "--dry-run") {
36
42
  options.dryRun = true;
37
43
  }
@@ -42,8 +48,29 @@ export function parseSetupArgs(argv) {
42
48
  throw new Error(`unknown setup argument: ${arg}`);
43
49
  }
44
50
  }
51
+ if (options.local && options.client !== "codex" && options.client !== "workbuddy" && options.client !== "print")
52
+ throw new Error("--local 目前只支持 --client codex、workbuddy 或 print");
53
+ if (options.local && options.client === "codex" && !options.project)
54
+ throw new Error("Codex --local 需要 --project 明确项目根");
45
55
  return options;
46
56
  }
57
+ /**
58
+ * 本地已构建入口的启动定义。
59
+ *
60
+ * root 省略时不写 `--root`:宿主客户端会把**会话工作区**作为子进程 cwd,
61
+ * 服务端按 cwd 解析项目根。WorkBuddy 这类多项目宿主依赖这一行为,
62
+ * 显式钉根反而会把所有工作区都指到同一个项目。
63
+ */
64
+ export function localServerLaunch(root) {
65
+ const entry = fileURLToPath(new URL("../dist/index.js", import.meta.url));
66
+ if (!fs.existsSync(entry) || !fs.lstatSync(entry).isFile())
67
+ throw new Error("本地入口未构建,请先 npm run build。");
68
+ return { command: process.execPath, args: root ? [entry, "--root", path.resolve(root)] : [entry] };
69
+ }
70
+ /** 钉定单个项目根的本地启动定义(Codex `--project` 等场景)。 */
71
+ export function localProjectLaunch(root) {
72
+ return localServerLaunch(root);
73
+ }
47
74
  export function commandExists(command, env = process.env, platform = process.platform) {
48
75
  const searchPath = env.PATH ?? env.Path ?? "";
49
76
  const suffixes = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
@@ -69,6 +96,9 @@ export function detectClients(context) {
69
96
  found.push("cursor");
70
97
  if (context.commandExists("code"))
71
98
  found.push("vscode");
99
+ // WorkBuddy 桌面版:`codebuddy`/`workbuddy` 命令、WORKBUDDY_CONFIG_DIR,或既有配置目录任一命中即视为已安装。
100
+ if (context.commandExists("workbuddy") || context.commandExists("codebuddy") || Boolean(env.WORKBUDDY_CONFIG_DIR) || exists(path.join(home, ".workbuddy-ai")) || exists(path.join(home, ".workbuddy")))
101
+ found.push("workbuddy");
72
102
  return found;
73
103
  }
74
104
  function asObject(value, label) {
@@ -76,7 +106,10 @@ function asObject(value, label) {
76
106
  throw new Error(`${label} must be a JSON object`);
77
107
  return value;
78
108
  }
79
- export function mergeJsonServer(document, shape) {
109
+ function defaultJsonLaunch() {
110
+ return { command: "npx", args: ["-y", PACKAGE_SPEC], env: {} };
111
+ }
112
+ export function mergeJsonServer(document, shape, launch = defaultJsonLaunch()) {
80
113
  const root = asObject(document, "configuration root");
81
114
  let servers;
82
115
  if (shape === "zcode") {
@@ -89,14 +122,14 @@ export function mergeJsonServer(document, shape) {
89
122
  const currentServers = root.mcpServers === undefined ? (root.mcpServers = {}) : root.mcpServers;
90
123
  servers = asObject(currentServers, "mcpServers");
91
124
  }
92
- servers[SERVER_NAME] = { command: "npx", args: ["-y", PACKAGE_SPEC], env: {} };
125
+ servers[SERVER_NAME] = { ...launch, env: launch.env ?? {} };
93
126
  return root;
94
127
  }
95
128
  function backupName(file) {
96
129
  const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "");
97
130
  return `${file}.backup-${stamp}-${process.pid}-${randomUUID()}`;
98
131
  }
99
- export function writeJsonClientConfig(file, shape, dryRun = false) {
132
+ export function writeJsonClientConfig(file, shape, dryRun = false, launch) {
100
133
  const absolute = path.resolve(file);
101
134
  const exists = fs.existsSync(absolute);
102
135
  let document = {};
@@ -111,7 +144,7 @@ export function writeJsonClientConfig(file, shape, dryRun = false) {
111
144
  throw new Error(`invalid JSON in ${absolute}; no changes made: ${error instanceof Error ? error.message : String(error)}`);
112
145
  }
113
146
  }
114
- const merged = mergeJsonServer(document, shape);
147
+ const merged = mergeJsonServer(document, shape, launch);
115
148
  if (dryRun)
116
149
  return null;
117
150
  fs.mkdirSync(path.dirname(absolute), { recursive: true });
@@ -138,6 +171,26 @@ function codexConfigPath(env, home) {
138
171
  function zcodeConfigPath(env, home) {
139
172
  return path.join(env.ZCODE_HOME || path.join(home, ".zcode"), "cli", "config.json");
140
173
  }
174
+ /**
175
+ * WorkBuddy 桌面版的用户级 MCP 配置路径。
176
+ *
177
+ * 优先级:`WORKBUDDY_CONFIG_DIR` → `~/.workbuddy-ai`(当前版本数据目录)→ `~/.workbuddy`(旧版目录)→ 默认当前版本目录。
178
+ * 文件名固定 `mcp.json`,结构为标准 `{ "mcpServers": { ... } }`。
179
+ *
180
+ * 注意:WorkBuddy 的 MCP 子进程由会话工作区拉起,cwd 即当前项目,
181
+ * 因此这里**不写 `--root`**,一个条目即可服务所有工作区。
182
+ */
183
+ export function workbuddyConfigPath(env, home, exists) {
184
+ if (env.WORKBUDDY_CONFIG_DIR)
185
+ return path.join(env.WORKBUDDY_CONFIG_DIR, "mcp.json");
186
+ const current = path.join(home, ".workbuddy-ai");
187
+ if (exists(current))
188
+ return path.join(current, "mcp.json");
189
+ const legacy = path.join(home, ".workbuddy");
190
+ if (exists(legacy))
191
+ return path.join(legacy, "mcp.json");
192
+ return path.join(current, "mcp.json");
193
+ }
141
194
  function hasCodexServer(text) {
142
195
  return /^\s*\[mcp_servers\.(?:pm-mcp|"pm-mcp")\]\s*$/m.test(text);
143
196
  }
@@ -158,86 +211,6 @@ function appendCodexConfig(file, dryRun, log) {
158
211
  const block = `${separator}\n[mcp_servers.${SERVER_NAME}]\ncommand = "npx"\nargs = ["-y", "${PACKAGE_SPEC}"]\n`;
159
212
  fs.appendFileSync(absolute, block, "utf8");
160
213
  }
161
- function canonicalProjectRoot(projectRoot) {
162
- const absolute = path.resolve(projectRoot);
163
- let resolved;
164
- try {
165
- resolved = fs.realpathSync.native(absolute);
166
- }
167
- catch (error) {
168
- if (error.code !== "ENOENT")
169
- throw error;
170
- resolved = absolute;
171
- }
172
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
173
- }
174
- function legacyProjectServerKey(projectRoot) {
175
- const base = path.basename(projectRoot);
176
- const safe = base.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
177
- return `${SERVER_NAME}-${safe || "project"}`;
178
- }
179
- /** 完整规范路径参与命名,区分中文目录和位于不同父目录下的同名项目。 */
180
- export function codexProjectServerKey(projectRoot) {
181
- const canonical = canonicalProjectRoot(projectRoot);
182
- return `${legacyProjectServerKey(canonical)}-${createHash("sha256").update(canonical).digest("hex").slice(0, 12)}`;
183
- }
184
- /** 只解析本安装器生成的单行 JSON 兼容 args;手写复杂 TOML 不猜测其绑定。 */
185
- function projectEntry(text, server) {
186
- const header = new RegExp(`^\\s*\\[mcp_servers\\.(?:${server}|"${server}"|'${server}')\\]\\s*(?:#.*)?$`, "m").exec(text);
187
- if (!header)
188
- return undefined;
189
- const tail = text.slice(header.index + header[0].length);
190
- const nextHeader = tail.search(/^\s*\[/m);
191
- const section = nextHeader < 0 ? tail : tail.slice(0, nextHeader);
192
- const argsLine = /^\s*args\s*=\s*(\[[^\r\n]*\])\s*(?:#.*)?$/m.exec(section);
193
- try {
194
- const args = argsLine ? JSON.parse(argsLine[1]) : undefined;
195
- if (!Array.isArray(args) || !args.every((value) => typeof value === "string"))
196
- return {};
197
- const positions = args.flatMap((value, index) => value === "--root" ? [index] : []);
198
- if (positions.length !== 1 || !path.isAbsolute(args[positions[0] + 1] ?? ""))
199
- return {};
200
- return { root: canonicalProjectRoot(args[positions[0] + 1]) };
201
- }
202
- catch {
203
- return {};
204
- }
205
- }
206
- /**
207
- * --project 模式:把指定目录作为钉定项目写进 Codex config.toml(npx + --root)。
208
- * 桌面版 Codex 起 MCP 服务时 cwd 不在工作区,必须逐项目钉根;同名条目已存在则跳过(fail-closed,不产生重复 TOML 表头)。
209
- */
210
- export function appendCodexProjectConfig(file, projectRoot, force, dryRun, log) {
211
- const absolute = path.resolve(file);
212
- const server = codexProjectServerKey(projectRoot);
213
- const canonical = canonicalProjectRoot(projectRoot);
214
- const existing = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
215
- const entry = projectEntry(existing, server);
216
- const legacy = legacyProjectServerKey(canonical);
217
- const legacyEntry = projectEntry(existing, legacy);
218
- if (entry && entry.root !== canonical) {
219
- throw new Error(`已有 [mcp_servers.${server}] 指向不同项目或无法确认 --root;未修改配置。`);
220
- }
221
- if (entry || legacyEntry?.root === canonical) {
222
- const reused = entry ? server : legacy;
223
- if (!force)
224
- log(`[skip] Codex already has [mcp_servers.${reused}] for this project; nothing to change.`);
225
- else
226
- throw new Error(`Codex already has [mcp_servers.${reused}]; 请先手工删除该段再 --force 重加。`);
227
- return;
228
- }
229
- log(`[${dryRun ? "plan" : "write"}] Codex: ${absolute}`);
230
- if (dryRun)
231
- return;
232
- fs.mkdirSync(path.dirname(absolute), { recursive: true });
233
- if (fs.existsSync(absolute))
234
- fs.copyFileSync(absolute, backupName(absolute), fs.constants.COPYFILE_EXCL);
235
- const tomlRoot = path.resolve(projectRoot).replace(/\\/g, "/");
236
- const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
237
- const block = `${separator}\n[mcp_servers.${server}]\ncommand = "npx"\nargs = ["-y", ${JSON.stringify(PACKAGE_SPEC)}, "--root", ${JSON.stringify(tomlRoot)}]\n`;
238
- fs.appendFileSync(absolute, block, "utf8");
239
- log(`[entry] [mcp_servers.${server}] → --root ${tomlRoot}`);
240
- }
241
214
  function run(command, args, visible) {
242
215
  const result = spawnSync(command, args, {
243
216
  shell: false,
@@ -272,7 +245,7 @@ function configureCliClient(client, options, available, log) {
272
245
  function configureClient(client, options, context, log, projectRoot) {
273
246
  if (client === "codex") {
274
247
  if (options.project && projectRoot) {
275
- appendCodexProjectConfig(codexConfigPath(context.env, context.home), projectRoot, options.force, options.dryRun, log);
248
+ appendCodexProjectConfig(codexConfigPath(context.env, context.home), projectRoot, options.force, options.dryRun, log, options.local ? localProjectLaunch(projectRoot) : undefined);
276
249
  return;
277
250
  }
278
251
  if (configureCliClient("codex", options, context.commandExists("codex"), log))
@@ -293,6 +266,16 @@ function configureClient(client, options, context, log, projectRoot) {
293
266
  log(`[backup] ${backup}`);
294
267
  return;
295
268
  }
269
+ if (client === "workbuddy") {
270
+ const file = workbuddyConfigPath(context.env, context.home, context.exists);
271
+ log(`[${options.dryRun ? "plan" : "write"}] workbuddy: ${file}`);
272
+ // 默认写 npx 发布版;--local 绑定已构建入口,未发布候选走这条路径。
273
+ const launch = options.local ? localServerLaunch() : undefined;
274
+ const backup = writeJsonClientConfig(file, "standard", options.dryRun, launch);
275
+ if (backup)
276
+ log(`[backup] ${backup}`);
277
+ return;
278
+ }
296
279
  if (!context.commandExists("code"))
297
280
  throw new Error("code was not found in PATH");
298
281
  const definition = JSON.stringify({ name: SERVER_NAME, command: "npx", args: ["-y", PACKAGE_SPEC] });
@@ -303,9 +286,12 @@ function configureClient(client, options, context, log, projectRoot) {
303
286
  function printHelp(log) {
304
287
  log("pm-mcp setup — configure local AI coding clients");
305
288
  log("");
306
- log(`Usage: npx -y ${PACKAGE_SPEC} setup [--client auto|all|codex|claude|zcode|cursor|vscode|print] [--project [dir]] [--force] [--dry-run]`);
289
+ log(`Usage: npx -y ${PACKAGE_SPEC} setup [--client auto|all|codex|claude|zcode|cursor|vscode|workbuddy|print] [--project [dir]] [--force] [--dry-run] [--local]`);
307
290
  log("Default auto mode configures every detected supported client.");
308
291
  log("--project [dir]: 在 Codex 把该目录注册为钉定项目并初始化 .pm(新项目一条命令上手;默认当前目录)");
292
+ log("--local: 使用本包已构建的本地入口,适合未发布候选;--force 仅升级已确认项目的 command/args。");
293
+ log("workbuddy: 写入 <WorkBuddy 数据目录>/mcp.json(标准 mcpServers),不钉根——服务端按会话工作区解析项目根。");
294
+ log("升级后运行 pm-mcp doctor --root <项目目录> 核验;现有客户端仍需重新连接。");
309
295
  }
310
296
  export function runSetup(argv, overrides = {}) {
311
297
  const options = parseSetupArgs(argv);
@@ -315,7 +301,8 @@ export function runSetup(argv, overrides = {}) {
315
301
  return 0;
316
302
  }
317
303
  if (options.client === "print") {
318
- log(JSON.stringify({ mcpServers: { [SERVER_NAME]: { command: "npx", args: ["-y", PACKAGE_SPEC], env: {} } } }, null, 2));
304
+ const launch = options.local ? localProjectLaunch(options.projectDir ?? overrides.cwd ?? process.cwd()) : { command: "npx", args: ["-y", PACKAGE_SPEC] };
305
+ log(JSON.stringify({ mcpServers: { [SERVER_NAME]: { ...launch, env: {} } } }, null, 2));
319
306
  return 0;
320
307
  }
321
308
  const env = overrides.env ?? process.env;
@@ -370,8 +357,19 @@ export function runSetup(argv, overrides = {}) {
370
357
  if (options.project && selected.includes("codex")) {
371
358
  log("完成。请完全退出并重启 Codex,开新会话即可使用(工具名前缀 pm-mcp-<项目名>)。");
372
359
  }
360
+ else if (selected.length === 1 && selected[0] === "workbuddy") {
361
+ log("完成。请在 WorkBuddy 的「连接器管理 → 自定义连接器」中确认信任 pm-mcp,再重开会话调用 get_status 核验。");
362
+ }
373
363
  else {
374
- log("Done. Restart the configured client and ask it to call pm-mcp get_status.");
364
+ // 多客户端时逐条给出各自的重连/授权要求,避免只提其中一个造成误导。
365
+ const notes = [];
366
+ if (selected.includes("workbuddy"))
367
+ notes.push("WorkBuddy 需在「连接器管理 → 自定义连接器」确认信任 pm-mcp");
368
+ if (selected.includes("codex"))
369
+ notes.push("Codex 需完全退出并重启");
370
+ log(notes.length > 0
371
+ ? `完成。重启已配置的客户端后调用 get_status 核验;${notes.join(";")}。`
372
+ : "Done. Restart the configured client and ask it to call pm-mcp get_status.");
375
373
  }
376
374
  return 0;
377
375
  }
@@ -0,0 +1,110 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { z } from "zod";
5
+ import { fingerprintProject } from "./project-fingerprint.js";
6
+ import { changedGitFiles, projectFilePath } from "./git-state.js";
7
+ import { SCAN_IGNORE_DIRS, ignoreMatcher, projectScanIgnores } from "./scan-policy.js";
8
+ import { loadProject } from "./store.js";
9
+ import { evidenceLevel, weakestEvidence } from "./quality-evidence.js";
10
+ const fingerprint = z.object({ algorithm: z.literal("sha256-tree-v1"), sha256: z.string().regex(/^[a-f0-9]{64}$/) });
11
+ const count = z.number().int().nonnegative().nullable();
12
+ const reportSchema = z.object({
13
+ schema_version: z.literal(2), run_at: z.string().datetime(), ok: z.boolean(), execute: z.boolean(),
14
+ source: z.object({ before: fingerprint, after: fingerprint, stable: z.boolean() }).nullable(),
15
+ results: z.array(z.object({
16
+ unit_root: z.string(), kind: z.string(), command: z.string().min(1), args: z.array(z.string()),
17
+ status: z.string(), exit_code: z.number().nullable(), signal: z.string().nullable(), output_truncated: z.boolean(),
18
+ output_summary: z.object({ tests: count, passed: count.optional(), failed: count, skipped: count, cancelled: count, todo: count, coverage: z.unknown().optional() }),
19
+ })).min(1),
20
+ });
21
+ /** 只读取当前项目内生成的质量报告;链接、损坏和旧格式不冒充证据。 */
22
+ export function verifiedTaskEvidence(root, requested, files = []) {
23
+ const absolute = fs.realpathSync.native(path.resolve(root));
24
+ const anchors = files.map(projectFilePath);
25
+ if (anchors.length === 0)
26
+ throw new Error("完成开发/修复任务需要通过 files 关联实际实现或测试文件。");
27
+ const excluded = ignoreMatcher(projectScanIgnores(root));
28
+ for (const rel of anchors) {
29
+ const parts = rel.split("/");
30
+ if (rel === "PROJECT.md" || parts.some((part, index) => (index < parts.length - 1 && SCAN_IGNORE_DIRS.has(part)) || excluded(parts.slice(0, index + 1).join("/"), index < parts.length - 1))) {
31
+ throw new Error(`任务文件不在源码验证范围内: ${rel}`);
32
+ }
33
+ for (let index = 0; index < parts.length; index++) {
34
+ const target = path.join(absolute, ...parts.slice(0, index + 1));
35
+ let stat;
36
+ try {
37
+ stat = fs.lstatSync(target);
38
+ }
39
+ catch (error) {
40
+ if (error.code !== "ENOENT")
41
+ throw error;
42
+ if (!changedGitFiles(absolute).includes(rel))
43
+ throw new Error(`任务关联文件不存在,也没有 Git 删除记录: ${rel}`);
44
+ break;
45
+ }
46
+ if (stat.isSymbolicLink() || (index === parts.length - 1 ? !stat.isFile() : !stat.isDirectory())) {
47
+ throw new Error(`任务必须关联项目内的普通文件,禁止链接或目录: ${rel}`);
48
+ }
49
+ }
50
+ }
51
+ const directory = path.join(absolute, ".pm", "quality-runs");
52
+ for (const part of [path.join(absolute, ".pm"), directory]) {
53
+ if (!fs.existsSync(part))
54
+ throw new Error("完成开发/修复任务前请执行 run_quality_matrix,缺少质量验证报告。");
55
+ const stat = fs.lstatSync(part);
56
+ if (!stat.isDirectory() || stat.isSymbolicLink())
57
+ throw new Error("质量证据目录必须是项目内的普通目录。");
58
+ }
59
+ const reports = fs.readdirSync(directory).filter((name) => /^quality-.*\.json$/.test(name)).map((name) => {
60
+ const file = path.join(directory, name);
61
+ const stat = fs.lstatSync(file);
62
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024)
63
+ throw new Error("质量证据必须是有界普通文件,禁止符号链接。");
64
+ const raw = fs.readFileSync(file, "utf8");
65
+ const value = JSON.parse(raw);
66
+ const header = z.object({ run_at: z.string().datetime() }).safeParse(value);
67
+ if (!header.success)
68
+ throw new Error(`质量报告 ${name} 缺少有效生成时间,无法判断新鲜度。`);
69
+ return { relative: `.pm/quality-runs/${name}`, raw, value, runAt: header.data.run_at };
70
+ }).sort((a, b) => b.runAt.localeCompare(a.runAt) || b.relative.localeCompare(a.relative));
71
+ const latest = reports[0];
72
+ if (!latest)
73
+ throw new Error("缺少质量验证报告,请先执行 run_quality_matrix。");
74
+ if (requested !== undefined && projectFilePath(requested) !== latest.relative)
75
+ throw new Error("必须使用最新质量报告,不能绕过后来出现的验证结果。");
76
+ const parsed = reportSchema.safeParse(latest.value);
77
+ if (!parsed.success)
78
+ throw new Error("最新质量报告无效或格式过旧,请重新执行验证。");
79
+ const report = parsed.data;
80
+ if (!report.ok || !report.execute || !report.source?.stable || report.source.before.sha256 !== report.source.after.sha256 ||
81
+ report.results.some((item) => item.status !== "passed" || item.exit_code !== 0 || item.signal !== null || item.output_truncated ||
82
+ [item.output_summary.failed, item.output_summary.skipped, item.output_summary.cancelled, item.output_summary.todo].some((n) => n !== null && n > 0))) {
83
+ throw new Error("最新质量验证失败、未执行、输出不完整或存在跳过项,不能标记完成。");
84
+ }
85
+ const tests = report.results.filter((item) => item.kind === "test" || item.kind === "coverage");
86
+ if (tests.length === 0 || tests.some((item) => item.output_summary.tests === 0))
87
+ throw new Error("完成开发/修复任务需要实际测试或覆盖率执行证据。");
88
+ const level = weakestEvidence(tests.map((item) => evidenceLevel(item.kind, item.output_summary, item.output_truncated)));
89
+ const policy = loadProject(root).completion_evidence;
90
+ if (level === "execution_only" && policy?.minimum !== "execution_only") {
91
+ throw new Error("仅有命令执行证据,未观察到完整测试计数。默认完成策略要求 tests_observed;请使用可识别的测试摘要,或由项目负责人明确设置兼容策略并说明理由。");
92
+ }
93
+ const within = (base, target) => {
94
+ const relative = path.relative(base, target);
95
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
96
+ };
97
+ if (tests.some((item) => !path.isAbsolute(item.unit_root)))
98
+ throw new Error("质量报告的执行目录不属于当前项目。");
99
+ const unitRoots = tests.map((item) => fs.realpathSync.native(item.unit_root));
100
+ if (unitRoots.some((unit) => !within(absolute, unit)))
101
+ throw new Error("质量报告的执行目录不属于当前项目。");
102
+ if (anchors.some((file) => !unitRoots.some((unit) => within(unit, path.join(absolute, file))))) {
103
+ throw new Error("质量报告的测试单元未覆盖任务关联文件所在目录。");
104
+ }
105
+ const current = fingerprintProject(absolute);
106
+ if (current.sha256 !== report.source.after.sha256)
107
+ throw new Error("质量验证对应的源码已变化,证据过期,请重新验证。");
108
+ const digest = createHash("sha256").update(latest.raw).digest("hex");
109
+ return `质量报告: ${latest.relative}\n报告 SHA-256: ${digest}\n源码 SHA-256: ${current.sha256}\n执行时间: ${report.run_at}\n证据等级: ${level}${policy ? `\n项目策略: ${policy.minimum};${policy.reason}` : "\n项目策略: tests_observed(默认)"}`;
110
+ }
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { z } from "zod";
5
+ import { loadTasks } from "./store.js";
6
+ import { TaskStatusSchema, TaskTypeSchema } from "./types.js";
7
+ const CursorSchema = z.object({ v: z.literal(1), revision: z.string().regex(/^[a-f0-9]{64}$/), offset: z.number().int().positive(), size: z.number().int().min(1).max(100) }).strict();
8
+ export const TaskPageSchema = z.object({
9
+ total: z.number().int().nonnegative(), all_total: z.number().int().nonnegative(),
10
+ offset: z.number().int().nonnegative(), page_size: z.number().int().min(1).max(100), returned: z.number().int().nonnegative(),
11
+ has_more: z.boolean(), next_cursor: z.string().nullable(), revision: z.string(),
12
+ items: z.array(z.object({ id: z.string(), title: z.string(), status: TaskStatusSchema, type: TaskTypeSchema,
13
+ priority: z.string().nullable(), milestone: z.string().nullable(), next_step: z.string().nullable(), abbreviated: z.boolean() })),
14
+ });
15
+ /** 游标是读取位置,不是授权凭据;绑定项目、过滤及同一份已读取的账本快照。 */
16
+ export function taskPage(root, query, maximum = 100, data = loadTasks(root)) {
17
+ const filters = { status: query.status ?? null, type: query.type ?? null, milestone: query.milestone ?? null, tag: query.tag ?? null, include_done: query.include_done ?? false };
18
+ let canonical = fs.realpathSync.native(path.resolve(root));
19
+ if (process.platform === "win32")
20
+ canonical = canonical.toLowerCase();
21
+ const revision = createHash("sha256").update(canonical).update("\0").update(JSON.stringify(filters)).update("\0").update(JSON.stringify(data)).digest("hex");
22
+ let cursor;
23
+ if (query.cursor !== undefined) {
24
+ try {
25
+ if (query.cursor.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(query.cursor))
26
+ throw new Error();
27
+ cursor = CursorSchema.parse(JSON.parse(Buffer.from(query.cursor, "base64url").toString("utf8")));
28
+ }
29
+ catch {
30
+ throw new Error("分页游标无效,请从第一页重新读取。");
31
+ }
32
+ if (cursor.revision !== revision)
33
+ throw new Error("任务数据、项目或过滤条件已变化,游标失效,请重新读取第一页。");
34
+ }
35
+ const limit = Math.max(1, Math.min(100, Math.floor(maximum)));
36
+ const requested = query.page_size ?? cursor?.size ?? 25;
37
+ if (!Number.isInteger(requested) || requested < 1 || requested > 100)
38
+ throw new Error("page_size 必须为 1 到 100。");
39
+ const size = Math.min(requested, limit);
40
+ if (cursor && (cursor.size !== size || cursor.offset % size !== 0))
41
+ throw new Error("分页大小或输出预算已变化,请重新读取第一页。");
42
+ const list = data.tasks.filter((task) => (query.status ? task.status === query.status : query.include_done || !["done", "cancelled"].includes(task.status)) &&
43
+ (!query.type || task.type === query.type) && (!query.milestone || task.milestone === query.milestone) && (!query.tag || task.tags.includes(query.tag)));
44
+ const offset = cursor?.offset ?? 0;
45
+ if (cursor && offset >= list.length)
46
+ throw new Error("分页游标越界,请重新读取第一页。");
47
+ const brief = (text, length) => { const one = text.replace(/\s+/g, " "); return one.length > length ? one.slice(0, length) + "…" : one; };
48
+ const items = list.slice(offset, offset + size).map((task) => {
49
+ const title = brief(task.title, 160), nextStep = task.checkpoint ? brief(task.checkpoint.next_step, 80) : null;
50
+ return { id: task.id, title, status: task.status, type: task.type, priority: task.priority, milestone: task.milestone,
51
+ next_step: nextStep, abbreviated: title !== task.title || nextStep !== (task.checkpoint?.next_step ?? null) };
52
+ });
53
+ const next = offset + items.length;
54
+ return { total: list.length, all_total: data.tasks.length, offset, page_size: size, returned: items.length, has_more: next < list.length,
55
+ next_cursor: next < list.length ? Buffer.from(JSON.stringify({ v: 1, revision, offset: next, size })).toString("base64url") : null, revision, items };
56
+ }
57
+ export function taskPageText(page) {
58
+ return [
59
+ `共 ${page.total} 个任务(全部 ${page.all_total} 个);本页 ${page.offset + (page.returned ? 1 : 0)}-${page.offset + page.returned},返回 ${page.returned} 个。`,
60
+ ...page.items.map((task) => `${task.status === "in_progress" ? "🔄" : task.status === "blocked" ? "🚫" : task.status === "done" ? "✅" : "☐"} ${task.id} [${task.status}/${task.type}${task.priority ? `/${task.priority}` : ""}] ${task.title}${task.next_step ? ` → ${task.next_step}` : ""}`),
61
+ page.next_cursor ? `下一页:保持过滤条件,list_tasks 传 cursor=${page.next_cursor}` : "已到末页,无更多任务。",
62
+ ...(page.items.some((task) => task.abbreviated) ? ["长标题或下一步已缩略;任务编号与分页条目完整。"] : []),
63
+ ].join("\n");
64
+ }