install.js 9.6 KB

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