#!/usr/bin/env node const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); const { ensureActivated } = require('../mcp/src/core/activation'); const { readNewApiToken } = require('../mcp/src/core/credentials'); const SOURCE_ROOT = path.resolve(__dirname, '..'); const WORKSPACE_ROOT = process.cwd(); const DEFAULT_TARGET = path.join(os.homedir(), '.claude', 'plugins', 'voc-intelligence'); const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.claude', 'plugins', 'voc-intelligence'); const WORKSPACE_PLUGINS_ROOT = path.join(WORKSPACE_ROOT, '.claude', 'plugins'); const CODEX_PROJECT_TARGET = path.join(WORKSPACE_ROOT, '.codex', 'plugins', 'voc-intelligence'); const CODEX_GLOBAL_TARGET = path.join(os.homedir(), '.codex', 'plugins', 'voc-intelligence'); const PORTABLE_WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.vocmarket', 'plugins', 'voc-intelligence'); const VOCMARKET_CREDENTIALS = path.join(os.homedir(), '.vocmarket', 'credentials.json'); const MIN_NODE_MAJOR = 18; const EXCLUDED_DIRS = new Set([ 'node_modules', 'memory', 'outputs', '.npm-cache-acceptance', '.tmp', '.git', '.claude' ]); const EXCLUDED_FILES = new Set([ '.env', '.env.local', '.npmrc' ]); function usage() { return [ 'Claude VOC skill package installer', '', 'Usage:', ' claude-voc install', ' claude-voc workspace', ' claude-voc workspace --activate --channel ', ' claude-voc install --workspace --smoke', ' claude-voc check', ' claude-voc smoke', ' claude-voc image --image-path --prompt ', ' claude-voc path', '', 'npx:', ' npx @vocmarket/voc-skill install', ' npx @vocmarket/voc-skill workspace --smoke', ' npx @vocmarket/voc-skill workspace --activate (其他 AI 编程器渠道专用:安装并按状态弹付费)', '', 'Options:', ' --workspace Install into ./.claude/plugins/voc-intelligence', ' --target Install into a custom directory', ' --force Allow overwriting an existing custom target', ' --skip-install Skip npm install after copying files', ' --smoke Run sample/preference/MCP smoke checks after install', ' --activate 安装后按 token 状态激活:无 token 弹付费/登录页,有 token 直接放行(外部 AI IDE 专用,不影响 --smoke)', ' --channel 安装渠道(claude-code/codex/workbuddy/fmode-studio)', ' --scope Codex 安装范围(project/global,默认 project)', ' --help, -h Show help' ].join('\n'); } function expandHome(value) { return value.replace(/^~(?=$|[\\/])/, os.homedir()); } function parseArgs(argv) { const first = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install'; const opts = { command: first, target: DEFAULT_TARGET, skipInstall: false, smoke: false, activate: false, channel: 'external', scope: 'project', targetExplicit: false, workspaceRequested: first === 'workspace' || first === 'install-workspace', force: false, help: false }; if (first === 'workspace' || first === 'install-workspace') { opts.command = 'install'; opts.target = WORKSPACE_TARGET; } for (let i = first === argv[0] ? 1 : 0; i < argv.length; i++) { const token = argv[i]; if (token === '--target') { opts.target = argv[++i]; opts.targetExplicit = true; } else if (token.startsWith('--target=')) { opts.target = token.slice('--target='.length); opts.targetExplicit = true; } else if (token === '--workspace') { opts.target = WORKSPACE_TARGET; opts.workspaceRequested = true; } else if (token === '--force') { opts.force = true; } else if (token === '--skip-install') { opts.skipInstall = true; } else if (token === '--smoke') { opts.smoke = true; } else if (token === '--activate') { opts.activate = true; } else if (token === '--channel') { opts.channel = argv[++i]; } else if (token.startsWith('--channel=')) { opts.channel = token.slice('--channel='.length); } else if (token === '--scope') { opts.scope = argv[++i]; } else if (token.startsWith('--scope=')) { opts.scope = token.slice('--scope='.length); } else if (token === '--help' || token === '-h') { opts.help = true; } } opts.channel = normalizeInstallChannel(opts.channel); opts.scope = normalizeInstallScope(opts.channel, opts.scope); if (opts.workspaceRequested && !opts.targetExplicit) { opts.target = defaultTargetFor(opts.channel, opts.scope); } opts.target = path.resolve(expandHome(opts.target)); return opts; } function normalizeInstallChannel(value) { const channel = String(value || 'claude-code').trim().toLowerCase(); if (channel === 'external') return 'claude-code'; if (!['claude-code', 'codex', 'workbuddy', 'fmode-studio'].includes(channel)) { throw new Error(`Unsupported install channel: ${channel}`); } return channel; } function normalizeInstallScope(channel, value) { if (channel !== 'codex') return 'project'; const scope = String(value || 'project').trim().toLowerCase(); if (!['project', 'global'].includes(scope)) { throw new Error(`Unsupported Codex install scope: ${scope}`); } return scope; } function defaultTargetFor(channel, scope) { if (channel === 'codex') return scope === 'global' ? CODEX_GLOBAL_TARGET : CODEX_PROJECT_TARGET; if (channel === 'workbuddy') return PORTABLE_WORKSPACE_TARGET; return WORKSPACE_TARGET; } function run(command, args, cwd) { const useCmd = process.platform === 'win32' && command === 'npm'; const executable = useCmd ? 'cmd.exe' : command; const finalArgs = useCmd ? ['/d', '/s', '/c', 'npm', ...args] : args; const child = spawnSync(executable, finalArgs, { cwd, encoding: 'utf8', stdio: 'inherit', maxBuffer: 1024 * 1024 * 100 }); if (child.status !== 0) { const detail = child.error ? `: ${child.error.message}` : ''; throw new Error(`${command} ${args.join(' ')} failed with exit ${child.status}${detail}`); } } function checkNodeVersion() { const major = Number(process.versions.node.split('.')[0]); if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) { throw new Error(`Node.js ${MIN_NODE_MAJOR}+ is required. Current version: ${process.version}`); } } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } // Remove an existing install dir, resilient to Windows locks. // A normal recursive remove fails with EPERM/EBUSY when the directory is held // by a running process — most commonly a Claude Code session / VSCode terminal // whose current working directory is inside the plugin dir. Such a directory // can still be *renamed* even though it cannot be deleted, so on failure we move // it aside and install fresh, then best-effort delete the moved-aside copy. function removeDirResilient(targetDir) { try { fs.rmSync(targetDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); return; } catch (err) { if (!['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'].includes(err.code)) throw err; const asidePath = `${targetDir}.held-${Date.now()}`; try { fs.renameSync(targetDir, asidePath); } catch (renameErr) { const e = new Error( `无法更新技能目录(被占用):${targetDir}\n` + `原因:该目录正被一个运行中的进程占用(常见是该工作区里仍开着的 Claude Code 会话或 VSCode 终端,其当前目录在该插件目录内)。\n` + `请关闭占用该目录的 Claude Code 会话/终端后重试安装。\n` + `(${err.code}: ${err.message}; rename fallback failed: ${renameErr.code || renameErr.message})` ); e.code = err.code; throw e; } console.log(`Existing skill directory was in use; moved aside to: ${asidePath}`); try { fs.rmSync(asidePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch (cleanupErr) { console.log(`Note: moved-aside copy is still in use and was not deleted. Remove it later: ${asidePath}`); } } } function isInside(parentDir, childDir) { const relative = path.relative(path.resolve(parentDir), path.resolve(childDir)); return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)); } function isSafeDefaultTarget(targetDir) { return path.resolve(targetDir) === path.resolve(DEFAULT_TARGET); } function isSafeWorkspaceTarget(targetDir) { return isInside(WORKSPACE_PLUGINS_ROOT, targetDir); } function isSafeManagedTarget(targetDir) { return [WORKSPACE_TARGET, CODEX_PROJECT_TARGET, CODEX_GLOBAL_TARGET, PORTABLE_WORKSPACE_TARGET] .some(candidate => path.resolve(candidate) === path.resolve(targetDir)); } function canOverwriteTarget(targetDir, opts) { return opts.force || isSafeDefaultTarget(targetDir) || isSafeWorkspaceTarget(targetDir) || isSafeManagedTarget(targetDir); } function shouldCopyEntry(entry) { if (entry.isDirectory()) return !EXCLUDED_DIRS.has(entry.name); if (entry.isFile()) { if (EXCLUDED_FILES.has(entry.name)) return false; if (entry.name.endsWith('.tgz')) return false; return true; } return false; } function copyDirRecursive(srcDir, destDir) { ensureDir(destDir); let count = 0; for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { if (!shouldCopyEntry(entry)) continue; const src = path.join(srcDir, entry.name); const dest = path.join(destDir, entry.name); if (entry.isDirectory()) { count += copyDirRecursive(src, dest); } else if (entry.isFile()) { ensureDir(path.dirname(dest)); fs.copyFileSync(src, dest); count++; } } return count; } function assertInstalled(targetDir) { const required = [ 'package.json', 'install.js', '.claude-plugin/plugin.json', '.mcp.json', 'skills/xiaohongshu-trend-intelligence/SKILL.md', 'skills/douyin-trend-intelligence/SKILL.md', 'skills/voc-issue-pool/SKILL.md', 'skills/voc-problem-deep-dive/SKILL.md', 'skills/voc-content-plan/SKILL.md', 'skills/voc-speaking-script/SKILL.md', 'skills/voc-competitor-map/SKILL.md', 'skills/voc-business-workflow/SKILL.md', 'skills/fmode-image-analysis/SKILL.md', 'skills/voc-api-catalog/SKILL.md', 'skills/voc-cost-controller/SKILL.md', 'mcp/src/tools/fmode-image-analysis.js', 'mcp/src/tools/voc-api-catalog-run.js', 'mcp/catalog/voc-social-endpoints.json', 'mcp/src/server.js' ]; const missing = required.filter(rel => !fs.existsSync(path.join(targetDir, rel))); if (missing.length) { throw new Error(`Missing required files in install target: ${missing.join(', ')}`); } } function writeMcpConfig(targetDir) { const serverPath = path.join(targetDir, 'mcp', 'src', 'server.js'); const mcpConfig = { mcpServers: { voc: { command: 'node', args: [serverPath], cwd: targetDir } } }; fs.writeFileSync(path.join(targetDir, '.mcp.json'), `${JSON.stringify(mcpConfig, null, 2)}\n`, 'utf8'); } function readJsonIfExists(filePath) { if (!fs.existsSync(filePath)) return {}; return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function writeJsonMcpConfig(configPath, targetDir) { const existing = readJsonIfExists(configPath); const mcpConfig = { ...existing, mcpServers: { ...(existing.mcpServers || {}), voc: { command: 'node', args: [path.join(targetDir, 'mcp', 'src', 'server.js')], cwd: targetDir } } }; ensureDir(path.dirname(configPath)); fs.writeFileSync(configPath, `${JSON.stringify(mcpConfig, null, 2)}\n`, 'utf8'); } function writeWorkspaceMcpConfig(targetDir) { const mcpConfigPath = path.join(WORKSPACE_ROOT, '.mcp.json'); writeJsonMcpConfig(mcpConfigPath, targetDir); } function tomlString(value) { return JSON.stringify(String(value)); } function upsertCodexProjectMcp(targetDir, workspaceRoot = WORKSPACE_ROOT) { const configPath = path.join(workspaceRoot, '.codex', 'config.toml'); const original = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : ''; const lines = original.replace(/\r\n/g, '\n').split('\n'); const kept = []; let skipping = false; for (const line of lines) { const table = line.match(/^\s*\[([^\]]+)]\s*(?:#.*)?$/); if (table) { skipping = table[1] === 'mcp_servers.voc' || table[1].startsWith('mcp_servers.voc.'); } if (!skipping) kept.push(line); } while (kept.length && !kept[kept.length - 1].trim()) kept.pop(); const serverPath = path.join(targetDir, 'mcp', 'src', 'server.js'); const block = [ '[mcp_servers.voc]', 'command = "node"', `args = [${tomlString(serverPath)}]`, `cwd = ${tomlString(targetDir)}` ]; ensureDir(path.dirname(configPath)); fs.writeFileSync(configPath, `${kept.concat(kept.length ? [''] : [], block).join('\n')}\n`, 'utf8'); return configPath; } function runCapture(command, args, cwd = WORKSPACE_ROOT) { const useCmd = process.platform === 'win32'; const executable = useCmd ? 'cmd.exe' : command; const finalArgs = useCmd ? ['/d', '/s', '/c', command, ...args] : args; return spawnSync(executable, finalArgs, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 * 10 }); } function registerCodexGlobalMcp(targetDir) { const getResult = runCapture('codex', ['mcp', 'get', 'voc']); if (!getResult.error && getResult.status === 0) { const removeResult = runCapture('codex', ['mcp', 'remove', 'voc']); if (removeResult.error || removeResult.status !== 0) { throw new Error('Unable to update the existing Codex VOC MCP registration'); } } const serverPath = path.join(targetDir, 'mcp', 'src', 'server.js'); const addResult = runCapture('codex', ['mcp', 'add', 'voc', '--', 'node', serverPath]); if (addResult.error || addResult.status !== 0) { const detail = String(addResult.stderr || addResult.error?.message || '').trim(); throw new Error(`Codex MCP registration failed${detail ? `: ${detail}` : ''}`); } } function copyPackagedSkills(targetDir, destinationRoot) { const sourceRoot = path.join(targetDir, 'skills'); ensureDir(destinationRoot); let copied = 0; for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const destination = path.join(destinationRoot, entry.name); removeDirResilient(destination); copied += copyDirRecursive(path.join(sourceRoot, entry.name), destination); } return copied; } function persistNeutralCredential(credentialsPath = VOCMARKET_CREDENTIALS) { const token = readNewApiToken(); if (!token) return false; const existing = readJsonIfExists(credentialsPath); ensureDir(path.dirname(credentialsPath)); fs.writeFileSync(credentialsPath, `${JSON.stringify({ ...existing, fmodeApiKey: token }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); try { fs.chmodSync(credentialsPath, 0o600); } catch {} return true; } function asForwardSlash(filePath) { return path.resolve(filePath).replace(/\\/g, '/'); } function quotedPath(filePath) { return `"${asForwardSlash(filePath)}"`; } function workspaceTrendSkillText(options) { const trendRunner = path.join(options.targetDir, 'mcp', 'src', 'tools', options.trendTool); const preferenceUpdater = path.join(options.targetDir, 'mcp', 'src', 'tools', options.preferenceTool); const profilePath = path.join(options.targetDir, 'memory-templates', options.profileFile); const defaultMemoryPath = path.join(WORKSPACE_ROOT, 'outputs', options.memoryDir, options.memoryFile); const liveLimits = options.platform === '抖音' ? '--keyword-limit 1 --videos-per-keyword 1 --max-comment-pages 1' : '--keyword-limit 1 --notes-per-keyword 1 --max-comment-pages 1'; return [ '---', `name: ${options.skillName}`, `description: ${options.description}`, 'allowed-tools: Read Write Bash(node *)', '---', '', `# ${options.platform}趋势情报官`, '', `你是面向业务用户的“${options.platform}趋势情报官”。用户要的是第一轮样本观察、机会假设和待确认问题,不是技术日志。`, '', '## 必须执行', '', `- 用户要求${options.platform}趋势情报、内容趋势、评论分析、真实采集、live采集或社媒 VOC 时,必须运行本技能包的工具或 MCP,不要只凭经验回答。`, '- 用户说“live / 真实采集 / 进入 live / 实时采集 / 抓真实数据”时,必须运行 live 模式工具。不要回复“我不能实时抓取外部数据”。本技能包已经提供本地 VOC 采集入口。', '- live 模式返回 `needs_token`、`needs_recharge` 或 `needs_valid_token` 时,必须把工具输出里的 `assistantMessage` 原样转述给用户。`needs_token` 是缺 token、不是没钱:先引导自救(读 `~/.claude/settings.json` 的 `sk-` token),不要劝充值。', '- 如果工具输出里有 `nextActions`,转述其中的自救/充值链接(以工具实际返回为准,不要写死某个链接)。', '- 第一轮只说“初步判断 / 机会假设 / 待校准”,不要说成最终结论。', '- 后续用户说“保留、不要、更偏、这个不准”等反馈时,运行偏好更新命令沉淀记忆。', '', '## MANDATORY Live Rule', '', '- If the user asks for live collection, real collection, or entering live mode, run the local VOC tool in live mode.', '- Do not answer that you cannot fetch external data. The local VOC tool is the approved collection entry.', '- If the tool returns `needs_token`, `needs_recharge`, or `needs_valid_token`, paste its `assistantMessage` to the user verbatim.', '- `needs_token` is recoverable: an `sk-` fmode key (Claude Code `ANTHROPIC_AUTH_TOKEN`) lets collection run — guide self-rescue first, do not push recharge. Only a real `402` needs recharge. 完整口径见下方「充值 / 计费口径」。', '', '## sample 命令', '', '```bash', `node ${quotedPath(trendRunner)} --collection-mode sample --profile ${quotedPath(profilePath)} --output ${JSON.stringify(options.sampleOutput)} --assistant-message-only`, '```', '', '## live 命令', '', '```bash', `node ${quotedPath(trendRunner)} --collection-mode live --profile ${quotedPath(profilePath)} --output ${JSON.stringify(options.liveOutput)} ${liveLimits} --assistant-message-only`, '```', '', '运行后把命令输出正文直接发给用户。不要展示 JSON、summary、data、files 或调试日志。', '', '## 偏好更新命令', '', '```bash', `node ${quotedPath(preferenceUpdater)} --message "<用户反馈原文>" --memory ${quotedPath(defaultMemoryPath)} --result-prefix ${options.resultPrefix}`, '```', '', '## 充值 / 计费口径(简版;完整对照见 voc-api-catalog 技能的「错误码速查」references/error-codes.md)', '', '- 缺 token(`needs_token`)= 可恢复、不是没钱:先自救读 `~/.claude/settings.json` 的 `sk-` fmode key(或 `~/.fmode/config.json` / `~/.claude/voc-credentials.json`),用 `FMODE_API_KEY=sk-…` 重试,不要劝充值。', '- 只有真 `402 余额不足` 才充值;充值入口以工具返回的 Tokenized Balance 链接或二维码支付结果为准。链接格式为 `https://app.fmode.cn/dev/studio/balance/?token=USER_SESSION_TOKEN`,不要在话术中写死账号或旧 APIG 参数。', '- 实际充值/自救链接以工具返回的 `assistantMessage` / `nextActions` 为准,不要写死。', '' ].join('\n'); } function workspaceSkillText(targetDir) { return workspaceTrendSkillText({ targetDir, skillName: 'xiaohongshu-trend-intelligence', platform: '小红书', description: 'Build Xiaohongshu trend intelligence reports. Use when the user asks for 小红书趋势情报、行业趋势、内容选题、销售话术、真实采集、live采集、用户顾虑 or social VOC analysis.', trendTool: 'xiaohongshu-trend-run.js', preferenceTool: 'xiaohongshu-preference-update.js', profileFile: 'xiaohongshu-trend-profile.json', memoryDir: 'claude-code-xhs-memory', memoryFile: 'xiaohongshu-trend-memory.json', sampleOutput: 'outputs/claude-code-xhs-sample', liveOutput: 'outputs/claude-code-xhs-live', resultPrefix: 'XHS_PREF_RESULT' }); } function workspaceDouyinSkillText(targetDir) { return workspaceTrendSkillText({ targetDir, skillName: 'douyin-trend-intelligence', platform: '抖音', description: 'Build Douyin trend intelligence reports. Use when the user asks for 抖音趋势情报、视频评论、口播选题、短视频开头、内容趋势、真实采集、live采集、用户顾虑 or social VOC analysis.', trendTool: 'douyin-trend-run.js', preferenceTool: 'douyin-preference-update.js', profileFile: 'douyin-trend-profile.json', memoryDir: 'claude-code-douyin-memory', memoryFile: 'douyin-trend-memory.json', sampleOutput: 'outputs/claude-code-douyin-sample', liveOutput: 'outputs/claude-code-douyin-live', resultPrefix: 'DOUYIN_PREF_RESULT' }); } function workspaceProblemDeepDiveSkillText(targetDir) { const deepDiveRunner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-problem-deep-dive-run.js'); return [ '---', 'name: voc-problem-deep-dive', 'description: Deep-dive a single VOC problem into root cause, boss/operator impact, concrete actions, content ideas, and validation metrics. Use when the user says 继续挖、深入分析、老板怎么解决、这个问题怎么办、怎么改门店、怎么转成内容、单点VOC or VOC深挖.', 'allowed-tools: Read Write Bash(node *)', '---', '', '# 单点 VOC 深挖官', '', '你是面向老板和门店经营者的“单点 VOC 深挖官”。用户不需要学习提示词。用户只要说“这个问题继续挖”“老板怎么解决”“这个差评背后是什么”,你就要把真实用户声音转成可执行经营动作。', '', '## 必须执行', '', '- 当用户说“继续挖、深入分析、老板怎么解决、这个问题怎么办、怎么改、怎么转成内容、单点 VOC”时,必须运行单点深挖工具。', '- 不要输出提示词教学,不要让用户自己设计分析维度。', '- 输出必须站在老板视角:这件事影响新客、复购、客单、口碑,还是现场效率。', '- 每次只深挖一个问题点。如果用户给了多个问题,先选择最影响转化的一个。', '- 如果上一轮趋势报告里有证据样本或高赞评论,要把相关评论作为 evidence 传入工具。', '- 要使用记忆能力:用户说“这个动作不适合”“我们已经试过”“这个有效”“下次更偏内容/门店动作”时,把原话作为 `feedback` 传入;如果能明确识别,也同步传 `blockedActions`、`validatedActions`、`rejectedActions` 或 `preferredActions`。', '- 后续多轮迭代时要带上 `project`/`brand`/`store`、`industry`、`scenario`、`audience`。未显式传 `memory` 时,工具会按这些字段自动隔离记忆,避免不同行业、不同客户串味。', '- 如果用户没有指定记忆文件,不要强行要求用户理解路径;让工具使用默认分桶记忆即可。只有在同一个客户需要固定沉淀时,才显式传同一个 `memory` 路径。', '', '## 命令', '', '```bash', `node ${quotedPath(deepDiveRunner)} --issue "怎么选" --industry "<你的行业>" --scenario "线上获客" --audience "意向用户" --assistant-message-only`, '```', '', '## 带记忆的多轮迭代', '', '```bash', `node ${quotedPath(deepDiveRunner)} --issue "怎么选" --industry "<你的行业>" --scenario "线上获客" --audience "意向用户" --memory "outputs/voc-problem-memory.json" --feedback "老板不想做直播带看,更想先改评论区回复和方案说明" --blocked-actions "直播带看" --preferred-actions "评论区回复,方案说明" --assistant-message-only`, '```', '', '自然语言反馈也可以直接传入 `feedback`,工具会尝试自动提取偏好和屏蔽动作:', '', '```bash', `node ${quotedPath(deepDiveRunner)} --issue "怎么选" --project "示例品牌A" --industry "<你的行业>" --scenario "线上获客" --audience "意向用户" --feedback "这个品牌不想做直播带看,更想先改评论区回复和方案说明" --assistant-message-only`, '```', '', '运行后把命令输出正文直接发给用户。不要展示 JSON、summary、data、files 或调试日志。', '' ].join('\n'); } function workspaceIssuePoolSkillText(targetDir) { const issuePoolRunner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-issue-pool-run.js'); return [ '---', 'name: voc-issue-pool', 'description: Turn VOC comments, report evidence, or user feedback into a prioritized issue pool with status, evidence, business impact, next actions, and follow-up deep-dive suggestions. Use when the user asks 哪些问题最影响生意、问题池、问题清单、用户都在吐槽什么、先改哪个、VOC问题管理、问题优先级、把评论整理成问题.', 'allowed-tools: Read Write Bash(node *)', '---', '', '# VOC 问题池官', '', '你是面向老板、门店经营者和营销负责人的 VOC 问题池官。用户不需要知道分类法,也不需要学习提示词。用户只要说“这些评论里哪些问题最重要”“先改哪个”“整理成问题池”,你就把真实用户声音整理成可排序、可跟进、可继续深挖的问题列表。', '', '## 必须执行', '', '- 当用户说“问题池、问题清单、用户吐槽什么、哪些问题最影响生意、先改哪个、VOC 问题管理、把评论整理成问题”时,优先运行 `voc_issue_pool_run`。', '- 如果上一轮小红书/抖音报告里有证据样本或高赞评论,把相关评论作为 `evidence`、`evidenceText` 或 `reportPath` 传入。', '- 输出必须站在业务视角:这个问题影响新客、复购、客单、口碑、内容信任,还是现场效率。', '- 不要只做词频统计。每个问题都要包含证据样本、影响环节、建议动作和下一步。', '- 如果用户说某个问题已解决、验证中、暂缓,要通过 `resolvedIssues`、`validatingIssues`、`blockedIssues` 或 `statusUpdates` 更新问题状态。', '- 后续深挖时,引导用户直接说“继续深挖「问题名」”,然后转入 `voc-problem-deep-dive`。', '', '## 命令', '', '```bash', `node ${quotedPath(issuePoolRunner)} --project "示例品牌A" --industry "<你的行业>" --scenario "线上获客" --audience "意向用户" --evidence "第一次买不知道怎么选,怕踩雷。,价格有点高,不确定值不值。,咨询的时候没人理。" --assistant-message-only`, '```', '', '带报告文件:', '', '```bash', `node ${quotedPath(issuePoolRunner)} --project "示例品牌A" --industry "<你的行业>" --report "outputs/douyin-trend-report.md" --assistant-message-only`, '```', '', '运行后把命令输出正文直接发给用户。不要展示 JSON、summary、data、files 或调试日志。', '' ].join('\n'); } function workspaceContentPlanSkillText(targetDir) { const runner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-content-plan-run.js'); return [ '---', 'name: voc-content-plan', 'description: Turn VOC issues into a 7-day content plan with short-video topics and speaking scripts. Use when the user asks 下周账号发什么、口播脚本、短视频选题、把用户问题变成内容、7天内容计划.', 'allowed-tools: Read Write Bash(node *)', '---', '', '# VOC 内容选题官', '', '当用户说“下周发什么、7 天选题、口播脚本、短视频脚本、账号内容、把问题变成内容”时,运行本工具。', '', '必须把内容绑定到 VOC 用户问题,不要输出空泛爆款标题。不要覆盖创始人故事。', '', '## 命令', '', '```bash', `node ${quotedPath(runner)} --brand "示例品牌A" --industry "<你的行业>" --issues "第一次买怕踩雷;觉得价格贵;怕质量不稳定" --assistant-message-only`, '```', '', '运行后把正文直接发给用户,不要展示 JSON。', '' ].join('\n'); } function workspaceSpeakingScriptSkillText(targetDir) { const runner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-speaking-script-run.js'); return [ '---', 'name: voc-speaking-script', 'description: Turn one VOC-backed topic into a co-created speaking script with 60-second draft, 30-second compressed version, teleprompter text, revision feedback, finalization, and script memory. Use when the user says 选题2进入脚本共创、改稿、定稿、口播稿、提词器、开头更狠、老板视角、少讲概念.', 'allowed-tools: Read Write Bash(node *)', '---', '', '# VOC 口播脚本共创', '', '当用户从 7 天内容计划里选择某条选题,或说“进入脚本共创、改稿、定稿、提词器版、开头更狠、老板视角”时,运行本工具。', '', '口播必须来自 VOC 用户问题、趋势证据、问题池 Top 问题、单点深挖动作或竞品评论差异。没有真实证据时要标注低证据风险。', '', '## 命令', '', '```bash', `node ${quotedPath(runner)} --brand "示例品牌A" --industry "<你的行业>" --topic "第一次买怎么选不踩雷" --user-issue "第一次买怕选错" --evidence "第一次买不知道怎么选,怕踩雷" --assistant-message-only`, '```', '', '改稿:', '', '```bash', `node ${quotedPath(runner)} --brand "示例品牌A" --topic "第一次买怎么选不踩雷" --user-issue "第一次买怕选错" --evidence "第一次买不知道怎么选,怕踩雷" --feedback "开头更狠一点,老板视角,少讲概念,多给具体场景" --assistant-message-only`, '```', '', '定稿时传 `--finalize true`,工具会写入脚本记忆。运行后把正文直接发给用户,不要展示 JSON。', '' ].join('\n'); } function workspaceCompetitorMapSkillText(targetDir) { const runner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-competitor-map-run.js'); return [ '---', 'name: voc-competitor-map', 'description: Build a competitor map and differentiated opportunity report. Use when the user asks 看竞品、竞品分析、错位竞争、别人为什么吸引顾客、我不知道竞品是谁.', 'allowed-tools: Read Write Bash(node *)', '---', '', '# VOC 竞品图谱官', '', '当用户说“看竞品、竞品分析、错位竞争、别人怎么做、我不知道竞品是谁”时,运行本工具。', '', '如果用户没有明确竞品,先按同城、同品类、同价格带、同消费场景、平台声量生成候选竞品类型。不要覆盖创始人故事。', '', '## 命令', '', '```bash', `node ${quotedPath(runner)} --brand "示例品牌A" --city "本地" --category "<你的品类>" --assistant-message-only`, '```', '', '运行后把正文直接发给用户,不要展示 JSON。', '' ].join('\n'); } function workspaceBusinessWorkflowSkillText(targetDir) { const runner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-business-workflow-run.js'); return [ '---', 'name: voc-business-workflow', 'description: Run the full VOC business workflow from market voices to issue pool, top issue action, 7-day content plan, and one speaking script. Use when the user asks 看顾客关心什么、先改哪里、下周发什么、完整VOC闭环、经营诊断.', 'allowed-tools: Read Write Bash(node *)', '---', '', '# VOC经营闭环', '', '当用户想一次拿到“看市场、看问题、找动作、做内容”的结果时,运行本工具。不要让用户理解 MCP、schema 或多个工具名。', '', '## 命令', '', '```bash', `node ${quotedPath(runner)} --brand "示例品牌A" --industry "<你的行业>" --platform douyin --collection-mode sample --keywords "<你的品类>怎么选,第一次买怎么对比,售后" --assistant-message-only`, '```', '', '真实采集时把 `--collection-mode sample` 换成 `--collection-mode live`。如果工具提示没有 token、余额不足或没有样本,直接把工具正文发给用户,并保留充值/降级建议。', '', '## 交付物', '', '必须包含五段:市场和用户声音、先改哪个问题、Top问题怎么解决、下周账号发什么、第一条口播稿。', '' ].join('\n'); } function writeWorkspaceSkillEntries(targetDir) { const xhsSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'xiaohongshu-trend-intelligence'); const douyinSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'douyin-trend-intelligence'); const issuePoolSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-issue-pool'); const deepDiveSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-problem-deep-dive'); const contentPlanSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-content-plan'); const speakingScriptSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-speaking-script'); const competitorMapSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-competitor-map'); const businessWorkflowSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-business-workflow'); const fmodeImageSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'fmode-image-analysis'); const apiCatalogSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-api-catalog'); const costControllerSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-cost-controller'); ensureDir(xhsSkillDir); ensureDir(douyinSkillDir); ensureDir(issuePoolSkillDir); ensureDir(deepDiveSkillDir); ensureDir(contentPlanSkillDir); ensureDir(speakingScriptSkillDir); ensureDir(competitorMapSkillDir); ensureDir(businessWorkflowSkillDir); ensureDir(fmodeImageSkillDir); ensureDir(apiCatalogSkillDir); ensureDir(costControllerSkillDir); fs.writeFileSync(path.join(xhsSkillDir, 'SKILL.md'), workspaceSkillText(targetDir), 'utf8'); fs.writeFileSync(path.join(douyinSkillDir, 'SKILL.md'), workspaceDouyinSkillText(targetDir), 'utf8'); fs.writeFileSync(path.join(issuePoolSkillDir, 'SKILL.md'), workspaceIssuePoolSkillText(targetDir), 'utf8'); fs.writeFileSync(path.join(deepDiveSkillDir, 'SKILL.md'), workspaceProblemDeepDiveSkillText(targetDir), 'utf8'); fs.writeFileSync(path.join(contentPlanSkillDir, 'SKILL.md'), workspaceContentPlanSkillText(targetDir), 'utf8'); fs.writeFileSync(path.join(speakingScriptSkillDir, 'SKILL.md'), workspaceSpeakingScriptSkillText(targetDir), 'utf8'); fs.writeFileSync(path.join(competitorMapSkillDir, 'SKILL.md'), workspaceCompetitorMapSkillText(targetDir), 'utf8'); fs.writeFileSync(path.join(businessWorkflowSkillDir, 'SKILL.md'), workspaceBusinessWorkflowSkillText(targetDir), 'utf8'); fs.copyFileSync(path.join(targetDir, 'skills', 'fmode-image-analysis', 'SKILL.md'), path.join(fmodeImageSkillDir, 'SKILL.md')); fs.copyFileSync(path.join(targetDir, 'skills', 'voc-api-catalog', 'SKILL.md'), path.join(apiCatalogSkillDir, 'SKILL.md')); fs.copyFileSync(path.join(targetDir, 'skills', 'voc-cost-controller', 'SKILL.md'), path.join(costControllerSkillDir, 'SKILL.md')); } function writeClaudeWorkspaceActivation(targetDir) { writeWorkspaceMcpConfig(targetDir); writeWorkspaceSkillEntries(targetDir); console.log('Wrote Claude/Fmode workspace MCP config: .\\.mcp.json'); console.log('Wrote workspace skill entry: .\\.claude\\skills\\xiaohongshu-trend-intelligence\\SKILL.md'); console.log('Wrote workspace skill entry: .\\.claude\\skills\\douyin-trend-intelligence\\SKILL.md'); console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-issue-pool\\SKILL.md'); console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-problem-deep-dive\\SKILL.md'); console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-content-plan\\SKILL.md'); console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-speaking-script\\SKILL.md'); console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-competitor-map\\SKILL.md'); console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-business-workflow\\SKILL.md'); console.log('Wrote workspace skill entry: .\\.claude\\skills\\fmode-image-analysis\\SKILL.md'); console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-api-catalog\\SKILL.md'); console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-cost-controller\\SKILL.md'); } function writeWorkspaceActivation(targetDir, opts) { if (!opts.workspaceRequested) return; if (opts.channel === 'codex') { const skillRoot = opts.scope === 'global' ? path.join(os.homedir(), '.codex', 'skills') : path.join(WORKSPACE_ROOT, '.codex', 'skills'); copyPackagedSkills(targetDir, skillRoot); if (opts.scope === 'global') { registerCodexGlobalMcp(targetDir); console.log('Registered VOC MCP with Codex user config.'); console.log(`Installed Codex user skills: ${skillRoot}`); } else { const configPath = upsertCodexProjectMcp(targetDir); console.log(`Registered VOC MCP with Codex project config: ${configPath}`); console.log(`Installed Codex project skills: ${skillRoot}`); } return; } if (opts.channel === 'workbuddy') { writeWorkspaceMcpConfig(targetDir); const skillRoot = path.join(WORKSPACE_ROOT, '.workbuddy', 'skills'); copyPackagedSkills(targetDir, skillRoot); console.log('Wrote portable WorkBuddy MCP registration: .\\.mcp.json'); console.log('Installed WorkBuddy workspace skills: .\\.workbuddy\\skills'); return; } writeClaudeWorkspaceActivation(targetDir); } async function verifyVocToolDiscovery(targetDir) { const { Client } = require('@modelcontextprotocol/sdk/client/index.js'); const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js'); const { version } = require('../package.json'); const client = new Client({ name: 'voc-installer-verification', version }); const transport = new StdioClientTransport({ command: process.execPath, args: [path.join(targetDir, 'mcp', 'src', 'server.js')], cwd: targetDir, stderr: 'pipe' }); await client.connect(transport); try { const result = await client.listTools(); const names = (result.tools || []).map(tool => tool.name); const discovered = names.filter(name => name.startsWith('voc_api_')); if (!discovered.length) { throw new Error('MCP started, but no voc_api_* tool was discovered'); } console.log(`Verified MCP tool discovery (no data request): ${discovered.join(', ')}`); return discovered; } finally { await client.close(); } } function printNextSteps(targetDir, opts) { const workspaceMode = opts.workspaceRequested; console.log(''); console.log('Install complete.'); console.log(''); if (workspaceMode) { console.log(`${opts.channel} ${opts.scope} mode is ready.`); console.log(''); console.log('Generated project files:'); console.log(` ${targetDir}`); console.log(''); console.log('Restart the VSCode Claude Code session if it was already open.'); } else { console.log('Claude Code CLI launch command:'); console.log(` claude --plugin-dir "${targetDir}"`); } console.log(''); console.log('Try this prompt in Claude Code:'); console.log(' 帮我看一下 {你的行业/品类} 最近用户在关心什么,并告诉我先改哪里、下周发什么。'); console.log(''); console.log('Or try:'); console.log(' 帮我做一份 {你的行业/品类} 的小红书趋势情报。'); console.log(' 先用演示样例跑通流程,不要真实采集。'); console.log(' 在聊天里给我第一轮样本观察和待确认问题。'); console.log(''); console.log('Or try:'); console.log(' 帮我做一份 {你的行业/品类} 的抖音趋势情报。'); console.log(' 先用演示样例跑通流程,不要真实采集。'); console.log(' 在聊天里给我第一轮样本观察和待确认问题。'); console.log(''); console.log(`Install target: ${targetDir}`); } async function install(opts) { checkNodeVersion(); if (path.resolve(SOURCE_ROOT) !== path.resolve(opts.target)) { if (fs.existsSync(opts.target)) { if (!canOverwriteTarget(opts.target, opts)) { throw new Error(`Target already exists. Use --force to overwrite custom target: ${opts.target}`); } removeDirResilient(opts.target); } ensureDir(opts.target); const copied = copyDirRecursive(SOURCE_ROOT, opts.target); console.log(`Copied skill package files: ${copied}`); } assertInstalled(opts.target); writeMcpConfig(opts.target); writeWorkspaceActivation(opts.target, opts); if (!opts.skipInstall) { console.log('Installing runtime dependencies...'); run('npm', ['install', '--omit=dev', '--ignore-scripts'], opts.target); } if (opts.smoke) { console.log('Running install smoke checks...'); run(process.execPath, ['install.js', '--smoke', '--skip-install', '--no-next'], opts.target); } else { run(process.execPath, ['install.js', '--check', '--no-next'], opts.target); } if (opts.activate) { persistNeutralCredential(); await verifyVocToolDiscovery(opts.target); } printNextSteps(opts.target, opts); } function check(opts) { assertInstalled(opts.target); run(process.execPath, ['install.js', '--check', '--no-next'], opts.target); } function smoke(opts) { assertInstalled(opts.target); run(process.execPath, ['install.js', '--smoke', '--skip-install', '--no-next'], opts.target); } function printPath(opts) { console.log(opts.target); if (isSafeWorkspaceTarget(opts.target)) { console.log('.\\.claude\\plugins\\voc-intelligence'); } else { console.log(`claude --plugin-dir "${opts.target}"`); } } async function main() { const directCommand = process.argv[2]; if (directCommand === 'image' || directCommand === 'fmode-image') { run(process.execPath, ['mcp/src/tools/fmode-image-analysis.js', ...process.argv.slice(3)], SOURCE_ROOT); return; } const opts = parseArgs(process.argv.slice(2)); if (opts.help || opts.command === 'help') { console.log(usage()); return; } if (opts.command === 'install') { await install(opts); if (opts.activate) { await ensureActivated({ channel: opts.channel }); } return; } if (opts.command === 'check') return check(opts); if (opts.command === 'smoke') return smoke(opts); if (opts.command === 'path') return printPath(opts); throw new Error(`Unknown command: ${opts.command}\n\n${usage()}`); } if (require.main === module) { main().catch((error) => { console.error(''); console.error(`claude-voc failed: ${error.message}`); process.exit(1); }); } module.exports = { parseArgs, normalizeInstallChannel, normalizeInstallScope, defaultTargetFor, upsertCodexProjectMcp, verifyVocToolDiscovery, persistNeutralCredential };