@reconcrap/boss-recruit-mcp 1.0.15 → 1.0.17

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 CHANGED
@@ -25,10 +25,12 @@ npx @reconcrap/boss-recruit-mcp install
25
25
  - 安装 Codex skill 到 `$CODEX_HOME/skills/boss-recruit-pipeline`
26
26
  - 初始化用户配置到 `$CODEX_HOME/boss-recruit-mcp/screening-config.json`
27
27
  - 生成通用 MCP 配置模板到 `$CODEX_HOME/boss-recruit-mcp/agent-mcp-configs`
28
+ - 自动尝试写入已检测到的外部 agent MCP 配置(含 Trae / trae-cn / Cursor / Claude / OpenClaw)
29
+ - 自动尝试把 skill 镜像到已检测到的外部 agent skills 目录
28
30
  - 包内自带 `boss-search-cli` 与 `boss-screen-cli` 运行时文件,无需额外目录结构
29
31
  - 不包含 `favorite-calibration.json`,首次使用前需要自行校准生成
30
32
 
31
- ## 跨 Agent 快速接入(Cursor / Trae / Claude Code / OpenClaw)
33
+ ## 跨 Agent 快速接入(Cursor / Trae / trae-cn / Claude Code / OpenClaw)
32
34
 
33
35
  生成 MCP 配置模板:
34
36
 
@@ -58,6 +60,7 @@ $CODEX_HOME/boss-recruit-mcp/agent-mcp-configs
58
60
  boss-recruit-mcp mcp-config --client cursor
59
61
  boss-recruit-mcp mcp-config --client claudecode
60
62
  boss-recruit-mcp mcp-config --client trae
63
+ boss-recruit-mcp mcp-config --client trae-cn
61
64
  boss-recruit-mcp mcp-config --client openclaw
62
65
  boss-recruit-mcp mcp-config --client generic
63
66
  ```
@@ -73,6 +76,13 @@ boss-recruit-mcp mcp-config --client generic
73
76
  boss-recruit-mcp mcp-config --client generic --command boss-recruit-mcp --args-json "[\"start\"]"
74
77
  ```
75
78
 
79
+ 可选环境变量(用于跨 agent 自动配置):
80
+
81
+ ```bash
82
+ BOSS_RECRUIT_MCP_CONFIG_TARGETS # JSON 数组或系统 path 分隔路径列表,指定额外 mcp.json 目标文件
83
+ BOSS_RECRUIT_EXTERNAL_SKILL_DIRS # JSON 数组或系统 path 分隔路径列表,指定额外 skills 根目录
84
+ ```
85
+
76
86
  ## 准备配置
77
87
 
78
88
  1. 初始化后编辑用户配置文件:
@@ -224,6 +234,7 @@ boss-recruit-mcp doctor --port <port>
224
234
  - 确认后自动执行:搜索 CLI -> 点击搜索 -> 勾选“过滤近14天查看”(按轮次规则) -> 筛选 CLI
225
235
  - 返回摘要:目标数、已处理、通过数、耗时、输出 CSV
226
236
  - 执行前会先做本地依赖预检查,若目录 / 入口 / 配置文件缺失则返回 `PIPELINE_PREFLIGHT_FAILED`
237
+ - preflight 会检查 Node.js 与 npm 依赖(`chrome-remote-interface` / `ws`);失败时会返回 `diagnostics.recovery`(含有序修复步骤与 `agent_prompt`)
227
238
  - 若缺少 `favorite-calibration.json`,会返回 `CALIBRATION_REQUIRED`
228
239
  - 若某轮搜索返回可筛选候选人但筛选 `processed_count` 非法或为 0,会先导出当前累计 CSV,再返回 `SCREEN_NO_PROGRESS`
229
240
  - 若当前运行环境不允许启动子进程,会返回更明确的权限错误码而不是笼统失败
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recruit-mcp",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "Unified MCP pipeline for boss-search-cli and boss-screen-cli",
5
5
  "keywords": [
6
6
  "boss",
@@ -289,6 +289,8 @@ description: "Use when users ask to recruit candidates on Boss Zhipin via the bo
289
289
  - 不要把底层 stderr 原样大段贴给用户,只提炼关键错误和下一步。
290
290
  - 如果失败原因明显是环境问题,要直接说明不是用户输入有误。
291
291
  - 如果工具已经返回 `diagnostics.checks`,优先基于这些检查项生成排障建议。
292
+ - 如果返回 `PIPELINE_PREFLIGHT_FAILED` 且含 `diagnostics.recovery.agent_prompt`,优先把该提示词直接交给 AI agent 自动修复依赖。
293
+ - 自动修复依赖必须严格串行:先 Node.js,再 npm 依赖;若涉及 Python / Pillow,先 Python 再 Pillow。
292
294
  - 如果工具返回 `output_csv`,在摘要里给出路径,避免重复解释内部流程。
293
295
  - 如果端口还没确认,必须先问用户“是否使用推荐的 `9222`,还是你已经有别的远程调试端口”,不能直接把 `9222` 当成已确认值。
294
296
  - 用户确认端口后,先执行一次 `boss-recruit-mcp set-port --port <port>`,让后续 `doctor / launch-chrome / calibrate / run` 自动复用同一端口。
package/src/adapters.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { spawn } from "node:child_process";
4
+ import { spawn, spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import CDP from "chrome-remote-interface";
7
7
  const currentFilePath = fileURLToPath(import.meta.url);
@@ -156,6 +156,117 @@ function runProcess({ command, args, cwd, timeoutMs }) {
156
156
  });
157
157
  }
158
158
 
159
+ function runProcessSync({ command, args, cwd }) {
160
+ try {
161
+ const result = spawnSync(command, args, {
162
+ cwd,
163
+ windowsHide: true,
164
+ shell: false,
165
+ env: process.env,
166
+ encoding: "utf8"
167
+ });
168
+ const stdout = String(result.stdout || "").trim();
169
+ const stderr = String(result.stderr || "").trim();
170
+ return {
171
+ ok: result.status === 0,
172
+ status: result.status,
173
+ stdout,
174
+ stderr,
175
+ output: [stdout, stderr].filter(Boolean).join("\n").trim(),
176
+ error_code: result.error?.code || null,
177
+ error_message: result.error?.message || null
178
+ };
179
+ } catch (error) {
180
+ return {
181
+ ok: false,
182
+ status: -1,
183
+ stdout: "",
184
+ stderr: "",
185
+ output: "",
186
+ error_code: error.code || "SPAWN_FAILED",
187
+ error_message: error.message || String(error)
188
+ };
189
+ }
190
+ }
191
+
192
+ function parseMajorVersion(raw) {
193
+ const match = String(raw || "").match(/v?(\d+)(?:\.\d+){0,2}/);
194
+ if (!match) return null;
195
+ const major = Number.parseInt(match[1], 10);
196
+ return Number.isFinite(major) ? major : null;
197
+ }
198
+
199
+ function buildNodeCommandCheck() {
200
+ const probe = runProcessSync({
201
+ command: "node",
202
+ args: ["--version"]
203
+ });
204
+ const major = parseMajorVersion(probe.output);
205
+ const versionOk = Number.isInteger(major) && major >= 18;
206
+ return {
207
+ key: "node_cli",
208
+ ok: probe.ok && versionOk,
209
+ path: "node --version",
210
+ message: probe.ok
211
+ ? (versionOk
212
+ ? `Node 命令可用 (${probe.output || "unknown version"})`
213
+ : `Node 版本过低 (${probe.output || "unknown version"}),要求 >= 18`)
214
+ : `未找到 node 命令,请先安装 Node.js >= 18。${probe.error_message ? ` (${probe.error_message})` : ""}`
215
+ };
216
+ }
217
+
218
+ function buildNodePackageCheck({ key, moduleName, cwd, missingMessage }) {
219
+ if (!cwd || !pathExists(cwd)) {
220
+ return {
221
+ key,
222
+ ok: false,
223
+ path: moduleName,
224
+ module: moduleName,
225
+ install_cwd: null,
226
+ message: missingMessage
227
+ };
228
+ }
229
+ const probe = runProcessSync({
230
+ command: "node",
231
+ args: ["-e", `require.resolve(${JSON.stringify(moduleName)});`],
232
+ cwd
233
+ });
234
+ return {
235
+ key,
236
+ ok: probe.ok,
237
+ path: moduleName,
238
+ module: moduleName,
239
+ install_cwd: cwd,
240
+ message: probe.ok
241
+ ? `${moduleName} npm 依赖可用`
242
+ : `缺少 npm 依赖 ${moduleName},请在 boss-recruit-mcp 目录执行 npm install。`
243
+ };
244
+ }
245
+
246
+ function buildRuntimeDependencyChecks({ searchDir, screenDir }) {
247
+ return [
248
+ buildNodeCommandCheck(),
249
+ buildNodePackageCheck({
250
+ key: "npm_dep_chrome_remote_interface_search",
251
+ moduleName: "chrome-remote-interface",
252
+ cwd: searchDir,
253
+ missingMessage: "无法校验 chrome-remote-interface:boss-search-cli 目录不存在。"
254
+ }),
255
+ buildNodePackageCheck({
256
+ key: "npm_dep_chrome_remote_interface_screen",
257
+ moduleName: "chrome-remote-interface",
258
+ cwd: screenDir,
259
+ missingMessage: "无法校验 chrome-remote-interface:boss-screen-cli 目录不存在。"
260
+ }),
261
+ buildNodePackageCheck({
262
+ key: "npm_dep_ws",
263
+ moduleName: "ws",
264
+ cwd: screenDir,
265
+ missingMessage: "无法校验 ws:boss-screen-cli 目录不存在。"
266
+ })
267
+ ];
268
+ }
269
+
159
270
  function parseSearchCount(output) {
160
271
  const m = output.match(/找到\s*(\d+)\s*个候选人/);
161
272
  if (!m) return null;
@@ -351,13 +462,18 @@ export function runPipelinePreflight(workspaceRoot) {
351
462
  message: "favorite-calibration.json 不存在(可选,仅在旧页面回退点击时需要)"
352
463
  }
353
464
  ];
465
+ checks.push(...buildRuntimeDependencyChecks({ searchDir, screenDir }));
354
466
 
355
467
  const requiredCheckKeys = new Set([
356
468
  "search_cli_dir",
357
469
  "search_cli_entry",
358
470
  "screen_cli_dir",
359
471
  "screen_cli_entry",
360
- "screen_config"
472
+ "screen_config",
473
+ "node_cli",
474
+ "npm_dep_chrome_remote_interface_search",
475
+ "npm_dep_chrome_remote_interface_screen",
476
+ "npm_dep_ws"
361
477
  ]);
362
478
 
363
479
  return {
package/src/cli.js CHANGED
@@ -28,14 +28,16 @@ const SUPPORTED_MCP_CLIENTS = ["generic", "cursor", "trae", "claudecode", "openc
28
28
  const DEFAULT_MCP_SERVER_NAME = "boss-recruit";
29
29
  const DEFAULT_MCP_COMMAND = "npx";
30
30
  const DEFAULT_MCP_ARGS = ["-y", "@reconcrap/boss-recruit-mcp@latest", "start"];
31
- const AUTO_SYNC_SKIP_COMMANDS = new Set([
32
- "install",
33
- "install-skill",
34
- "where",
35
- "help",
36
- "--help",
37
- "-h"
38
- ]);
31
+ const AUTO_SYNC_SKIP_COMMANDS = new Set([
32
+ "install",
33
+ "install-skill",
34
+ "where",
35
+ "help",
36
+ "--help",
37
+ "-h"
38
+ ]);
39
+ const EXTERNAL_MCP_TARGETS_ENV = "BOSS_RECRUIT_MCP_CONFIG_TARGETS";
40
+ const EXTERNAL_SKILL_DIRS_ENV = "BOSS_RECRUIT_EXTERNAL_SKILL_DIRS";
39
41
 
40
42
  function getPackageVersion() {
41
43
  try {
@@ -58,13 +60,46 @@ function getCodexHome() {
58
60
  : path.join(os.homedir(), ".codex");
59
61
  }
60
62
 
61
- function ensureDir(targetPath) {
62
- fs.mkdirSync(targetPath, { recursive: true });
63
- }
64
-
65
- function getDesktopDir() {
66
- return path.join(os.homedir(), "Desktop");
67
- }
63
+ function ensureDir(targetPath) {
64
+ fs.mkdirSync(targetPath, { recursive: true });
65
+ }
66
+
67
+ function pathExists(targetPath) {
68
+ try {
69
+ return fs.existsSync(targetPath);
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ function readJsonObjectFileSafe(filePath) {
76
+ if (!pathExists(filePath)) return {};
77
+ try {
78
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
79
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
80
+ return parsed;
81
+ }
82
+ } catch {
83
+ // Fallback below.
84
+ }
85
+ return {};
86
+ }
87
+
88
+ function dedupePaths(items) {
89
+ const result = [];
90
+ const seen = new Set();
91
+ for (const item of items || []) {
92
+ const resolved = path.resolve(String(item || ""));
93
+ if (!resolved || seen.has(resolved)) continue;
94
+ seen.add(resolved);
95
+ result.push(resolved);
96
+ }
97
+ return result;
98
+ }
99
+
100
+ function getDesktopDir() {
101
+ return path.join(os.homedir(), "Desktop");
102
+ }
68
103
 
69
104
  function getUserConfigPath() {
70
105
  return path.join(getCodexHome(), "boss-recruit-mcp", "screening-config.json");
@@ -146,12 +181,13 @@ function parseStringArrayOption(value, label) {
146
181
  return parsed;
147
182
  }
148
183
 
149
- function normalizeMcpClientName(value) {
150
- const raw = String(value || "").trim().toLowerCase();
151
- if (!raw) return "";
152
- if (raw === "claude-code") return "claudecode";
153
- return raw;
154
- }
184
+ function normalizeMcpClientName(value) {
185
+ const raw = String(value || "").trim().toLowerCase();
186
+ if (!raw) return "";
187
+ if (raw === "claude-code") return "claudecode";
188
+ if (raw === "trae-cn") return "trae";
189
+ return raw;
190
+ }
155
191
 
156
192
  function parseMcpClientTargets(rawValue) {
157
193
  if (!rawValue) return SUPPORTED_MCP_CLIENTS.slice();
@@ -214,7 +250,7 @@ function buildMcpConfigFileContent(options = {}) {
214
250
  };
215
251
  }
216
252
 
217
- function writeMcpConfigFiles(options = {}) {
253
+ function writeMcpConfigFiles(options = {}) {
218
254
  const clients = parseMcpClientTargets(options.client);
219
255
  const outputDir = getAgentConfigOutputDir(options);
220
256
  ensureDir(outputDir);
@@ -227,8 +263,142 @@ function writeMcpConfigFiles(options = {}) {
227
263
  files.push({ client, file: filePath });
228
264
  }
229
265
 
230
- return { outputDir, files };
231
- }
266
+ return { outputDir, files };
267
+ }
268
+
269
+ function parsePathListFromEnv(raw) {
270
+ if (!raw) return [];
271
+ const text = String(raw).trim();
272
+ if (!text) return [];
273
+ try {
274
+ const parsed = JSON.parse(text);
275
+ if (Array.isArray(parsed)) {
276
+ return dedupePaths(parsed.filter(Boolean));
277
+ }
278
+ } catch {
279
+ // Fallback to delimiter split.
280
+ }
281
+ return dedupePaths(
282
+ text
283
+ .split(path.delimiter)
284
+ .map((item) => item.trim())
285
+ .filter(Boolean)
286
+ );
287
+ }
288
+
289
+ function getKnownExternalMcpConfigPaths() {
290
+ const home = os.homedir();
291
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
292
+ return dedupePaths([
293
+ path.join(appData, "Cursor", "User", "mcp.json"),
294
+ path.join(appData, "Trae", "User", "mcp.json"),
295
+ path.join(appData, "Trae CN", "User", "mcp.json"),
296
+ path.join(home, ".trae", "mcp.json"),
297
+ path.join(home, ".trae-cn", "mcp.json"),
298
+ path.join(home, ".claude", "mcp.json"),
299
+ path.join(home, ".openclaw", "mcp.json")
300
+ ]);
301
+ }
302
+
303
+ function resolveExternalMcpConfigTargets() {
304
+ const fromEnv = parsePathListFromEnv(process.env[EXTERNAL_MCP_TARGETS_ENV]);
305
+ const known = getKnownExternalMcpConfigPaths().filter((filePath) => {
306
+ if (pathExists(filePath)) return true;
307
+ return pathExists(path.dirname(filePath));
308
+ });
309
+ return dedupePaths([...fromEnv, ...known]);
310
+ }
311
+
312
+ function mergeMcpServerConfigFile(filePath, options = {}) {
313
+ const nextConfig = buildMcpConfigFileContent(options);
314
+ const serverName = Object.keys(nextConfig.mcpServers || {})[0] || DEFAULT_MCP_SERVER_NAME;
315
+ const launchConfig = nextConfig.mcpServers?.[serverName] || buildMcpLaunchConfig(options);
316
+ const current = readJsonObjectFileSafe(filePath);
317
+ const existingServers =
318
+ current?.mcpServers && typeof current.mcpServers === "object" && !Array.isArray(current.mcpServers)
319
+ ? current.mcpServers
320
+ : {};
321
+ const existingEntry = existingServers[serverName];
322
+ const merged = {
323
+ ...current,
324
+ mcpServers: {
325
+ ...existingServers,
326
+ [serverName]: launchConfig
327
+ }
328
+ };
329
+
330
+ ensureDir(path.dirname(filePath));
331
+ fs.writeFileSync(filePath, JSON.stringify(merged, null, 2), "utf8");
332
+ const updated = JSON.stringify(existingEntry || null) !== JSON.stringify(launchConfig);
333
+ return {
334
+ file: filePath,
335
+ server: serverName,
336
+ updated
337
+ };
338
+ }
339
+
340
+ function installExternalMcpConfigs(options = {}) {
341
+ const targets = resolveExternalMcpConfigTargets();
342
+ const applied = [];
343
+ const skipped = [];
344
+ for (const target of targets) {
345
+ try {
346
+ const existed = pathExists(target);
347
+ const merged = mergeMcpServerConfigFile(target, options);
348
+ applied.push({
349
+ file: target,
350
+ server: merged.server,
351
+ created: !existed,
352
+ updated: merged.updated
353
+ });
354
+ } catch (error) {
355
+ skipped.push({
356
+ file: target,
357
+ reason: error.message
358
+ });
359
+ }
360
+ }
361
+ return { targets, applied, skipped };
362
+ }
363
+
364
+ function getKnownExternalSkillBaseDirs() {
365
+ const home = os.homedir();
366
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
367
+ return dedupePaths([
368
+ path.join(home, ".cursor", "skills"),
369
+ path.join(home, ".trae", "skills"),
370
+ path.join(home, ".trae-cn", "skills"),
371
+ path.join(home, ".claude", "skills"),
372
+ path.join(home, ".openclaw", "skills"),
373
+ path.join(appData, "Cursor", "User", "skills"),
374
+ path.join(appData, "Trae", "User", "skills"),
375
+ path.join(appData, "Trae CN", "User", "skills"),
376
+ path.join(appData, "OpenClaw", "User", "skills")
377
+ ]);
378
+ }
379
+
380
+ function resolveExternalSkillBaseDirs() {
381
+ const fromEnv = parsePathListFromEnv(process.env[EXTERNAL_SKILL_DIRS_ENV]);
382
+ const known = getKnownExternalSkillBaseDirs().filter((dirPath) => pathExists(dirPath));
383
+ return dedupePaths([...fromEnv, ...known]);
384
+ }
385
+
386
+ function mirrorSkillToExternalDirs() {
387
+ const baseDirs = resolveExternalSkillBaseDirs();
388
+ const mirrored = [];
389
+ const skipped = [];
390
+ for (const baseDir of baseDirs) {
391
+ try {
392
+ const targetDir = path.join(baseDir, skillName);
393
+ ensureDir(path.dirname(targetDir));
394
+ fs.cpSync(skillSourceDir, targetDir, { recursive: true, force: true });
395
+ mirrored.push({ base_dir: baseDir, target_dir: targetDir });
396
+ } catch (error) {
397
+ skipped.push({ base_dir: baseDir, reason: error.message });
398
+ }
399
+ }
400
+ return { baseDirs, mirrored, skipped };
401
+ }
232
402
 
233
403
  function readTextFile(filePath, label) {
234
404
  const resolved = path.resolve(String(filePath));
@@ -909,7 +1079,7 @@ async function launchChrome(options) {
909
1079
  };
910
1080
  }
911
1081
 
912
- function printHelp() {
1082
+ function printHelp() {
913
1083
  console.log("boss-recruit-mcp");
914
1084
  console.log("");
915
1085
  console.log("Usage:");
@@ -920,7 +1090,7 @@ function printHelp() {
920
1090
  console.log(" boss-recruit-mcp install-skill Install only the Codex skill");
921
1091
  console.log(" boss-recruit-mcp init-config Create ~/.codex/boss-recruit-mcp/screening-config.json if missing");
922
1092
  console.log(" boss-recruit-mcp set-port Persist preferred Chrome debug port to active screening-config");
923
- console.log(" boss-recruit-mcp mcp-config Generate MCP config JSON for Cursor/Trae/Claude Code/OpenClaw");
1093
+ console.log(" boss-recruit-mcp mcp-config Generate MCP config JSON for Cursor/Trae(含 trae-cn)/Claude Code/OpenClaw");
924
1094
  console.log(" boss-recruit-mcp doctor Check config, calibration, and runtime prerequisites");
925
1095
  console.log(" boss-recruit-mcp calibrate Auto-open Boss search page, then run favorite-button calibration");
926
1096
  console.log(" boss-recruit-mcp launch-chrome Reuse existing Chrome debug instance when possible; otherwise launch one, open Boss search, and check login state");
@@ -972,22 +1142,41 @@ function printMcpConfig(options = {}) {
972
1142
  console.log("2. Merge its mcpServers block into that client's MCP config.");
973
1143
  }
974
1144
 
975
- function installAll() {
976
- const skillTarget = installSkill();
977
- const configResult = ensureUserConfig();
978
- const mcpTemplateResult = writeMcpConfigFiles({ client: "all" });
979
- console.log(`Skill installed to: ${skillTarget}`);
980
- if (configResult.created) {
981
- console.log(`Config template created at: ${configResult.path}`);
982
- } else {
983
- console.log(`Config already exists at: ${configResult.path}`);
1145
+ function installAll() {
1146
+ const skillTarget = installSkill();
1147
+ const configResult = ensureUserConfig();
1148
+ const mcpTemplateResult = writeMcpConfigFiles({ client: "all" });
1149
+ const externalMcpResult = installExternalMcpConfigs({});
1150
+ const externalSkillResult = mirrorSkillToExternalDirs();
1151
+ console.log(`Skill installed to: ${skillTarget}`);
1152
+ if (configResult.created) {
1153
+ console.log(`Config template created at: ${configResult.path}`);
1154
+ } else {
1155
+ console.log(`Config already exists at: ${configResult.path}`);
984
1156
  }
985
1157
  console.log(`MCP config templates exported to: ${mcpTemplateResult.outputDir}`);
986
- for (const item of mcpTemplateResult.files) {
987
- console.log(`- ${item.client}: ${item.file}`);
988
- }
989
- console.log("");
990
- console.log("Next steps:");
1158
+ for (const item of mcpTemplateResult.files) {
1159
+ console.log(`- ${item.client}: ${item.file}`);
1160
+ }
1161
+ if (externalMcpResult.targets.length > 0) {
1162
+ console.log(`Auto-configured external MCP files: ${externalMcpResult.applied.length}`);
1163
+ for (const item of externalMcpResult.applied) {
1164
+ const action = item.created ? "created" : item.updated ? "updated" : "unchanged";
1165
+ console.log(`- ${item.file} (${action})`);
1166
+ }
1167
+ } else {
1168
+ console.log("No external MCP config target detected. Set BOSS_RECRUIT_MCP_CONFIG_TARGETS to auto-configure custom agents.");
1169
+ }
1170
+ if (externalSkillResult.baseDirs.length > 0) {
1171
+ console.log(`Mirrored skill to external dirs: ${externalSkillResult.mirrored.length}`);
1172
+ for (const item of externalSkillResult.mirrored) {
1173
+ console.log(`- ${item.target_dir}`);
1174
+ }
1175
+ } else {
1176
+ console.log("No external skill dir detected. Set BOSS_RECRUIT_EXTERNAL_SKILL_DIRS to mirror skill for non-Codex agents.");
1177
+ }
1178
+ console.log("");
1179
+ console.log("Next steps:");
991
1180
  console.log("1. Fill in baseUrl/apiKey/model in the config file above.");
992
1181
  console.log("2. Choose a client template from the exported MCP config files and merge it into your AI client config.");
993
1182
  console.log("3. Choose a Chrome remote-debugging port (9222 is recommended, but you can reuse an existing port).");
package/src/pipeline.js CHANGED
@@ -8,6 +8,126 @@ import {
8
8
  runScreenCli
9
9
  } from "./adapters.js";
10
10
 
11
+ function dedupe(values = []) {
12
+ return [...new Set(values.filter(Boolean))];
13
+ }
14
+
15
+ function failedCheckSet(checks = []) {
16
+ const failed = checks
17
+ .filter((item) => item && item.ok === false && typeof item.key === "string")
18
+ .map((item) => item.key);
19
+ return new Set(failed);
20
+ }
21
+
22
+ function collectNpmInstallDirs(checks = [], workspaceRoot) {
23
+ const npmCheckKeys = new Set([
24
+ "npm_dep_chrome_remote_interface_search",
25
+ "npm_dep_chrome_remote_interface_screen",
26
+ "npm_dep_ws"
27
+ ]);
28
+ const dirs = checks
29
+ .filter((item) => item && item.ok === false && npmCheckKeys.has(item.key))
30
+ .map((item) => item.install_cwd)
31
+ .filter((value) => typeof value === "string" && value.trim());
32
+ if (dirs.length > 0) return dedupe(dirs);
33
+ return workspaceRoot ? [workspaceRoot] : [];
34
+ }
35
+
36
+ function buildNpmInstallCommands(checks = [], workspaceRoot) {
37
+ const dirs = collectNpmInstallDirs(checks, workspaceRoot);
38
+ const commands = [];
39
+ for (const dir of dirs) {
40
+ const escaped = String(dir).replace(/'/g, "''");
41
+ commands.push(`Set-Location '${escaped}'`);
42
+ commands.push("npm install");
43
+ }
44
+ return commands;
45
+ }
46
+
47
+ function formatCommandBlock(commands = []) {
48
+ return commands.map((command) => `- ${command}`).join("\n");
49
+ }
50
+
51
+ function buildPreflightRecovery(checks = [], workspaceRoot) {
52
+ const failed = failedCheckSet(checks);
53
+ if (failed.size === 0) return null;
54
+
55
+ const needNode = failed.has("node_cli");
56
+ const needNpm = (
57
+ failed.has("npm_dep_chrome_remote_interface_search")
58
+ || failed.has("npm_dep_chrome_remote_interface_screen")
59
+ || failed.has("npm_dep_ws")
60
+ );
61
+ const needPython = failed.has("python_cli");
62
+ const needPillow = failed.has("python_pillow");
63
+
64
+ const ordered_steps = [];
65
+ if (needNode) {
66
+ ordered_steps.push({
67
+ id: "install_nodejs",
68
+ title: "安装 Node.js >= 18",
69
+ blocked_by: [],
70
+ commands: [
71
+ "winget install OpenJS.NodeJS.LTS",
72
+ "node --version"
73
+ ]
74
+ });
75
+ }
76
+ if (needNpm) {
77
+ ordered_steps.push({
78
+ id: "install_npm_dependencies",
79
+ title: "安装 npm 依赖(chrome-remote-interface / ws)",
80
+ blocked_by: needNode ? ["install_nodejs"] : [],
81
+ commands: buildNpmInstallCommands(checks, workspaceRoot)
82
+ });
83
+ }
84
+ if (needPython) {
85
+ ordered_steps.push({
86
+ id: "install_python",
87
+ title: "安装 Python(确保 python 命令可用)",
88
+ blocked_by: [],
89
+ commands: [
90
+ "winget install Python.Python.3.12",
91
+ "python --version"
92
+ ]
93
+ });
94
+ }
95
+ if (needPillow) {
96
+ ordered_steps.push({
97
+ id: "install_pillow",
98
+ title: "安装 Pillow",
99
+ blocked_by: needPython ? ["install_python"] : [],
100
+ commands: [
101
+ "python -m pip install --upgrade pip",
102
+ "python -m pip install pillow"
103
+ ]
104
+ });
105
+ }
106
+
107
+ const promptLines = [
108
+ "你是环境修复 agent。请先读取 diagnostics.checks,再严格按下面顺序执行,不要并行跳步:",
109
+ "1) node_cli 失败 -> 先安装 Node.js,未成功前禁止执行 npm install。",
110
+ "2) npm_dep_* 失败 -> 再安装 npm 依赖(chrome-remote-interface / ws)。",
111
+ "3) python_cli 失败 -> 安装 Python 并确保 python 命令可用。",
112
+ "4) python_pillow 失败 -> 最后安装 Pillow。",
113
+ "每一步完成后都重新运行 doctor,直到所有检查通过后再重试 run_recruit_pipeline。"
114
+ ];
115
+
116
+ if (needNpm) {
117
+ const npmCommands = buildNpmInstallCommands(checks, workspaceRoot);
118
+ if (npmCommands.length > 0) {
119
+ promptLines.push("建议执行的 npm 命令:");
120
+ promptLines.push(formatCommandBlock(npmCommands));
121
+ }
122
+ }
123
+
124
+ return {
125
+ failed_check_keys: [...failed],
126
+ ordered_steps,
127
+ agent_prompt: promptLines.join("\n")
128
+ };
129
+ }
130
+
11
131
  function buildRequiredConfirmations(parsedResult) {
12
132
  const confirmations = [];
13
133
 
@@ -280,6 +400,7 @@ export async function runRecruitPipeline({
280
400
 
281
401
  const preflight = runPreflight(workspaceRoot);
282
402
  if (!preflight.ok) {
403
+ const recovery = buildPreflightRecovery(preflight.checks, workspaceRoot);
283
404
  return buildFailedResponse(
284
405
  "PIPELINE_PREFLIGHT_FAILED",
285
406
  "招聘流水线运行前检查失败,请先修复缺失的本地依赖或配置文件。",
@@ -289,7 +410,8 @@ export async function runRecruitPipeline({
289
410
  diagnostics: {
290
411
  checks: preflight.checks,
291
412
  debug_port: preflight.debug_port,
292
- calibration_path: preflight.calibration_path
413
+ calibration_path: preflight.calibration_path,
414
+ recovery
293
415
  }
294
416
  }
295
417
  );
@@ -420,6 +420,45 @@ async function testNeedInputGateStillWorks() {
420
420
  assert.equal(preflightCalled, false);
421
421
  }
422
422
 
423
+ async function testPreflightRecoveryPlanOrder() {
424
+ const tempDir = createTempDir("preflight-recovery");
425
+ const parsed = createParsed();
426
+ const deps = {
427
+ parseRecruitInstruction: () => parsed,
428
+ runPipelinePreflight: () => ({
429
+ ok: false,
430
+ debug_port: 9222,
431
+ calibration_path: null,
432
+ checks: [
433
+ { key: "node_cli", ok: false },
434
+ { key: "npm_dep_ws", ok: false, install_cwd: "C:/workspace/boss-recruit-mcp" }
435
+ ]
436
+ }),
437
+ ensureBossSearchPageReady: async () => ({ ok: true, state: "SEARCH_READY", debug_port: 9222, page_state: {} }),
438
+ runSearchCli: async () => buildSearchOk(0),
439
+ runScreenCli: async () => buildScreenOk({ processedCount: 0, passedCount: 0, outputCsv: null })
440
+ };
441
+
442
+ const result = await runRecruitPipeline(
443
+ {
444
+ workspaceRoot: tempDir,
445
+ instruction: "test",
446
+ confirmation: {},
447
+ overrides: {}
448
+ },
449
+ deps
450
+ );
451
+
452
+ assert.equal(result.status, "FAILED");
453
+ assert.equal(result.error.code, "PIPELINE_PREFLIGHT_FAILED");
454
+ assert.deepEqual(
455
+ result.diagnostics.recovery.ordered_steps.map((item) => item.id),
456
+ ["install_nodejs", "install_npm_dependencies"]
457
+ );
458
+ assert.deepEqual(result.diagnostics.recovery.ordered_steps[1].blocked_by, ["install_nodejs"]);
459
+ assert.equal(result.diagnostics.recovery.agent_prompt.includes("不要并行跳步"), true);
460
+ }
461
+
423
462
  async function main() {
424
463
  await testSearchExhaustedCompletesAndMergesCsv();
425
464
  await testSearchExhaustedByTipNodataEvenWhenCandidateCountPositive();
@@ -427,6 +466,7 @@ async function main() {
427
466
  await testScreenNoProgressWithZeroProcessedExportsBeforeFail();
428
467
  await testScreenNoProgressWithInvalidProcessedAndNoCsv();
429
468
  await testNeedInputGateStillWorks();
469
+ await testPreflightRecoveryPlanOrder();
430
470
  // eslint-disable-next-line no-console
431
471
  console.log("pipeline tests passed");
432
472
  }