install.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  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. ' node install.js --check',
  15. ' node install.js workspace <客户项目目录>',
  16. ' node install.js workspace <客户项目目录> --smoke',
  17. '',
  18. 'workspace 模式会写入 .claude/plugins、.claude/skills 和项目级 .mcp.json。',
  19. ].join('\n');
  20. }
  21. function readJson(filePath, fallback = {}) {
  22. try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
  23. catch { return fallback; }
  24. }
  25. function writeJson(filePath, value) {
  26. fs.mkdirSync(path.dirname(filePath), { recursive: true });
  27. fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
  28. }
  29. function assertRequiredFiles() {
  30. const required = [
  31. 'package.json',
  32. '.claude-plugin/plugin.json',
  33. 'skill-package-manifest.json',
  34. 'mcp/src/server.js',
  35. 'skills/qiwei-dashboard/SKILL.md',
  36. 'skills/qiwei-goal-management/SKILL.md',
  37. 'skills/qiwei-real-estate-auto-reply/SKILL.md',
  38. ];
  39. for (const relative of required) {
  40. if (!fs.existsSync(path.join(ROOT, relative))) throw new Error(`缺少安装文件:${relative}`);
  41. }
  42. const [major, minor] = process.versions.node.split('.').map(Number);
  43. if (!Number.isFinite(major) || major < 22 || (major === 22 && minor < 5)) {
  44. throw new Error(`需要 Node.js 22.5+(使用内置 node:sqlite),当前版本 ${process.version}`);
  45. }
  46. }
  47. function assertInside(target, parent) {
  48. const resolvedTarget = path.resolve(target);
  49. const resolvedParent = path.resolve(parent);
  50. const relative = path.relative(resolvedParent, resolvedTarget);
  51. if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
  52. throw new Error(`拒绝操作不安全路径:${resolvedTarget}`);
  53. }
  54. }
  55. function copyFilter(source) {
  56. const relative = path.relative(ROOT, source);
  57. if (!relative) return true;
  58. const parts = relative.split(path.sep);
  59. if (EXCLUDED_ROOTS.has(parts[0])) return false;
  60. if (EXCLUDED_FILES.has(path.basename(source))) return false;
  61. if (/\.(?:tgz|zip)$/i.test(source)) return false;
  62. if (/^\.env\..+/i.test(path.basename(source)) && path.basename(source) !== '.env.example') return false;
  63. return true;
  64. }
  65. function copyTree(source, destination, filter = () => true) {
  66. if (!filter(source)) return;
  67. const stat = fs.statSync(source);
  68. if (stat.isDirectory()) {
  69. fs.mkdirSync(destination, { recursive: true });
  70. for (const entry of fs.readdirSync(source)) {
  71. copyTree(path.join(source, entry), path.join(destination, entry), filter);
  72. }
  73. return;
  74. }
  75. fs.mkdirSync(path.dirname(destination), { recursive: true });
  76. fs.copyFileSync(source, destination);
  77. }
  78. function run(command, args, cwd, options = {}) {
  79. const useCmd = process.platform === 'win32' && command === 'npm';
  80. const result = spawnSync(useCmd ? 'cmd.exe' : command, useCmd ? ['/d', '/s', '/c', 'npm', ...args] : args, {
  81. cwd,
  82. env: { ...process.env, ...(options.env || {}) },
  83. stdio: 'inherit',
  84. encoding: 'utf8',
  85. windowsHide: true,
  86. });
  87. if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} 执行失败`);
  88. }
  89. function installWorkspace(targetInput, options = {}) {
  90. const target = path.resolve(targetInput || process.cwd());
  91. fs.mkdirSync(target, { recursive: true });
  92. const pluginsRoot = path.join(target, '.claude', 'plugins');
  93. const skillsRoot = path.join(target, '.claude', 'skills');
  94. const pluginDir = path.join(pluginsRoot, 'qiwei-assistant');
  95. assertInside(pluginDir, pluginsRoot);
  96. fs.rmSync(pluginDir, { recursive: true, force: true });
  97. fs.mkdirSync(pluginDir, { recursive: true });
  98. copyTree(ROOT, pluginDir, copyFilter);
  99. for (const entry of fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true })) {
  100. if (!entry.isDirectory() || !fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md'))) continue;
  101. const destination = path.join(skillsRoot, entry.name);
  102. assertInside(destination, skillsRoot);
  103. fs.rmSync(destination, { recursive: true, force: true });
  104. fs.mkdirSync(path.dirname(destination), { recursive: true });
  105. copyTree(path.join(ROOT, 'skills', entry.name), destination);
  106. }
  107. const mcpPath = path.join(target, '.mcp.json');
  108. const mcp = readJson(mcpPath, { mcpServers: {} });
  109. mcp.mcpServers ||= {};
  110. mcp.mcpServers['qiwei-assistant'] = {
  111. command: process.execPath,
  112. args: [path.join(pluginDir, 'mcp', 'src', 'server.js')],
  113. cwd: pluginDir,
  114. };
  115. writeJson(mcpPath, mcp);
  116. if (!options.skipInstall) run('npm', ['install', '--omit=dev', '--ignore-scripts'], pluginDir);
  117. if (options.smoke) {
  118. const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-install-smoke-'));
  119. const smokeOutputs = path.join(smokeRoot, 'outputs');
  120. const smokeEnv = {
  121. QIWEI_OUTPUTS_DIR: smokeOutputs,
  122. QIWEI_AGENT_DB_PATH: path.join(smokeOutputs, 'messages', 'agent-workbench.db'),
  123. };
  124. try {
  125. run('npm', ['run', 'check'], pluginDir, { env: smokeEnv });
  126. run('npm', ['run', 'agent:smoke'], pluginDir, { env: smokeEnv });
  127. run('npm', ['run', 'goal:smoke'], pluginDir, { env: smokeEnv });
  128. } finally {
  129. fs.rmSync(smokeRoot, { recursive: true, force: true });
  130. }
  131. }
  132. process.stdout.write([
  133. '',
  134. '企业微信技能包已安装到客户项目:',
  135. ` ${target}`,
  136. '',
  137. '下一步:',
  138. ' 1. 在 Fmode Studio 中打开该项目。',
  139. ' 2. 启动 Claude Code,调用 qiwei_agent_dashboard_start。',
  140. ' 3. 在项目终端运行 npm --prefix ".claude/plugins/qiwei-assistant" run agent:session:list 查看客户 Session。',
  141. '',
  142. ].join('\n'));
  143. }
  144. function main() {
  145. const args = process.argv.slice(2);
  146. if (args.includes('--help') || args.includes('-h')) {
  147. process.stdout.write(`${usage()}\n`);
  148. return;
  149. }
  150. assertRequiredFiles();
  151. if (args.includes('--check') || !args.length) {
  152. process.stdout.write('技能包结构检查通过。\n');
  153. if (!args.length) process.stdout.write(`${usage()}\n`);
  154. return;
  155. }
  156. if (args[0] !== 'workspace') throw new Error('仅支持 workspace 安装模式');
  157. const target = args[1] && !args[1].startsWith('--') ? args[1] : process.cwd();
  158. installWorkspace(target, {
  159. smoke: args.includes('--smoke'),
  160. skipInstall: args.includes('--skip-install'),
  161. });
  162. }
  163. try { main(); }
  164. catch (error) {
  165. process.stderr.write(`安装失败:${error.message}\n`);
  166. process.exit(1);
  167. }