claude-voc.js 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967
  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 { ensureActivated } = require('../mcp/src/core/activation');
  7. const { readNewApiToken } = require('../mcp/src/core/credentials');
  8. const SOURCE_ROOT = path.resolve(__dirname, '..');
  9. const WORKSPACE_ROOT = process.cwd();
  10. const DEFAULT_TARGET = path.join(os.homedir(), '.claude', 'plugins', 'voc-intelligence');
  11. const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.claude', 'plugins', 'voc-intelligence');
  12. const WORKSPACE_PLUGINS_ROOT = path.join(WORKSPACE_ROOT, '.claude', 'plugins');
  13. const CODEX_PROJECT_TARGET = path.join(WORKSPACE_ROOT, '.codex', 'plugins', 'voc-intelligence');
  14. const CODEX_GLOBAL_TARGET = path.join(os.homedir(), '.codex', 'plugins', 'voc-intelligence');
  15. const PORTABLE_WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.vocmarket', 'plugins', 'voc-intelligence');
  16. const VOCMARKET_CREDENTIALS = path.join(os.homedir(), '.vocmarket', 'credentials.json');
  17. const MIN_NODE_MAJOR = 18;
  18. const EXCLUDED_DIRS = new Set([
  19. 'node_modules',
  20. 'memory',
  21. 'outputs',
  22. '.npm-cache-acceptance',
  23. '.tmp',
  24. '.git',
  25. '.claude'
  26. ]);
  27. const EXCLUDED_FILES = new Set([
  28. '.env',
  29. '.env.local',
  30. '.npmrc'
  31. ]);
  32. function usage() {
  33. return [
  34. 'Claude VOC skill package installer',
  35. '',
  36. 'Usage:',
  37. ' claude-voc install',
  38. ' claude-voc workspace',
  39. ' claude-voc workspace --activate --channel <name>',
  40. ' claude-voc install --workspace --smoke',
  41. ' claude-voc check',
  42. ' claude-voc smoke',
  43. ' claude-voc image --image-path <file> --prompt <text>',
  44. ' claude-voc path',
  45. '',
  46. 'npx:',
  47. ' npx @vocmarket/voc-skill install',
  48. ' npx @vocmarket/voc-skill workspace --smoke',
  49. ' npx @vocmarket/voc-skill workspace --activate (其他 AI 编程器渠道专用:安装并按状态弹付费)',
  50. '',
  51. 'Options:',
  52. ' --workspace Install into ./.claude/plugins/voc-intelligence',
  53. ' --target <dir> Install into a custom directory',
  54. ' --force Allow overwriting an existing custom target',
  55. ' --skip-install Skip npm install after copying files',
  56. ' --smoke Run sample/preference/MCP smoke checks after install',
  57. ' --activate 安装后按 token 状态激活:无 token 弹付费/登录页,有 token 直接放行(外部 AI IDE 专用,不影响 --smoke)',
  58. ' --channel <name> 安装渠道(claude-code/codex/workbuddy/fmode-studio)',
  59. ' --scope <name> Codex 安装范围(project/global,默认 project)',
  60. ' --help, -h Show help'
  61. ].join('\n');
  62. }
  63. function expandHome(value) {
  64. return value.replace(/^~(?=$|[\\/])/, os.homedir());
  65. }
  66. function parseArgs(argv) {
  67. const first = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
  68. const opts = {
  69. command: first,
  70. target: DEFAULT_TARGET,
  71. skipInstall: false,
  72. smoke: false,
  73. activate: false,
  74. channel: 'external',
  75. scope: 'project',
  76. targetExplicit: false,
  77. workspaceRequested: first === 'workspace' || first === 'install-workspace',
  78. force: false,
  79. help: false
  80. };
  81. if (first === 'workspace' || first === 'install-workspace') {
  82. opts.command = 'install';
  83. opts.target = WORKSPACE_TARGET;
  84. }
  85. for (let i = first === argv[0] ? 1 : 0; i < argv.length; i++) {
  86. const token = argv[i];
  87. if (token === '--target') {
  88. opts.target = argv[++i];
  89. opts.targetExplicit = true;
  90. } else if (token.startsWith('--target=')) {
  91. opts.target = token.slice('--target='.length);
  92. opts.targetExplicit = true;
  93. } else if (token === '--workspace') {
  94. opts.target = WORKSPACE_TARGET;
  95. opts.workspaceRequested = true;
  96. } else if (token === '--force') {
  97. opts.force = true;
  98. } else if (token === '--skip-install') {
  99. opts.skipInstall = true;
  100. } else if (token === '--smoke') {
  101. opts.smoke = true;
  102. } else if (token === '--activate') {
  103. opts.activate = true;
  104. } else if (token === '--channel') {
  105. opts.channel = argv[++i];
  106. } else if (token.startsWith('--channel=')) {
  107. opts.channel = token.slice('--channel='.length);
  108. } else if (token === '--scope') {
  109. opts.scope = argv[++i];
  110. } else if (token.startsWith('--scope=')) {
  111. opts.scope = token.slice('--scope='.length);
  112. } else if (token === '--help' || token === '-h') {
  113. opts.help = true;
  114. }
  115. }
  116. opts.channel = normalizeInstallChannel(opts.channel);
  117. opts.scope = normalizeInstallScope(opts.channel, opts.scope);
  118. if (opts.workspaceRequested && !opts.targetExplicit) {
  119. opts.target = defaultTargetFor(opts.channel, opts.scope);
  120. }
  121. opts.target = path.resolve(expandHome(opts.target));
  122. return opts;
  123. }
  124. function normalizeInstallChannel(value) {
  125. const channel = String(value || 'claude-code').trim().toLowerCase();
  126. if (channel === 'external') return 'claude-code';
  127. if (!['claude-code', 'codex', 'workbuddy', 'fmode-studio'].includes(channel)) {
  128. throw new Error(`Unsupported install channel: ${channel}`);
  129. }
  130. return channel;
  131. }
  132. function normalizeInstallScope(channel, value) {
  133. if (channel !== 'codex') return 'project';
  134. const scope = String(value || 'project').trim().toLowerCase();
  135. if (!['project', 'global'].includes(scope)) {
  136. throw new Error(`Unsupported Codex install scope: ${scope}`);
  137. }
  138. return scope;
  139. }
  140. function defaultTargetFor(channel, scope) {
  141. if (channel === 'codex') return scope === 'global' ? CODEX_GLOBAL_TARGET : CODEX_PROJECT_TARGET;
  142. if (channel === 'workbuddy') return PORTABLE_WORKSPACE_TARGET;
  143. return WORKSPACE_TARGET;
  144. }
  145. function run(command, args, cwd) {
  146. const useCmd = process.platform === 'win32' && command === 'npm';
  147. const executable = useCmd ? 'cmd.exe' : command;
  148. const finalArgs = useCmd ? ['/d', '/s', '/c', 'npm', ...args] : args;
  149. const child = spawnSync(executable, finalArgs, {
  150. cwd,
  151. encoding: 'utf8',
  152. stdio: 'inherit',
  153. maxBuffer: 1024 * 1024 * 100
  154. });
  155. if (child.status !== 0) {
  156. const detail = child.error ? `: ${child.error.message}` : '';
  157. throw new Error(`${command} ${args.join(' ')} failed with exit ${child.status}${detail}`);
  158. }
  159. }
  160. function checkNodeVersion() {
  161. const major = Number(process.versions.node.split('.')[0]);
  162. if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) {
  163. throw new Error(`Node.js ${MIN_NODE_MAJOR}+ is required. Current version: ${process.version}`);
  164. }
  165. }
  166. function ensureDir(dirPath) {
  167. fs.mkdirSync(dirPath, { recursive: true });
  168. }
  169. // Remove an existing install dir, resilient to Windows locks.
  170. // A normal recursive remove fails with EPERM/EBUSY when the directory is held
  171. // by a running process — most commonly a Claude Code session / VSCode terminal
  172. // whose current working directory is inside the plugin dir. Such a directory
  173. // can still be *renamed* even though it cannot be deleted, so on failure we move
  174. // it aside and install fresh, then best-effort delete the moved-aside copy.
  175. function removeDirResilient(targetDir) {
  176. try {
  177. fs.rmSync(targetDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
  178. return;
  179. } catch (err) {
  180. if (!['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'].includes(err.code)) throw err;
  181. const asidePath = `${targetDir}.held-${Date.now()}`;
  182. try {
  183. fs.renameSync(targetDir, asidePath);
  184. } catch (renameErr) {
  185. const e = new Error(
  186. `无法更新技能目录(被占用):${targetDir}\n` +
  187. `原因:该目录正被一个运行中的进程占用(常见是该工作区里仍开着的 Claude Code 会话或 VSCode 终端,其当前目录在该插件目录内)。\n` +
  188. `请关闭占用该目录的 Claude Code 会话/终端后重试安装。\n` +
  189. `(${err.code}: ${err.message}; rename fallback failed: ${renameErr.code || renameErr.message})`
  190. );
  191. e.code = err.code;
  192. throw e;
  193. }
  194. console.log(`Existing skill directory was in use; moved aside to: ${asidePath}`);
  195. try {
  196. fs.rmSync(asidePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
  197. } catch (cleanupErr) {
  198. console.log(`Note: moved-aside copy is still in use and was not deleted. Remove it later: ${asidePath}`);
  199. }
  200. }
  201. }
  202. function isInside(parentDir, childDir) {
  203. const relative = path.relative(path.resolve(parentDir), path.resolve(childDir));
  204. return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
  205. }
  206. function isSafeDefaultTarget(targetDir) {
  207. return path.resolve(targetDir) === path.resolve(DEFAULT_TARGET);
  208. }
  209. function isSafeWorkspaceTarget(targetDir) {
  210. return isInside(WORKSPACE_PLUGINS_ROOT, targetDir);
  211. }
  212. function isSafeManagedTarget(targetDir) {
  213. return [WORKSPACE_TARGET, CODEX_PROJECT_TARGET, CODEX_GLOBAL_TARGET, PORTABLE_WORKSPACE_TARGET]
  214. .some(candidate => path.resolve(candidate) === path.resolve(targetDir));
  215. }
  216. function canOverwriteTarget(targetDir, opts) {
  217. return opts.force || isSafeDefaultTarget(targetDir) || isSafeWorkspaceTarget(targetDir) || isSafeManagedTarget(targetDir);
  218. }
  219. function shouldCopyEntry(entry) {
  220. if (entry.isDirectory()) return !EXCLUDED_DIRS.has(entry.name);
  221. if (entry.isFile()) {
  222. if (EXCLUDED_FILES.has(entry.name)) return false;
  223. if (entry.name.endsWith('.tgz')) return false;
  224. return true;
  225. }
  226. return false;
  227. }
  228. function copyDirRecursive(srcDir, destDir) {
  229. ensureDir(destDir);
  230. let count = 0;
  231. for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
  232. if (!shouldCopyEntry(entry)) continue;
  233. const src = path.join(srcDir, entry.name);
  234. const dest = path.join(destDir, entry.name);
  235. if (entry.isDirectory()) {
  236. count += copyDirRecursive(src, dest);
  237. } else if (entry.isFile()) {
  238. ensureDir(path.dirname(dest));
  239. fs.copyFileSync(src, dest);
  240. count++;
  241. }
  242. }
  243. return count;
  244. }
  245. function assertInstalled(targetDir) {
  246. const required = [
  247. 'package.json',
  248. 'install.js',
  249. '.claude-plugin/plugin.json',
  250. '.mcp.json',
  251. 'skills/xiaohongshu-trend-intelligence/SKILL.md',
  252. 'skills/douyin-trend-intelligence/SKILL.md',
  253. 'skills/voc-issue-pool/SKILL.md',
  254. 'skills/voc-problem-deep-dive/SKILL.md',
  255. 'skills/voc-content-plan/SKILL.md',
  256. 'skills/voc-speaking-script/SKILL.md',
  257. 'skills/voc-competitor-map/SKILL.md',
  258. 'skills/voc-business-workflow/SKILL.md',
  259. 'skills/fmode-image-analysis/SKILL.md',
  260. 'skills/voc-api-catalog/SKILL.md',
  261. 'skills/voc-cost-controller/SKILL.md',
  262. 'mcp/src/tools/fmode-image-analysis.js',
  263. 'mcp/src/tools/voc-api-catalog-run.js',
  264. 'mcp/catalog/voc-social-endpoints.json',
  265. 'mcp/src/server.js'
  266. ];
  267. const missing = required.filter(rel => !fs.existsSync(path.join(targetDir, rel)));
  268. if (missing.length) {
  269. throw new Error(`Missing required files in install target: ${missing.join(', ')}`);
  270. }
  271. }
  272. function writeMcpConfig(targetDir) {
  273. const serverPath = path.join(targetDir, 'mcp', 'src', 'server.js');
  274. const mcpConfig = {
  275. mcpServers: {
  276. voc: {
  277. command: 'node',
  278. args: [serverPath],
  279. cwd: targetDir
  280. }
  281. }
  282. };
  283. fs.writeFileSync(path.join(targetDir, '.mcp.json'), `${JSON.stringify(mcpConfig, null, 2)}\n`, 'utf8');
  284. }
  285. function readJsonIfExists(filePath) {
  286. if (!fs.existsSync(filePath)) return {};
  287. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  288. }
  289. function writeJsonMcpConfig(configPath, targetDir) {
  290. const existing = readJsonIfExists(configPath);
  291. const mcpConfig = {
  292. ...existing,
  293. mcpServers: {
  294. ...(existing.mcpServers || {}),
  295. voc: {
  296. command: 'node',
  297. args: [path.join(targetDir, 'mcp', 'src', 'server.js')],
  298. cwd: targetDir
  299. }
  300. }
  301. };
  302. ensureDir(path.dirname(configPath));
  303. fs.writeFileSync(configPath, `${JSON.stringify(mcpConfig, null, 2)}\n`, 'utf8');
  304. }
  305. function writeWorkspaceMcpConfig(targetDir) {
  306. const mcpConfigPath = path.join(WORKSPACE_ROOT, '.mcp.json');
  307. writeJsonMcpConfig(mcpConfigPath, targetDir);
  308. }
  309. function tomlString(value) {
  310. return JSON.stringify(String(value));
  311. }
  312. function upsertCodexProjectMcp(targetDir, workspaceRoot = WORKSPACE_ROOT) {
  313. const configPath = path.join(workspaceRoot, '.codex', 'config.toml');
  314. const original = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
  315. const lines = original.replace(/\r\n/g, '\n').split('\n');
  316. const kept = [];
  317. let skipping = false;
  318. for (const line of lines) {
  319. const table = line.match(/^\s*\[([^\]]+)]\s*(?:#.*)?$/);
  320. if (table) {
  321. skipping = table[1] === 'mcp_servers.voc' || table[1].startsWith('mcp_servers.voc.');
  322. }
  323. if (!skipping) kept.push(line);
  324. }
  325. while (kept.length && !kept[kept.length - 1].trim()) kept.pop();
  326. const serverPath = path.join(targetDir, 'mcp', 'src', 'server.js');
  327. const block = [
  328. '[mcp_servers.voc]',
  329. 'command = "node"',
  330. `args = [${tomlString(serverPath)}]`,
  331. `cwd = ${tomlString(targetDir)}`
  332. ];
  333. ensureDir(path.dirname(configPath));
  334. fs.writeFileSync(configPath, `${kept.concat(kept.length ? [''] : [], block).join('\n')}\n`, 'utf8');
  335. return configPath;
  336. }
  337. function runCapture(command, args, cwd = WORKSPACE_ROOT) {
  338. const useCmd = process.platform === 'win32';
  339. const executable = useCmd ? 'cmd.exe' : command;
  340. const finalArgs = useCmd ? ['/d', '/s', '/c', command, ...args] : args;
  341. return spawnSync(executable, finalArgs, {
  342. cwd,
  343. encoding: 'utf8',
  344. stdio: ['ignore', 'pipe', 'pipe'],
  345. maxBuffer: 1024 * 1024 * 10
  346. });
  347. }
  348. function registerCodexGlobalMcp(targetDir) {
  349. const getResult = runCapture('codex', ['mcp', 'get', 'voc']);
  350. if (!getResult.error && getResult.status === 0) {
  351. const removeResult = runCapture('codex', ['mcp', 'remove', 'voc']);
  352. if (removeResult.error || removeResult.status !== 0) {
  353. throw new Error('Unable to update the existing Codex VOC MCP registration');
  354. }
  355. }
  356. const serverPath = path.join(targetDir, 'mcp', 'src', 'server.js');
  357. const addResult = runCapture('codex', ['mcp', 'add', 'voc', '--', 'node', serverPath]);
  358. if (addResult.error || addResult.status !== 0) {
  359. const detail = String(addResult.stderr || addResult.error?.message || '').trim();
  360. throw new Error(`Codex MCP registration failed${detail ? `: ${detail}` : ''}`);
  361. }
  362. }
  363. function copyPackagedSkills(targetDir, destinationRoot) {
  364. const sourceRoot = path.join(targetDir, 'skills');
  365. ensureDir(destinationRoot);
  366. let copied = 0;
  367. for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {
  368. if (!entry.isDirectory()) continue;
  369. const destination = path.join(destinationRoot, entry.name);
  370. removeDirResilient(destination);
  371. copied += copyDirRecursive(path.join(sourceRoot, entry.name), destination);
  372. }
  373. return copied;
  374. }
  375. function persistNeutralCredential(credentialsPath = VOCMARKET_CREDENTIALS) {
  376. const token = readNewApiToken();
  377. if (!token) return false;
  378. const existing = readJsonIfExists(credentialsPath);
  379. ensureDir(path.dirname(credentialsPath));
  380. fs.writeFileSync(credentialsPath, `${JSON.stringify({ ...existing, fmodeApiKey: token }, null, 2)}\n`, {
  381. encoding: 'utf8',
  382. mode: 0o600
  383. });
  384. try {
  385. fs.chmodSync(credentialsPath, 0o600);
  386. } catch {}
  387. return true;
  388. }
  389. function asForwardSlash(filePath) {
  390. return path.resolve(filePath).replace(/\\/g, '/');
  391. }
  392. function quotedPath(filePath) {
  393. return `"${asForwardSlash(filePath)}"`;
  394. }
  395. function workspaceTrendSkillText(options) {
  396. const trendRunner = path.join(options.targetDir, 'mcp', 'src', 'tools', options.trendTool);
  397. const preferenceUpdater = path.join(options.targetDir, 'mcp', 'src', 'tools', options.preferenceTool);
  398. const profilePath = path.join(options.targetDir, 'memory-templates', options.profileFile);
  399. const defaultMemoryPath = path.join(WORKSPACE_ROOT, 'outputs', options.memoryDir, options.memoryFile);
  400. const liveLimits = options.platform === '抖音'
  401. ? '--keyword-limit 1 --videos-per-keyword 1 --max-comment-pages 1'
  402. : '--keyword-limit 1 --notes-per-keyword 1 --max-comment-pages 1';
  403. return [
  404. '---',
  405. `name: ${options.skillName}`,
  406. `description: ${options.description}`,
  407. 'allowed-tools: Read Write Bash(node *)',
  408. '---',
  409. '',
  410. `# ${options.platform}趋势情报官`,
  411. '',
  412. `你是面向业务用户的“${options.platform}趋势情报官”。用户要的是第一轮样本观察、机会假设和待确认问题,不是技术日志。`,
  413. '',
  414. '## 必须执行',
  415. '',
  416. `- 用户要求${options.platform}趋势情报、内容趋势、评论分析、真实采集、live采集或社媒 VOC 时,必须运行本技能包的工具或 MCP,不要只凭经验回答。`,
  417. '- 用户说“live / 真实采集 / 进入 live / 实时采集 / 抓真实数据”时,必须运行 live 模式工具。不要回复“我不能实时抓取外部数据”。本技能包已经提供本地 VOC 采集入口。',
  418. '- live 模式返回 `needs_token`、`needs_recharge` 或 `needs_valid_token` 时,必须把工具输出里的 `assistantMessage` 原样转述给用户。`needs_token` 是缺 token、不是没钱:先引导自救(读 `~/.claude/settings.json` 的 `sk-` token),不要劝充值。',
  419. '- 如果工具输出里有 `nextActions`,转述其中的自救/充值链接(以工具实际返回为准,不要写死某个链接)。',
  420. '- 第一轮只说“初步判断 / 机会假设 / 待校准”,不要说成最终结论。',
  421. '- 后续用户说“保留、不要、更偏、这个不准”等反馈时,运行偏好更新命令沉淀记忆。',
  422. '',
  423. '## MANDATORY Live Rule',
  424. '',
  425. '- If the user asks for live collection, real collection, or entering live mode, run the local VOC tool in live mode.',
  426. '- Do not answer that you cannot fetch external data. The local VOC tool is the approved collection entry.',
  427. '- If the tool returns `needs_token`, `needs_recharge`, or `needs_valid_token`, paste its `assistantMessage` to the user verbatim.',
  428. '- `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. 完整口径见下方「充值 / 计费口径」。',
  429. '',
  430. '## sample 命令',
  431. '',
  432. '```bash',
  433. `node ${quotedPath(trendRunner)} --collection-mode sample --profile ${quotedPath(profilePath)} --output ${JSON.stringify(options.sampleOutput)} --assistant-message-only`,
  434. '```',
  435. '',
  436. '## live 命令',
  437. '',
  438. '```bash',
  439. `node ${quotedPath(trendRunner)} --collection-mode live --profile ${quotedPath(profilePath)} --output ${JSON.stringify(options.liveOutput)} ${liveLimits} --assistant-message-only`,
  440. '```',
  441. '',
  442. '运行后把命令输出正文直接发给用户。不要展示 JSON、summary、data、files 或调试日志。',
  443. '',
  444. '## 偏好更新命令',
  445. '',
  446. '```bash',
  447. `node ${quotedPath(preferenceUpdater)} --message "<用户反馈原文>" --memory ${quotedPath(defaultMemoryPath)} --result-prefix ${options.resultPrefix}`,
  448. '```',
  449. '',
  450. '## 充值 / 计费口径(简版;完整对照见 voc-api-catalog 技能的「错误码速查」references/error-codes.md)',
  451. '',
  452. '- 缺 token(`needs_token`)= 可恢复、不是没钱:先自救读 `~/.claude/settings.json` 的 `sk-` fmode key(或 `~/.fmode/config.json` / `~/.claude/voc-credentials.json`),用 `FMODE_API_KEY=sk-…` 重试,不要劝充值。',
  453. '- 只有真 `402 余额不足` 才充值;充值入口以工具返回的 Tokenized Balance 链接或二维码支付结果为准。链接格式为 `https://app.fmode.cn/dev/studio/balance/?token=USER_SESSION_TOKEN`,不要在话术中写死账号或旧 APIG 参数。',
  454. '- 实际充值/自救链接以工具返回的 `assistantMessage` / `nextActions` 为准,不要写死。',
  455. ''
  456. ].join('\n');
  457. }
  458. function workspaceSkillText(targetDir) {
  459. return workspaceTrendSkillText({
  460. targetDir,
  461. skillName: 'xiaohongshu-trend-intelligence',
  462. platform: '小红书',
  463. description: 'Build Xiaohongshu trend intelligence reports. Use when the user asks for 小红书趋势情报、行业趋势、内容选题、销售话术、真实采集、live采集、用户顾虑 or social VOC analysis.',
  464. trendTool: 'xiaohongshu-trend-run.js',
  465. preferenceTool: 'xiaohongshu-preference-update.js',
  466. profileFile: 'xiaohongshu-trend-profile.json',
  467. memoryDir: 'claude-code-xhs-memory',
  468. memoryFile: 'xiaohongshu-trend-memory.json',
  469. sampleOutput: 'outputs/claude-code-xhs-sample',
  470. liveOutput: 'outputs/claude-code-xhs-live',
  471. resultPrefix: 'XHS_PREF_RESULT'
  472. });
  473. }
  474. function workspaceDouyinSkillText(targetDir) {
  475. return workspaceTrendSkillText({
  476. targetDir,
  477. skillName: 'douyin-trend-intelligence',
  478. platform: '抖音',
  479. description: 'Build Douyin trend intelligence reports. Use when the user asks for 抖音趋势情报、视频评论、口播选题、短视频开头、内容趋势、真实采集、live采集、用户顾虑 or social VOC analysis.',
  480. trendTool: 'douyin-trend-run.js',
  481. preferenceTool: 'douyin-preference-update.js',
  482. profileFile: 'douyin-trend-profile.json',
  483. memoryDir: 'claude-code-douyin-memory',
  484. memoryFile: 'douyin-trend-memory.json',
  485. sampleOutput: 'outputs/claude-code-douyin-sample',
  486. liveOutput: 'outputs/claude-code-douyin-live',
  487. resultPrefix: 'DOUYIN_PREF_RESULT'
  488. });
  489. }
  490. function workspaceProblemDeepDiveSkillText(targetDir) {
  491. const deepDiveRunner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-problem-deep-dive-run.js');
  492. return [
  493. '---',
  494. 'name: voc-problem-deep-dive',
  495. '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深挖.',
  496. 'allowed-tools: Read Write Bash(node *)',
  497. '---',
  498. '',
  499. '# 单点 VOC 深挖官',
  500. '',
  501. '你是面向老板和门店经营者的“单点 VOC 深挖官”。用户不需要学习提示词。用户只要说“这个问题继续挖”“老板怎么解决”“这个差评背后是什么”,你就要把真实用户声音转成可执行经营动作。',
  502. '',
  503. '## 必须执行',
  504. '',
  505. '- 当用户说“继续挖、深入分析、老板怎么解决、这个问题怎么办、怎么改、怎么转成内容、单点 VOC”时,必须运行单点深挖工具。',
  506. '- 不要输出提示词教学,不要让用户自己设计分析维度。',
  507. '- 输出必须站在老板视角:这件事影响新客、复购、客单、口碑,还是现场效率。',
  508. '- 每次只深挖一个问题点。如果用户给了多个问题,先选择最影响转化的一个。',
  509. '- 如果上一轮趋势报告里有证据样本或高赞评论,要把相关评论作为 evidence 传入工具。',
  510. '- 要使用记忆能力:用户说“这个动作不适合”“我们已经试过”“这个有效”“下次更偏内容/门店动作”时,把原话作为 `feedback` 传入;如果能明确识别,也同步传 `blockedActions`、`validatedActions`、`rejectedActions` 或 `preferredActions`。',
  511. '- 后续多轮迭代时要带上 `project`/`brand`/`store`、`industry`、`scenario`、`audience`。未显式传 `memory` 时,工具会按这些字段自动隔离记忆,避免不同行业、不同客户串味。',
  512. '- 如果用户没有指定记忆文件,不要强行要求用户理解路径;让工具使用默认分桶记忆即可。只有在同一个客户需要固定沉淀时,才显式传同一个 `memory` 路径。',
  513. '',
  514. '## 命令',
  515. '',
  516. '```bash',
  517. `node ${quotedPath(deepDiveRunner)} --issue "怎么选" --industry "<你的行业>" --scenario "线上获客" --audience "意向用户" --assistant-message-only`,
  518. '```',
  519. '',
  520. '## 带记忆的多轮迭代',
  521. '',
  522. '```bash',
  523. `node ${quotedPath(deepDiveRunner)} --issue "怎么选" --industry "<你的行业>" --scenario "线上获客" --audience "意向用户" --memory "outputs/voc-problem-memory.json" --feedback "老板不想做直播带看,更想先改评论区回复和方案说明" --blocked-actions "直播带看" --preferred-actions "评论区回复,方案说明" --assistant-message-only`,
  524. '```',
  525. '',
  526. '自然语言反馈也可以直接传入 `feedback`,工具会尝试自动提取偏好和屏蔽动作:',
  527. '',
  528. '```bash',
  529. `node ${quotedPath(deepDiveRunner)} --issue "怎么选" --project "示例品牌A" --industry "<你的行业>" --scenario "线上获客" --audience "意向用户" --feedback "这个品牌不想做直播带看,更想先改评论区回复和方案说明" --assistant-message-only`,
  530. '```',
  531. '',
  532. '运行后把命令输出正文直接发给用户。不要展示 JSON、summary、data、files 或调试日志。',
  533. ''
  534. ].join('\n');
  535. }
  536. function workspaceIssuePoolSkillText(targetDir) {
  537. const issuePoolRunner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-issue-pool-run.js');
  538. return [
  539. '---',
  540. 'name: voc-issue-pool',
  541. '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问题管理、问题优先级、把评论整理成问题.',
  542. 'allowed-tools: Read Write Bash(node *)',
  543. '---',
  544. '',
  545. '# VOC 问题池官',
  546. '',
  547. '你是面向老板、门店经营者和营销负责人的 VOC 问题池官。用户不需要知道分类法,也不需要学习提示词。用户只要说“这些评论里哪些问题最重要”“先改哪个”“整理成问题池”,你就把真实用户声音整理成可排序、可跟进、可继续深挖的问题列表。',
  548. '',
  549. '## 必须执行',
  550. '',
  551. '- 当用户说“问题池、问题清单、用户吐槽什么、哪些问题最影响生意、先改哪个、VOC 问题管理、把评论整理成问题”时,优先运行 `voc_issue_pool_run`。',
  552. '- 如果上一轮小红书/抖音报告里有证据样本或高赞评论,把相关评论作为 `evidence`、`evidenceText` 或 `reportPath` 传入。',
  553. '- 输出必须站在业务视角:这个问题影响新客、复购、客单、口碑、内容信任,还是现场效率。',
  554. '- 不要只做词频统计。每个问题都要包含证据样本、影响环节、建议动作和下一步。',
  555. '- 如果用户说某个问题已解决、验证中、暂缓,要通过 `resolvedIssues`、`validatingIssues`、`blockedIssues` 或 `statusUpdates` 更新问题状态。',
  556. '- 后续深挖时,引导用户直接说“继续深挖「问题名」”,然后转入 `voc-problem-deep-dive`。',
  557. '',
  558. '## 命令',
  559. '',
  560. '```bash',
  561. `node ${quotedPath(issuePoolRunner)} --project "示例品牌A" --industry "<你的行业>" --scenario "线上获客" --audience "意向用户" --evidence "第一次买不知道怎么选,怕踩雷。,价格有点高,不确定值不值。,咨询的时候没人理。" --assistant-message-only`,
  562. '```',
  563. '',
  564. '带报告文件:',
  565. '',
  566. '```bash',
  567. `node ${quotedPath(issuePoolRunner)} --project "示例品牌A" --industry "<你的行业>" --report "outputs/douyin-trend-report.md" --assistant-message-only`,
  568. '```',
  569. '',
  570. '运行后把命令输出正文直接发给用户。不要展示 JSON、summary、data、files 或调试日志。',
  571. ''
  572. ].join('\n');
  573. }
  574. function workspaceContentPlanSkillText(targetDir) {
  575. const runner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-content-plan-run.js');
  576. return [
  577. '---',
  578. 'name: voc-content-plan',
  579. 'description: Turn VOC issues into a 7-day content plan with short-video topics and speaking scripts. Use when the user asks 下周账号发什么、口播脚本、短视频选题、把用户问题变成内容、7天内容计划.',
  580. 'allowed-tools: Read Write Bash(node *)',
  581. '---',
  582. '',
  583. '# VOC 内容选题官',
  584. '',
  585. '当用户说“下周发什么、7 天选题、口播脚本、短视频脚本、账号内容、把问题变成内容”时,运行本工具。',
  586. '',
  587. '必须把内容绑定到 VOC 用户问题,不要输出空泛爆款标题。不要覆盖创始人故事。',
  588. '',
  589. '## 命令',
  590. '',
  591. '```bash',
  592. `node ${quotedPath(runner)} --brand "示例品牌A" --industry "<你的行业>" --issues "第一次买怕踩雷;觉得价格贵;怕质量不稳定" --assistant-message-only`,
  593. '```',
  594. '',
  595. '运行后把正文直接发给用户,不要展示 JSON。',
  596. ''
  597. ].join('\n');
  598. }
  599. function workspaceSpeakingScriptSkillText(targetDir) {
  600. const runner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-speaking-script-run.js');
  601. return [
  602. '---',
  603. 'name: voc-speaking-script',
  604. '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进入脚本共创、改稿、定稿、口播稿、提词器、开头更狠、老板视角、少讲概念.',
  605. 'allowed-tools: Read Write Bash(node *)',
  606. '---',
  607. '',
  608. '# VOC 口播脚本共创',
  609. '',
  610. '当用户从 7 天内容计划里选择某条选题,或说“进入脚本共创、改稿、定稿、提词器版、开头更狠、老板视角”时,运行本工具。',
  611. '',
  612. '口播必须来自 VOC 用户问题、趋势证据、问题池 Top 问题、单点深挖动作或竞品评论差异。没有真实证据时要标注低证据风险。',
  613. '',
  614. '## 命令',
  615. '',
  616. '```bash',
  617. `node ${quotedPath(runner)} --brand "示例品牌A" --industry "<你的行业>" --topic "第一次买怎么选不踩雷" --user-issue "第一次买怕选错" --evidence "第一次买不知道怎么选,怕踩雷" --assistant-message-only`,
  618. '```',
  619. '',
  620. '改稿:',
  621. '',
  622. '```bash',
  623. `node ${quotedPath(runner)} --brand "示例品牌A" --topic "第一次买怎么选不踩雷" --user-issue "第一次买怕选错" --evidence "第一次买不知道怎么选,怕踩雷" --feedback "开头更狠一点,老板视角,少讲概念,多给具体场景" --assistant-message-only`,
  624. '```',
  625. '',
  626. '定稿时传 `--finalize true`,工具会写入脚本记忆。运行后把正文直接发给用户,不要展示 JSON。',
  627. ''
  628. ].join('\n');
  629. }
  630. function workspaceCompetitorMapSkillText(targetDir) {
  631. const runner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-competitor-map-run.js');
  632. return [
  633. '---',
  634. 'name: voc-competitor-map',
  635. 'description: Build a competitor map and differentiated opportunity report. Use when the user asks 看竞品、竞品分析、错位竞争、别人为什么吸引顾客、我不知道竞品是谁.',
  636. 'allowed-tools: Read Write Bash(node *)',
  637. '---',
  638. '',
  639. '# VOC 竞品图谱官',
  640. '',
  641. '当用户说“看竞品、竞品分析、错位竞争、别人怎么做、我不知道竞品是谁”时,运行本工具。',
  642. '',
  643. '如果用户没有明确竞品,先按同城、同品类、同价格带、同消费场景、平台声量生成候选竞品类型。不要覆盖创始人故事。',
  644. '',
  645. '## 命令',
  646. '',
  647. '```bash',
  648. `node ${quotedPath(runner)} --brand "示例品牌A" --city "本地" --category "<你的品类>" --assistant-message-only`,
  649. '```',
  650. '',
  651. '运行后把正文直接发给用户,不要展示 JSON。',
  652. ''
  653. ].join('\n');
  654. }
  655. function workspaceBusinessWorkflowSkillText(targetDir) {
  656. const runner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-business-workflow-run.js');
  657. return [
  658. '---',
  659. 'name: voc-business-workflow',
  660. '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闭环、经营诊断.',
  661. 'allowed-tools: Read Write Bash(node *)',
  662. '---',
  663. '',
  664. '# VOC经营闭环',
  665. '',
  666. '当用户想一次拿到“看市场、看问题、找动作、做内容”的结果时,运行本工具。不要让用户理解 MCP、schema 或多个工具名。',
  667. '',
  668. '## 命令',
  669. '',
  670. '```bash',
  671. `node ${quotedPath(runner)} --brand "示例品牌A" --industry "<你的行业>" --platform douyin --collection-mode sample --keywords "<你的品类>怎么选,第一次买怎么对比,售后" --assistant-message-only`,
  672. '```',
  673. '',
  674. '真实采集时把 `--collection-mode sample` 换成 `--collection-mode live`。如果工具提示没有 token、余额不足或没有样本,直接把工具正文发给用户,并保留充值/降级建议。',
  675. '',
  676. '## 交付物',
  677. '',
  678. '必须包含五段:市场和用户声音、先改哪个问题、Top问题怎么解决、下周账号发什么、第一条口播稿。',
  679. ''
  680. ].join('\n');
  681. }
  682. function writeWorkspaceSkillEntries(targetDir) {
  683. const xhsSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'xiaohongshu-trend-intelligence');
  684. const douyinSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'douyin-trend-intelligence');
  685. const issuePoolSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-issue-pool');
  686. const deepDiveSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-problem-deep-dive');
  687. const contentPlanSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-content-plan');
  688. const speakingScriptSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-speaking-script');
  689. const competitorMapSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-competitor-map');
  690. const businessWorkflowSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-business-workflow');
  691. const fmodeImageSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'fmode-image-analysis');
  692. const apiCatalogSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-api-catalog');
  693. const costControllerSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-cost-controller');
  694. ensureDir(xhsSkillDir);
  695. ensureDir(douyinSkillDir);
  696. ensureDir(issuePoolSkillDir);
  697. ensureDir(deepDiveSkillDir);
  698. ensureDir(contentPlanSkillDir);
  699. ensureDir(speakingScriptSkillDir);
  700. ensureDir(competitorMapSkillDir);
  701. ensureDir(businessWorkflowSkillDir);
  702. ensureDir(fmodeImageSkillDir);
  703. ensureDir(apiCatalogSkillDir);
  704. ensureDir(costControllerSkillDir);
  705. fs.writeFileSync(path.join(xhsSkillDir, 'SKILL.md'), workspaceSkillText(targetDir), 'utf8');
  706. fs.writeFileSync(path.join(douyinSkillDir, 'SKILL.md'), workspaceDouyinSkillText(targetDir), 'utf8');
  707. fs.writeFileSync(path.join(issuePoolSkillDir, 'SKILL.md'), workspaceIssuePoolSkillText(targetDir), 'utf8');
  708. fs.writeFileSync(path.join(deepDiveSkillDir, 'SKILL.md'), workspaceProblemDeepDiveSkillText(targetDir), 'utf8');
  709. fs.writeFileSync(path.join(contentPlanSkillDir, 'SKILL.md'), workspaceContentPlanSkillText(targetDir), 'utf8');
  710. fs.writeFileSync(path.join(speakingScriptSkillDir, 'SKILL.md'), workspaceSpeakingScriptSkillText(targetDir), 'utf8');
  711. fs.writeFileSync(path.join(competitorMapSkillDir, 'SKILL.md'), workspaceCompetitorMapSkillText(targetDir), 'utf8');
  712. fs.writeFileSync(path.join(businessWorkflowSkillDir, 'SKILL.md'), workspaceBusinessWorkflowSkillText(targetDir), 'utf8');
  713. fs.copyFileSync(path.join(targetDir, 'skills', 'fmode-image-analysis', 'SKILL.md'), path.join(fmodeImageSkillDir, 'SKILL.md'));
  714. fs.copyFileSync(path.join(targetDir, 'skills', 'voc-api-catalog', 'SKILL.md'), path.join(apiCatalogSkillDir, 'SKILL.md'));
  715. fs.copyFileSync(path.join(targetDir, 'skills', 'voc-cost-controller', 'SKILL.md'), path.join(costControllerSkillDir, 'SKILL.md'));
  716. }
  717. function writeClaudeWorkspaceActivation(targetDir) {
  718. writeWorkspaceMcpConfig(targetDir);
  719. writeWorkspaceSkillEntries(targetDir);
  720. console.log('Wrote Claude/Fmode workspace MCP config: .\\.mcp.json');
  721. console.log('Wrote workspace skill entry: .\\.claude\\skills\\xiaohongshu-trend-intelligence\\SKILL.md');
  722. console.log('Wrote workspace skill entry: .\\.claude\\skills\\douyin-trend-intelligence\\SKILL.md');
  723. console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-issue-pool\\SKILL.md');
  724. console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-problem-deep-dive\\SKILL.md');
  725. console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-content-plan\\SKILL.md');
  726. console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-speaking-script\\SKILL.md');
  727. console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-competitor-map\\SKILL.md');
  728. console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-business-workflow\\SKILL.md');
  729. console.log('Wrote workspace skill entry: .\\.claude\\skills\\fmode-image-analysis\\SKILL.md');
  730. console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-api-catalog\\SKILL.md');
  731. console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-cost-controller\\SKILL.md');
  732. }
  733. function writeWorkspaceActivation(targetDir, opts) {
  734. if (!opts.workspaceRequested) return;
  735. if (opts.channel === 'codex') {
  736. const skillRoot = opts.scope === 'global'
  737. ? path.join(os.homedir(), '.codex', 'skills')
  738. : path.join(WORKSPACE_ROOT, '.codex', 'skills');
  739. copyPackagedSkills(targetDir, skillRoot);
  740. if (opts.scope === 'global') {
  741. registerCodexGlobalMcp(targetDir);
  742. console.log('Registered VOC MCP with Codex user config.');
  743. console.log(`Installed Codex user skills: ${skillRoot}`);
  744. } else {
  745. const configPath = upsertCodexProjectMcp(targetDir);
  746. console.log(`Registered VOC MCP with Codex project config: ${configPath}`);
  747. console.log(`Installed Codex project skills: ${skillRoot}`);
  748. }
  749. return;
  750. }
  751. if (opts.channel === 'workbuddy') {
  752. writeWorkspaceMcpConfig(targetDir);
  753. const skillRoot = path.join(WORKSPACE_ROOT, '.workbuddy', 'skills');
  754. copyPackagedSkills(targetDir, skillRoot);
  755. console.log('Wrote portable WorkBuddy MCP registration: .\\.mcp.json');
  756. console.log('Installed WorkBuddy workspace skills: .\\.workbuddy\\skills');
  757. return;
  758. }
  759. writeClaudeWorkspaceActivation(targetDir);
  760. }
  761. async function verifyVocToolDiscovery(targetDir) {
  762. const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
  763. const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
  764. const { version } = require('../package.json');
  765. const client = new Client({ name: 'voc-installer-verification', version });
  766. const transport = new StdioClientTransport({
  767. command: process.execPath,
  768. args: [path.join(targetDir, 'mcp', 'src', 'server.js')],
  769. cwd: targetDir,
  770. stderr: 'pipe'
  771. });
  772. await client.connect(transport);
  773. try {
  774. const result = await client.listTools();
  775. const names = (result.tools || []).map(tool => tool.name);
  776. const discovered = names.filter(name => name.startsWith('voc_api_'));
  777. if (!discovered.length) {
  778. throw new Error('MCP started, but no voc_api_* tool was discovered');
  779. }
  780. console.log(`Verified MCP tool discovery (no data request): ${discovered.join(', ')}`);
  781. return discovered;
  782. } finally {
  783. await client.close();
  784. }
  785. }
  786. function printNextSteps(targetDir, opts) {
  787. const workspaceMode = opts.workspaceRequested;
  788. console.log('');
  789. console.log('Install complete.');
  790. console.log('');
  791. if (workspaceMode) {
  792. console.log(`${opts.channel} ${opts.scope} mode is ready.`);
  793. console.log('');
  794. console.log('Generated project files:');
  795. console.log(` ${targetDir}`);
  796. console.log('');
  797. console.log('Restart the VSCode Claude Code session if it was already open.');
  798. } else {
  799. console.log('Claude Code CLI launch command:');
  800. console.log(` claude --plugin-dir "${targetDir}"`);
  801. }
  802. console.log('');
  803. console.log('Try this prompt in Claude Code:');
  804. console.log(' 帮我看一下 {你的行业/品类} 最近用户在关心什么,并告诉我先改哪里、下周发什么。');
  805. console.log('');
  806. console.log('Or try:');
  807. console.log(' 帮我做一份 {你的行业/品类} 的小红书趋势情报。');
  808. console.log(' 先用演示样例跑通流程,不要真实采集。');
  809. console.log(' 在聊天里给我第一轮样本观察和待确认问题。');
  810. console.log('');
  811. console.log('Or try:');
  812. console.log(' 帮我做一份 {你的行业/品类} 的抖音趋势情报。');
  813. console.log(' 先用演示样例跑通流程,不要真实采集。');
  814. console.log(' 在聊天里给我第一轮样本观察和待确认问题。');
  815. console.log('');
  816. console.log(`Install target: ${targetDir}`);
  817. }
  818. async function install(opts) {
  819. checkNodeVersion();
  820. if (path.resolve(SOURCE_ROOT) !== path.resolve(opts.target)) {
  821. if (fs.existsSync(opts.target)) {
  822. if (!canOverwriteTarget(opts.target, opts)) {
  823. throw new Error(`Target already exists. Use --force to overwrite custom target: ${opts.target}`);
  824. }
  825. removeDirResilient(opts.target);
  826. }
  827. ensureDir(opts.target);
  828. const copied = copyDirRecursive(SOURCE_ROOT, opts.target);
  829. console.log(`Copied skill package files: ${copied}`);
  830. }
  831. assertInstalled(opts.target);
  832. writeMcpConfig(opts.target);
  833. writeWorkspaceActivation(opts.target, opts);
  834. if (!opts.skipInstall) {
  835. console.log('Installing runtime dependencies...');
  836. run('npm', ['install', '--omit=dev', '--ignore-scripts'], opts.target);
  837. }
  838. if (opts.smoke) {
  839. console.log('Running install smoke checks...');
  840. run(process.execPath, ['install.js', '--smoke', '--skip-install', '--no-next'], opts.target);
  841. } else {
  842. run(process.execPath, ['install.js', '--check', '--no-next'], opts.target);
  843. }
  844. if (opts.activate) {
  845. persistNeutralCredential();
  846. await verifyVocToolDiscovery(opts.target);
  847. }
  848. printNextSteps(opts.target, opts);
  849. }
  850. function check(opts) {
  851. assertInstalled(opts.target);
  852. run(process.execPath, ['install.js', '--check', '--no-next'], opts.target);
  853. }
  854. function smoke(opts) {
  855. assertInstalled(opts.target);
  856. run(process.execPath, ['install.js', '--smoke', '--skip-install', '--no-next'], opts.target);
  857. }
  858. function printPath(opts) {
  859. console.log(opts.target);
  860. if (isSafeWorkspaceTarget(opts.target)) {
  861. console.log('.\\.claude\\plugins\\voc-intelligence');
  862. } else {
  863. console.log(`claude --plugin-dir "${opts.target}"`);
  864. }
  865. }
  866. async function main() {
  867. const directCommand = process.argv[2];
  868. if (directCommand === 'image' || directCommand === 'fmode-image') {
  869. run(process.execPath, ['mcp/src/tools/fmode-image-analysis.js', ...process.argv.slice(3)], SOURCE_ROOT);
  870. return;
  871. }
  872. const opts = parseArgs(process.argv.slice(2));
  873. if (opts.help || opts.command === 'help') {
  874. console.log(usage());
  875. return;
  876. }
  877. if (opts.command === 'install') {
  878. await install(opts);
  879. if (opts.activate) {
  880. await ensureActivated({ channel: opts.channel });
  881. }
  882. return;
  883. }
  884. if (opts.command === 'check') return check(opts);
  885. if (opts.command === 'smoke') return smoke(opts);
  886. if (opts.command === 'path') return printPath(opts);
  887. throw new Error(`Unknown command: ${opts.command}\n\n${usage()}`);
  888. }
  889. if (require.main === module) {
  890. main().catch((error) => {
  891. console.error('');
  892. console.error(`claude-voc failed: ${error.message}`);
  893. process.exit(1);
  894. });
  895. }
  896. module.exports = {
  897. parseArgs,
  898. normalizeInstallChannel,
  899. normalizeInstallScope,
  900. defaultTargetFor,
  901. upsertCodexProjectMcp,
  902. verifyVocToolDiscovery,
  903. persistNeutralCredential
  904. };