install.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const os = require('os');
  4. const path = require('path');
  5. const { spawnSync } = require('child_process');
  6. const ROOT = __dirname;
  7. const EXCLUDED_ROOTS = new Set(['.claude', '.git', '.github', '.playwright-cli', '.vscode', 'coverage', 'dist', 'node_modules', 'output', 'outputs']);
  8. const EXCLUDED_FILES = new Set(['.env', '.env.local', '.npmrc', 'poll_reply.log', 'poll_state.json']);
  9. function usage() {
  10. return [
  11. '企业微信 Claude Code 技能包安装器',
  12. '',
  13. '用法:',
  14. ' fmode-qiwei --check',
  15. ' fmode-qiwei workspace [客户项目目录]',
  16. ' fmode-qiwei workspace [客户项目目录] --smoke',
  17. ' fmode-qiwei preview [客户项目目录] [--no-open] [--port 4320]',
  18. '',
  19. 'workspace 模式会写入 .claude/plugins、.claude/skills 和项目级 .mcp.json。',
  20. 'preview 模式会启动工作台、检查当前状态并打开浏览器。',
  21. ].join('\n');
  22. }
  23. function readJson(filePath, fallback = {}) {
  24. try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
  25. catch { return fallback; }
  26. }
  27. function writeJson(filePath, value) {
  28. fs.mkdirSync(path.dirname(filePath), { recursive: true });
  29. fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
  30. }
  31. function assertRequiredFiles() {
  32. const required = [
  33. 'package.json',
  34. '.claude-plugin/plugin.json',
  35. 'skill-package-manifest.json',
  36. 'mcp/src/server.js',
  37. 'skills/qiwei-dashboard/SKILL.md',
  38. 'skills/qiwei-goal-management/SKILL.md',
  39. 'skills/qiwei-real-estate-auto-reply/SKILL.md',
  40. ];
  41. for (const relative of required) {
  42. if (!fs.existsSync(path.join(ROOT, relative))) throw new Error(`缺少安装文件:${relative}`);
  43. }
  44. const [major, minor] = process.versions.node.split('.').map(Number);
  45. if (!Number.isFinite(major) || major < 22 || (major === 22 && minor < 5)) {
  46. throw new Error(`需要 Node.js 22.5+(使用内置 node:sqlite),当前版本 ${process.version}`);
  47. }
  48. }
  49. function assertInside(target, parent) {
  50. const resolvedTarget = path.resolve(target);
  51. const resolvedParent = path.resolve(parent);
  52. const relative = path.relative(resolvedParent, resolvedTarget);
  53. if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
  54. throw new Error(`拒绝操作不安全路径:${resolvedTarget}`);
  55. }
  56. }
  57. function copyFilter(source) {
  58. const relative = path.relative(ROOT, source);
  59. if (!relative) return true;
  60. const parts = relative.split(path.sep);
  61. if (EXCLUDED_ROOTS.has(parts[0])) return false;
  62. if (EXCLUDED_FILES.has(path.basename(source))) return false;
  63. if (/\.(?:tgz|zip)$/i.test(source)) return false;
  64. if (/^\.env\..+/i.test(path.basename(source)) && path.basename(source) !== '.env.example') return false;
  65. return true;
  66. }
  67. function copyTree(source, destination, filter = () => true) {
  68. if (!filter(source)) return;
  69. const stat = fs.statSync(source);
  70. if (stat.isDirectory()) {
  71. fs.mkdirSync(destination, { recursive: true });
  72. for (const entry of fs.readdirSync(source)) {
  73. copyTree(path.join(source, entry), path.join(destination, entry), filter);
  74. }
  75. return;
  76. }
  77. fs.mkdirSync(path.dirname(destination), { recursive: true });
  78. fs.copyFileSync(source, destination);
  79. }
  80. function run(command, args, cwd, options = {}) {
  81. const useCmd = process.platform === 'win32' && command === 'npm';
  82. const result = spawnSync(useCmd ? 'cmd.exe' : command, useCmd ? ['/d', '/s', '/c', 'npm', ...args] : args, {
  83. cwd,
  84. env: { ...process.env, ...(options.env || {}) },
  85. stdio: 'inherit',
  86. encoding: 'utf8',
  87. windowsHide: true,
  88. });
  89. if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} 执行失败`);
  90. }
  91. function installWorkspace(targetInput, options = {}) {
  92. const target = path.resolve(targetInput || process.cwd());
  93. fs.mkdirSync(target, { recursive: true });
  94. const pluginsRoot = path.join(target, '.claude', 'plugins');
  95. const skillsRoot = path.join(target, '.claude', 'skills');
  96. const pluginDir = path.join(pluginsRoot, 'qiwei-assistant');
  97. assertInside(pluginDir, pluginsRoot);
  98. fs.rmSync(pluginDir, { recursive: true, force: true });
  99. fs.mkdirSync(pluginDir, { recursive: true });
  100. copyTree(ROOT, pluginDir, copyFilter);
  101. for (const entry of fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true })) {
  102. if (!entry.isDirectory() || !fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md'))) continue;
  103. const destination = path.join(skillsRoot, entry.name);
  104. assertInside(destination, skillsRoot);
  105. fs.rmSync(destination, { recursive: true, force: true });
  106. fs.mkdirSync(path.dirname(destination), { recursive: true });
  107. copyTree(path.join(ROOT, 'skills', entry.name), destination);
  108. }
  109. const mcpPath = path.join(target, '.mcp.json');
  110. const mcp = readJson(mcpPath, { mcpServers: {} });
  111. mcp.mcpServers ||= {};
  112. mcp.mcpServers['qiwei-assistant'] = {
  113. command: process.execPath,
  114. args: [path.join(pluginDir, 'mcp', 'src', 'server.js')],
  115. cwd: target,
  116. env: {
  117. QIWEI_WORKSPACE_ROOT: target,
  118. QIWEI_OUTPUTS_DIR: path.join(target, 'outputs'),
  119. CLAUDE_CODE_WORKDIR: target,
  120. },
  121. };
  122. writeJson(mcpPath, mcp);
  123. if (!options.skipInstall) run('npm', ['install', '--omit=dev', '--ignore-scripts'], pluginDir);
  124. if (options.smoke) {
  125. const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-install-smoke-'));
  126. const smokeOutputs = path.join(smokeRoot, 'outputs');
  127. const smokeEnv = {
  128. QIWEI_OUTPUTS_DIR: smokeOutputs,
  129. QIWEI_AGENT_DB_PATH: path.join(smokeOutputs, 'messages', 'agent-workbench.db'),
  130. };
  131. try {
  132. run('npm', ['run', 'check'], pluginDir, { env: smokeEnv });
  133. run('npm', ['run', 'agent:smoke'], pluginDir, { env: smokeEnv });
  134. run('npm', ['run', 'goal:smoke'], pluginDir, { env: smokeEnv });
  135. } finally {
  136. fs.rmSync(smokeRoot, { recursive: true, force: true });
  137. }
  138. }
  139. process.stdout.write([
  140. '',
  141. '企业微信技能包已安装到客户项目:',
  142. ` ${target}`,
  143. '',
  144. '下一步:',
  145. ' 1. 在 Fmode Studio 中打开该项目。',
  146. ' 2. 在 Claude Code 中说“启动并预览企微助手”。',
  147. ` 3. 也可以在项目终端运行:node "${path.join(pluginDir, 'install.js')}" preview "${target}"`,
  148. ' 4. 页面会依次提示登录、席位、企微在线、白名单和监听状态。',
  149. ' 5. 需要审阅客户会话时,在项目终端运行 npm --prefix ".claude/plugins/qiwei-assistant" run agent:session:list。',
  150. '',
  151. ].join('\n'));
  152. }
  153. function previewWorkspace(targetInput, args = []) {
  154. const target = path.resolve(targetInput || process.cwd());
  155. const installedRoot = path.join(target, '.claude', 'plugins', 'qiwei-assistant');
  156. const runtimeRoot = fs.existsSync(path.join(installedRoot, 'scripts', 'preview-dashboard.js')) ? installedRoot : ROOT;
  157. const previewScript = path.join(runtimeRoot, 'scripts', 'preview-dashboard.js');
  158. if (!fs.existsSync(previewScript)) throw new Error('技能包缺少启动预览脚本,请重新安装或升级技能包');
  159. run(process.execPath, [previewScript, ...args], target, {
  160. env: {
  161. QIWEI_WORKSPACE_ROOT: target,
  162. QIWEI_OUTPUTS_DIR: path.join(target, 'outputs'),
  163. CLAUDE_CODE_WORKDIR: target,
  164. },
  165. });
  166. }
  167. function main() {
  168. const args = process.argv.slice(2);
  169. if (args.includes('--help') || args.includes('-h')) {
  170. process.stdout.write(`${usage()}\n`);
  171. return;
  172. }
  173. assertRequiredFiles();
  174. if (args.includes('--check') || !args.length) {
  175. process.stdout.write('技能包结构检查通过。\n');
  176. if (!args.length) process.stdout.write(`${usage()}\n`);
  177. return;
  178. }
  179. if (args[0] === 'preview') {
  180. const target = args[1] && !args[1].startsWith('--') ? args[1] : process.cwd();
  181. const previewArgs = args.slice(args[1] && !args[1].startsWith('--') ? 2 : 1);
  182. previewWorkspace(target, previewArgs);
  183. return;
  184. }
  185. if (args[0] !== 'workspace') throw new Error('支持 workspace 安装或 preview 启动预览模式');
  186. const target = args[1] && !args[1].startsWith('--') ? args[1] : process.cwd();
  187. installWorkspace(target, {
  188. smoke: args.includes('--smoke'),
  189. skipInstall: args.includes('--skip-install'),
  190. });
  191. }
  192. try { main(); }
  193. catch (error) {
  194. process.stderr.write(`安装失败:${error.message}\n`);
  195. process.exit(1);
  196. }