#!/usr/bin/env node const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); const ROOT = __dirname; const EXCLUDED_ROOTS = new Set(['.claude', '.git', '.github', '.playwright-cli', '.vscode', 'coverage', 'dist', 'node_modules', 'output', 'outputs']); const EXCLUDED_FILES = new Set(['.env', '.env.local', '.npmrc', 'poll_reply.log', 'poll_state.json']); function usage() { return [ '企业微信 Claude Code 技能包安装器', '', '用法:', ' fmode-qiwei --check', ' fmode-qiwei workspace [客户项目目录]', ' fmode-qiwei workspace [客户项目目录] --smoke', ' fmode-qiwei preview [客户项目目录] [--no-open] [--port 4320]', '', 'workspace 模式会写入 .claude/plugins、.claude/skills 和项目级 .mcp.json。', 'preview 模式会启动工作台、检查当前状态并打开浏览器。', ].join('\n'); } function readJson(filePath, fallback = {}) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return fallback; } } function writeJson(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } function assertRequiredFiles() { const required = [ 'package.json', '.claude-plugin/plugin.json', 'skill-package-manifest.json', 'mcp/src/server.js', 'skills/qiwei-dashboard/SKILL.md', 'skills/qiwei-goal-management/SKILL.md', 'skills/qiwei-real-estate-auto-reply/SKILL.md', ]; for (const relative of required) { if (!fs.existsSync(path.join(ROOT, relative))) throw new Error(`缺少安装文件:${relative}`); } const [major, minor] = process.versions.node.split('.').map(Number); if (!Number.isFinite(major) || major < 22 || (major === 22 && minor < 5)) { throw new Error(`需要 Node.js 22.5+(使用内置 node:sqlite),当前版本 ${process.version}`); } } function assertInside(target, parent) { const resolvedTarget = path.resolve(target); const resolvedParent = path.resolve(parent); const relative = path.relative(resolvedParent, resolvedTarget); if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error(`拒绝操作不安全路径:${resolvedTarget}`); } } function copyFilter(source) { const relative = path.relative(ROOT, source); if (!relative) return true; const parts = relative.split(path.sep); if (EXCLUDED_ROOTS.has(parts[0])) return false; if (EXCLUDED_FILES.has(path.basename(source))) return false; if (/\.(?:tgz|zip)$/i.test(source)) return false; if (/^\.env\..+/i.test(path.basename(source)) && path.basename(source) !== '.env.example') return false; return true; } function copyTree(source, destination, filter = () => true) { if (!filter(source)) return; const stat = fs.statSync(source); if (stat.isDirectory()) { fs.mkdirSync(destination, { recursive: true }); for (const entry of fs.readdirSync(source)) { copyTree(path.join(source, entry), path.join(destination, entry), filter); } return; } fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.copyFileSync(source, destination); } function run(command, args, cwd, options = {}) { const useCmd = process.platform === 'win32' && command === 'npm'; const result = spawnSync(useCmd ? 'cmd.exe' : command, useCmd ? ['/d', '/s', '/c', 'npm', ...args] : args, { cwd, env: { ...process.env, ...(options.env || {}) }, stdio: 'inherit', encoding: 'utf8', windowsHide: true, }); if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} 执行失败`); } function installWorkspace(targetInput, options = {}) { const target = path.resolve(targetInput || process.cwd()); fs.mkdirSync(target, { recursive: true }); const pluginsRoot = path.join(target, '.claude', 'plugins'); const skillsRoot = path.join(target, '.claude', 'skills'); const pluginDir = path.join(pluginsRoot, 'qiwei-assistant'); assertInside(pluginDir, pluginsRoot); fs.rmSync(pluginDir, { recursive: true, force: true }); fs.mkdirSync(pluginDir, { recursive: true }); copyTree(ROOT, pluginDir, copyFilter); for (const entry of fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true })) { if (!entry.isDirectory() || !fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md'))) continue; const destination = path.join(skillsRoot, entry.name); assertInside(destination, skillsRoot); fs.rmSync(destination, { recursive: true, force: true }); fs.mkdirSync(path.dirname(destination), { recursive: true }); copyTree(path.join(ROOT, 'skills', entry.name), destination); } const mcpPath = path.join(target, '.mcp.json'); const mcp = readJson(mcpPath, { mcpServers: {} }); mcp.mcpServers ||= {}; mcp.mcpServers['qiwei-assistant'] = { command: process.execPath, args: [path.join(pluginDir, 'mcp', 'src', 'server.js')], cwd: target, env: { QIWEI_WORKSPACE_ROOT: target, QIWEI_OUTPUTS_DIR: path.join(target, 'outputs'), CLAUDE_CODE_WORKDIR: target, }, }; writeJson(mcpPath, mcp); if (!options.skipInstall) run('npm', ['install', '--omit=dev', '--ignore-scripts'], pluginDir); if (options.smoke) { const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-install-smoke-')); const smokeOutputs = path.join(smokeRoot, 'outputs'); const smokeEnv = { QIWEI_OUTPUTS_DIR: smokeOutputs, QIWEI_AGENT_DB_PATH: path.join(smokeOutputs, 'messages', 'agent-workbench.db'), }; try { run('npm', ['run', 'check'], pluginDir, { env: smokeEnv }); run('npm', ['run', 'agent:smoke'], pluginDir, { env: smokeEnv }); run('npm', ['run', 'goal:smoke'], pluginDir, { env: smokeEnv }); } finally { fs.rmSync(smokeRoot, { recursive: true, force: true }); } } process.stdout.write([ '', '企业微信技能包已安装到客户项目:', ` ${target}`, '', '下一步:', ' 1. 在 Fmode Studio 中打开该项目。', ' 2. 在 Claude Code 中说“启动并预览企微助手”。', ` 3. 也可以在项目终端运行:node "${path.join(pluginDir, 'install.js')}" preview "${target}"`, ' 4. 页面会依次提示登录、席位、企微在线、白名单和监听状态。', ' 5. 需要审阅客户会话时,在项目终端运行 npm --prefix ".claude/plugins/qiwei-assistant" run agent:session:list。', '', ].join('\n')); } function previewWorkspace(targetInput, args = []) { const target = path.resolve(targetInput || process.cwd()); const installedRoot = path.join(target, '.claude', 'plugins', 'qiwei-assistant'); const runtimeRoot = fs.existsSync(path.join(installedRoot, 'scripts', 'preview-dashboard.js')) ? installedRoot : ROOT; const previewScript = path.join(runtimeRoot, 'scripts', 'preview-dashboard.js'); if (!fs.existsSync(previewScript)) throw new Error('技能包缺少启动预览脚本,请重新安装或升级技能包'); run(process.execPath, [previewScript, ...args], target, { env: { QIWEI_WORKSPACE_ROOT: target, QIWEI_OUTPUTS_DIR: path.join(target, 'outputs'), CLAUDE_CODE_WORKDIR: target, }, }); } function main() { const args = process.argv.slice(2); if (args.includes('--help') || args.includes('-h')) { process.stdout.write(`${usage()}\n`); return; } assertRequiredFiles(); if (args.includes('--check') || !args.length) { process.stdout.write('技能包结构检查通过。\n'); if (!args.length) process.stdout.write(`${usage()}\n`); return; } if (args[0] === 'preview') { const target = args[1] && !args[1].startsWith('--') ? args[1] : process.cwd(); const previewArgs = args.slice(args[1] && !args[1].startsWith('--') ? 2 : 1); previewWorkspace(target, previewArgs); return; } if (args[0] !== 'workspace') throw new Error('支持 workspace 安装或 preview 启动预览模式'); const target = args[1] && !args[1].startsWith('--') ? args[1] : process.cwd(); installWorkspace(target, { smoke: args.includes('--smoke'), skipInstall: args.includes('--skip-install'), }); } try { main(); } catch (error) { process.stderr.write(`安装失败:${error.message}\n`); process.exit(1); }