fmode-listen.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  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 SKILL_NAME = 'fmode-listen';
  7. const SOURCE_ROOT = path.resolve(__dirname, '..');
  8. const SKILL_SOURCE = path.join(SOURCE_ROOT, 'skills', SKILL_NAME);
  9. const RUNNER = path.join(SKILL_SOURCE, 'scripts', 'listen-runner.mjs');
  10. const WORKSPACE_ROOT = process.cwd();
  11. const GLOBAL_TARGET = path.join(os.homedir(), '.claude', 'skills', SKILL_NAME);
  12. const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.claude', 'skills', SKILL_NAME);
  13. const WORKSPACE_SKILLS_ROOT = path.join(WORKSPACE_ROOT, '.claude', 'skills');
  14. const GLOBAL_SKILLS_ROOT = path.join(os.homedir(), '.claude', 'skills');
  15. const RUNNER_COMMANDS = new Set(['transcribe', 'run']);
  16. function expandHome(value) {
  17. return String(value || '').replace(/^~(?=$|[\\/])/, os.homedir());
  18. }
  19. // ---------------------------------------------------------------------------
  20. // Gateway runner passthrough
  21. // ---------------------------------------------------------------------------
  22. function runRunner(passthrough) {
  23. const result = spawnSync(process.execPath, [RUNNER, ...passthrough], { stdio: 'inherit', shell: false });
  24. if (result.error) {
  25. console.error(`fmode-listen: failed to launch runner: ${result.error.message}`);
  26. process.exit(1);
  27. }
  28. process.exit(result.status == null ? 1 : result.status);
  29. }
  30. // Everything after the subcommand, dropping a single leading "--" separator.
  31. function passthroughArgs(argv) {
  32. const rest = argv.slice(1);
  33. if (rest[0] === '--') return rest.slice(1);
  34. return rest;
  35. }
  36. // ---------------------------------------------------------------------------
  37. // Skill installer (mirrors fmode-vision / fmode-ffmpeg)
  38. // ---------------------------------------------------------------------------
  39. function parseArgs(argv) {
  40. const first = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
  41. const args = { command: first, target: GLOBAL_TARGET, smoke: false, force: false, help: false };
  42. if (first === 'workspace' || first === 'install-workspace') {
  43. args.command = 'install';
  44. args.target = WORKSPACE_TARGET;
  45. }
  46. for (let i = first === argv[0] ? 1 : 0; i < argv.length; i++) {
  47. const token = argv[i];
  48. if (token === '--target' && argv[i + 1]) args.target = argv[++i];
  49. else if (token.startsWith('--target=')) args.target = token.slice('--target='.length);
  50. else if (token === '--workspace') args.target = WORKSPACE_TARGET;
  51. else if (token === '--global') args.target = GLOBAL_TARGET;
  52. else if (token === '--smoke') args.smoke = true;
  53. else if (token === '--force') args.force = true;
  54. else if (token === '--help' || token === '-h') args.help = true;
  55. }
  56. args.target = path.resolve(expandHome(args.target));
  57. return args;
  58. }
  59. function printHelp() {
  60. console.log([
  61. 'fmode-listen — 录音转写网关客户端 + Claude Code 技能安装器',
  62. '',
  63. '通过 Fmode 网关转写音频(讯飞录音文件转写,凭据仅服务端):',
  64. ' npx fmode-listen@latest transcribe -- audio.mp3 [--language autodialect] [--diarize]',
  65. ' 需要 fmode token(环境变量 FMODE_API_TOKEN 或 ~/.fmode/config.json)',
  66. '',
  67. '安装 Claude Code 技能:',
  68. ' npx fmode-listen@latest workspace [--smoke] # 安装到 ./.claude/skills/fmode-listen',
  69. ' npx fmode-listen@latest install [--smoke] # 安装到 ~/.claude/skills/fmode-listen',
  70. ' npx fmode-listen@latest install --target <dir> [--force]',
  71. ' npx fmode-listen@latest check',
  72. ' npx fmode-listen@latest smoke',
  73. ' npx fmode-listen@latest path',
  74. '',
  75. 'Options:',
  76. ' --workspace 安装到 ./.claude/skills/fmode-listen',
  77. ' --global 安装到 ~/.claude/skills/fmode-listen(默认)',
  78. ' --target <dir> 安装到自定义目录',
  79. ' --force 允许覆盖自定义目录',
  80. ' --smoke 安装后运行冒烟检查',
  81. ' --help, -h 显示帮助'
  82. ].join('\n'));
  83. }
  84. function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); }
  85. function isInside(parentDir, childDir) {
  86. const relative = path.relative(path.resolve(parentDir), path.resolve(childDir));
  87. return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
  88. }
  89. function canOverwriteTarget(targetDir, force) {
  90. return force
  91. || path.resolve(targetDir) === path.resolve(GLOBAL_TARGET)
  92. || isInside(WORKSPACE_SKILLS_ROOT, targetDir)
  93. || isInside(GLOBAL_SKILLS_ROOT, targetDir);
  94. }
  95. function copyDirRecursive(source, destination) {
  96. const stat = fs.statSync(source);
  97. if (stat.isDirectory()) {
  98. ensureDir(destination);
  99. for (const child of fs.readdirSync(source)) {
  100. if (child === 'node_modules' || child === 'outputs' || child === '.git') continue;
  101. copyDirRecursive(path.join(source, child), path.join(destination, child));
  102. }
  103. return;
  104. }
  105. ensureDir(path.dirname(destination));
  106. fs.copyFileSync(source, destination);
  107. }
  108. function installSkill(target, force) {
  109. if (!fs.existsSync(SKILL_SOURCE)) {
  110. throw new Error(`Skill source missing: ${SKILL_SOURCE}`);
  111. }
  112. if (fs.existsSync(target)) {
  113. if (!canOverwriteTarget(target, force)) {
  114. throw new Error(`Refusing to overwrite custom target without --force: ${target}`);
  115. }
  116. fs.rmSync(target, { recursive: true, force: true });
  117. }
  118. ensureDir(target);
  119. copyDirRecursive(SKILL_SOURCE, target);
  120. }
  121. function checkSkill(target) {
  122. const required = ['SKILL.md', 'scripts/listen-runner.mjs'];
  123. const missing = required.filter(entry => !fs.existsSync(path.join(target, entry)));
  124. if (missing.length) {
  125. throw new Error(`Install target is missing required files: ${missing.join(', ')}`);
  126. }
  127. return { status: 'ok', skill: SKILL_NAME, target, required };
  128. }
  129. function runSmoke() {
  130. const result = spawnSync(process.execPath, ['scripts/smoke.js'], { cwd: SOURCE_ROOT, stdio: 'inherit', shell: false });
  131. if (result.status !== 0) throw new Error('smoke failed');
  132. }
  133. function printNextSteps(target) {
  134. const workspaceMode = isInside(WORKSPACE_SKILLS_ROOT, target);
  135. console.log('');
  136. console.log('Install complete.');
  137. console.log(`Skill installed at: ${target}`);
  138. console.log('');
  139. if (workspaceMode) {
  140. console.log('Project-level skill is ready. Restart the VSCode Claude Code session if it was open.');
  141. } else {
  142. console.log('User-level skill is ready for all Claude Code workspaces.');
  143. }
  144. console.log('');
  145. console.log('转写走 Fmode 网关(凭据仅服务端),客户端只需 fmode token。');
  146. console.log('Try this prompt in Claude Code:');
  147. console.log(' 把 meeting.mp3 转写成文字,开启说话人分离。');
  148. }
  149. function main() {
  150. const argv = process.argv.slice(2);
  151. const command = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
  152. // Gateway runner passthrough (handled before the installer arg parser).
  153. if (RUNNER_COMMANDS.has(command)) {
  154. runRunner(passthroughArgs(argv));
  155. return;
  156. }
  157. const args = parseArgs(argv);
  158. if (args.help || args.command === 'help') { printHelp(); return; }
  159. if (args.command === 'path') { console.log(args.target); return; }
  160. if (args.command === 'install') {
  161. installSkill(args.target, args.force);
  162. console.log(JSON.stringify(checkSkill(args.target), null, 2));
  163. if (args.smoke) runSmoke();
  164. printNextSteps(args.target);
  165. return;
  166. }
  167. if (args.command === 'check') { console.log(JSON.stringify(checkSkill(args.target), null, 2)); return; }
  168. if (args.command === 'smoke') { runSmoke(); return; }
  169. printHelp();
  170. process.exitCode = 1;
  171. }
  172. try { main(); }
  173. catch (error) { console.error(`fmode-listen failed: ${error.message}`); process.exit(1); }