Sfoglia il codice sorgente

skill-listen v0.2.0 开源版: 讯飞LFASR转写×Fmode网关, 多工具安装指南(ClaudeCode/Codex/GeminiCLI/WorkBuddy/Hermes), 5级token加载链, 零密钥

liuyuyang 1 settimana fa
commit
9a3c302ef4
9 ha cambiato i file con 873 aggiunte e 0 eliminazioni
  1. 4 0
      .gitignore
  2. 21 0
      LICENSE
  3. 117 0
      README.md
  4. 192 0
      bin/fmode-listen.js
  5. 38 0
      package.json
  6. 40 0
      scripts/smoke.js
  7. 17 0
      skill-package-manifest.json
  8. 126 0
      skills/SKILL.md
  9. 318 0
      skills/scripts/listen-runner.mjs

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+node_modules/
+*.tgz
+output/
+.env

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 fmodecn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 117 - 0
README.md

@@ -0,0 +1,117 @@
+# skill-listen · 录音转写技能(讯飞 LFASR × Fmode 网关)
+
+> 把录音/视频的音轨转写成文字——**AI Agent 的"耳朵"**。
+> 讯飞「录音文件转写」(LFASR) 异步识别,经 Fmode 网关 `POST /api/listen/transcribe` 完成。讯飞凭据仅服务端持有,客户端只需 fmode token,按音频真实时长计费。
+
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
+[![npm](https://img.shields.io/badge/npm-fmode--listen-blue)](https://www.npmjs.com/package/fmode-listen)
+
+## 能力
+
+- 🎙️ 会议 / 采访 / 课程录音转文字(中英多语种 + 方言)
+- 🎬 视频先抽音轨再转写(配合 `fmode-ffmpeg`:`npx fmode-ffmpeg exec -- -y -i in.mp4 -vn -ar 16000 -ac 1 out.wav`)
+- 👥 说话人分离(多人对话按角色整理)
+- 📄 输出纯文本或完整 JSON(`--out result.json`)
+
+## 快速开始
+
+```bash
+# 直接连网关转写(无需安装技能,一行命令)
+npx --yes fmode-listen@latest transcribe -- meeting.mp3
+
+# 指定语言 + 说话人分离 + 写出完整 JSON
+npx --yes fmode-listen@latest transcribe -- meeting.mp3 \
+  --language autodialect --diarize --speakers 3 --out result.json
+
+# 自定义网关(默认 https://server.fmode.cn/api/listen)
+npx --yes fmode-listen@latest transcribe -- meeting.mp3 --gateway https://server.fmode.cn/api/listen
+```
+
+> `transcribe` 后必须加 `--`,其后参数透传给运行器。
+
+## 鉴权(token 从哪来)
+
+客户端只需要 **fmode token**。运行器按以下优先级自动解析(**仓库与代码中无任何密钥**):
+
+1. 环境变量 `FMODE_API_TOKEN`
+2. `~/.fmode/config.json` → `fmodeApiToken` / `newapiToken`(FmodeStudio 保存配置后写入)
+3. 项目 `./.fmode/config.json` → 同上
+4. 进程注入的 `ANTHROPIC_AUTH_TOKEN`(Claude Code 把 settings.env 注入子进程时自动命中)
+5. `~/.claude/settings.json`(含 settings.local.json / 项目级 `.claude/`)的 `env.ANTHROPIC_AUTH_TOKEN` —— 即 Claude Code 的 `sk-` token(仅当 `sk-` 开头且非 `sk-ant-` 时采纳)
+
+> 报「未找到 token」= 缺 token,不是技能坏——按上面任一来源补上即可。**不要把任何密钥写进本仓库或 issue。**
+
+## 各工具安装指南
+
+本技能遵循各 Agent 工具的 Skill 规范。**AI 拿到本仓库后,按自己工具对应的节安装。**
+
+### Claude Code(推荐 npx 安装器)
+
+```bash
+npx --yes fmode-listen@latest install          # 装到 ~/.claude/skills/fmode-listen
+npx --yes fmode-listen@latest workspace        # 或装到当前项目 ./.claude/skills/
+npx --yes fmode-listen@latest check            # 校验安装
+```
+
+装完在 Claude Code 里直接说:「帮我把这段会议录音转成文字」即可触发。
+
+### Codex
+
+Codex 无 skill 机制,用 **CLI 直连**方式(技能体只是提示词封装):
+
+```bash
+# AGENTS.md 里加一段:
+# ## 录音转写
+# 转写音频用: npx --yes fmode-listen@latest transcribe -- <file> [--diarize --out out.json]
+# token 从环境变量 FMODE_API_TOKEN 或 ~/.fmode/config.json 读取。
+```
+
+或直接把 `skills/fmode-listen/SKILL.md` 内容粘进你的 `AGENTS.md` / 自定义 instructions。
+
+### Gemini CLI
+
+Gemini CLI 支持自定义命令(`~/.gemini/commands/`)。把 `skills/fmode-listen/SKILL.md` 存为:
+
+```
+~/.gemini/commands/transcribe.toml
+```
+
+prompt 段写:`npx --yes fmode-listen@latest transcribe -- {{args}}`,之后 `/transcribe meeting.mp3` 即可调用。
+
+### WorkBuddy / 其他 Skill 规范工具
+
+凡支持「SKILL.md + scripts/」目录规范的工具(WorkBuddy、Hermes 等):
+
+```bash
+git clone https://github.com/fmodecn/skill-listen.git
+cp -r skill-listen/skills/fmode-listen <你的工具技能目录>/fmode-listen
+```
+
+技能目录结构:
+
+```
+fmode-listen/
+├── SKILL.md            # 技能说明(frontmatter: name/description)
+└── scripts/
+    └── listen-runner.mjs   # 运行器(Node ≥18,零依赖)
+```
+
+### Hermes Agent
+
+复制技能目录到 `~/.hermes/skills/`(或 profile 对应 skills 目录),Hermes 的 skill 加载器会读取 SKILL.md:
+
+```bash
+git clone https://github.com/fmodecn/skill-listen.git
+cp -r skill-listen/skills/fmode-listen ~/.hermes/skills/
+hermes skills   # 确认 fmode-listen 出现在列表
+```
+
+## 计费与安全
+
+- **服务端计费**:`ceil(音频分钟数) × 单价`,不足 1 分钟按 1 分钟;余额不足返回 402 + 充值链接
+- **凭据零下放**:讯飞 appId/apiKey/secretKey 只在服务端;本仓库任何代码/文档都不得写入真实密钥
+- **网关可自托管**:`--gateway` 指向自建服务即可脱离 Fmode 云
+
+## License
+
+MIT

+ 192 - 0
bin/fmode-listen.js

@@ -0,0 +1,192 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const SKILL_NAME = 'fmode-listen';
+const SOURCE_ROOT = path.resolve(__dirname, '..');
+const SKILL_SOURCE = path.join(SOURCE_ROOT, 'skills', SKILL_NAME);
+const RUNNER = path.join(SKILL_SOURCE, 'scripts', 'listen-runner.mjs');
+const WORKSPACE_ROOT = process.cwd();
+const GLOBAL_TARGET = path.join(os.homedir(), '.claude', 'skills', SKILL_NAME);
+const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.claude', 'skills', SKILL_NAME);
+const WORKSPACE_SKILLS_ROOT = path.join(WORKSPACE_ROOT, '.claude', 'skills');
+const GLOBAL_SKILLS_ROOT = path.join(os.homedir(), '.claude', 'skills');
+
+const RUNNER_COMMANDS = new Set(['transcribe', 'run']);
+
+function expandHome(value) {
+  return String(value || '').replace(/^~(?=$|[\\/])/, os.homedir());
+}
+
+// ---------------------------------------------------------------------------
+// Gateway runner passthrough
+// ---------------------------------------------------------------------------
+function runRunner(passthrough) {
+  const result = spawnSync(process.execPath, [RUNNER, ...passthrough], { stdio: 'inherit', shell: false });
+  if (result.error) {
+    console.error(`fmode-listen: failed to launch runner: ${result.error.message}`);
+    process.exit(1);
+  }
+  process.exit(result.status == null ? 1 : result.status);
+}
+
+// Everything after the subcommand, dropping a single leading "--" separator.
+function passthroughArgs(argv) {
+  const rest = argv.slice(1);
+  if (rest[0] === '--') return rest.slice(1);
+  return rest;
+}
+
+// ---------------------------------------------------------------------------
+// Skill installer (mirrors fmode-vision / fmode-ffmpeg)
+// ---------------------------------------------------------------------------
+function parseArgs(argv) {
+  const first = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
+  const args = { command: first, target: GLOBAL_TARGET, smoke: false, force: false, help: false };
+  if (first === 'workspace' || first === 'install-workspace') {
+    args.command = 'install';
+    args.target = WORKSPACE_TARGET;
+  }
+  for (let i = first === argv[0] ? 1 : 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (token === '--target' && argv[i + 1]) args.target = argv[++i];
+    else if (token.startsWith('--target=')) args.target = token.slice('--target='.length);
+    else if (token === '--workspace') args.target = WORKSPACE_TARGET;
+    else if (token === '--global') args.target = GLOBAL_TARGET;
+    else if (token === '--smoke') args.smoke = true;
+    else if (token === '--force') args.force = true;
+    else if (token === '--help' || token === '-h') args.help = true;
+  }
+  args.target = path.resolve(expandHome(args.target));
+  return args;
+}
+
+function printHelp() {
+  console.log([
+    'fmode-listen — 录音转写网关客户端 + Claude Code 技能安装器',
+    '',
+    '通过 Fmode 网关转写音频(讯飞录音文件转写,凭据仅服务端):',
+    '  npx fmode-listen@latest transcribe -- audio.mp3 [--language autodialect] [--diarize]',
+    '      需要 fmode token(环境变量 FMODE_API_TOKEN 或 ~/.fmode/config.json)',
+    '',
+    '安装 Claude Code 技能:',
+    '  npx fmode-listen@latest workspace [--smoke]   # 安装到 ./.claude/skills/fmode-listen',
+    '  npx fmode-listen@latest install [--smoke]     # 安装到 ~/.claude/skills/fmode-listen',
+    '  npx fmode-listen@latest install --target <dir> [--force]',
+    '  npx fmode-listen@latest check',
+    '  npx fmode-listen@latest smoke',
+    '  npx fmode-listen@latest path',
+    '',
+    'Options:',
+    '  --workspace      安装到 ./.claude/skills/fmode-listen',
+    '  --global         安装到 ~/.claude/skills/fmode-listen(默认)',
+    '  --target <dir>   安装到自定义目录',
+    '  --force          允许覆盖自定义目录',
+    '  --smoke          安装后运行冒烟检查',
+    '  --help, -h       显示帮助'
+  ].join('\n'));
+}
+
+function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); }
+
+function isInside(parentDir, childDir) {
+  const relative = path.relative(path.resolve(parentDir), path.resolve(childDir));
+  return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
+}
+
+function canOverwriteTarget(targetDir, force) {
+  return force
+    || path.resolve(targetDir) === path.resolve(GLOBAL_TARGET)
+    || isInside(WORKSPACE_SKILLS_ROOT, targetDir)
+    || isInside(GLOBAL_SKILLS_ROOT, targetDir);
+}
+
+function copyDirRecursive(source, destination) {
+  const stat = fs.statSync(source);
+  if (stat.isDirectory()) {
+    ensureDir(destination);
+    for (const child of fs.readdirSync(source)) {
+      if (child === 'node_modules' || child === 'outputs' || child === '.git') continue;
+      copyDirRecursive(path.join(source, child), path.join(destination, child));
+    }
+    return;
+  }
+  ensureDir(path.dirname(destination));
+  fs.copyFileSync(source, destination);
+}
+
+function installSkill(target, force) {
+  if (!fs.existsSync(SKILL_SOURCE)) {
+    throw new Error(`Skill source missing: ${SKILL_SOURCE}`);
+  }
+  if (fs.existsSync(target)) {
+    if (!canOverwriteTarget(target, force)) {
+      throw new Error(`Refusing to overwrite custom target without --force: ${target}`);
+    }
+    fs.rmSync(target, { recursive: true, force: true });
+  }
+  ensureDir(target);
+  copyDirRecursive(SKILL_SOURCE, target);
+}
+
+function checkSkill(target) {
+  const required = ['SKILL.md', 'scripts/listen-runner.mjs'];
+  const missing = required.filter(entry => !fs.existsSync(path.join(target, entry)));
+  if (missing.length) {
+    throw new Error(`Install target is missing required files: ${missing.join(', ')}`);
+  }
+  return { status: 'ok', skill: SKILL_NAME, target, required };
+}
+
+function runSmoke() {
+  const result = spawnSync(process.execPath, ['scripts/smoke.js'], { cwd: SOURCE_ROOT, stdio: 'inherit', shell: false });
+  if (result.status !== 0) throw new Error('smoke failed');
+}
+
+function printNextSteps(target) {
+  const workspaceMode = isInside(WORKSPACE_SKILLS_ROOT, target);
+  console.log('');
+  console.log('Install complete.');
+  console.log(`Skill installed at: ${target}`);
+  console.log('');
+  if (workspaceMode) {
+    console.log('Project-level skill is ready. Restart the VSCode Claude Code session if it was open.');
+  } else {
+    console.log('User-level skill is ready for all Claude Code workspaces.');
+  }
+  console.log('');
+  console.log('转写走 Fmode 网关(凭据仅服务端),客户端只需 fmode token。');
+  console.log('Try this prompt in Claude Code:');
+  console.log('  把 meeting.mp3 转写成文字,开启说话人分离。');
+}
+
+function main() {
+  const argv = process.argv.slice(2);
+  const command = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
+
+  // Gateway runner passthrough (handled before the installer arg parser).
+  if (RUNNER_COMMANDS.has(command)) {
+    runRunner(passthroughArgs(argv));
+    return;
+  }
+
+  const args = parseArgs(argv);
+  if (args.help || args.command === 'help') { printHelp(); return; }
+  if (args.command === 'path') { console.log(args.target); return; }
+  if (args.command === 'install') {
+    installSkill(args.target, args.force);
+    console.log(JSON.stringify(checkSkill(args.target), null, 2));
+    if (args.smoke) runSmoke();
+    printNextSteps(args.target);
+    return;
+  }
+  if (args.command === 'check') { console.log(JSON.stringify(checkSkill(args.target), null, 2)); return; }
+  if (args.command === 'smoke') { runSmoke(); return; }
+  printHelp();
+  process.exitCode = 1;
+}
+
+try { main(); }
+catch (error) { console.error(`fmode-listen failed: ${error.message}`); process.exit(1); }

+ 38 - 0
package.json

@@ -0,0 +1,38 @@
+{
+  "name": "fmode-listen",
+  "version": "0.2.0",
+  "description": "Claude Code skill: 录音文件转写(讯飞 LFASR)通过 Fmode 网关 /api/listen/transcribe 完成。讯飞凭据仅服务端持有,客户端只需 fmode token,服务端按音频时长计费。提供 `npx fmode-listen transcribe` 直连命令 + Claude Code 技能安装器。",
+  "type": "commonjs",
+  "bin": {
+    "fmode-listen": "bin/fmode-listen.js"
+  },
+  "scripts": {
+    "smoke": "node scripts/smoke.js"
+  },
+  "files": [
+    ".claude-plugin/",
+    "bin/",
+    "README.md",
+    "scripts/",
+    "skill-package-manifest.json",
+    "skills/"
+  ],
+  "keywords": [
+    "claude-code",
+    "claude-skill",
+    "fmode",
+    "listen",
+    "transcribe",
+    "transcription",
+    "asr",
+    "iflytek",
+    "speech-to-text",
+    "audio"
+  ],
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "git+ssh://git@github.com/fmodecn/skill-listen.git"
+  },
+  "homepage": "https://github.com/fmodecn/skill-listen#readme"
+}

+ 40 - 0
scripts/smoke.js

@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { pathToFileURL } = require('url');
+
+const ROOT = path.resolve(__dirname, '..');
+const SKILL_DIR = path.join(ROOT, 'skills', 'fmode-listen');
+const BIN = path.join(ROOT, 'bin', 'fmode-listen.js');
+
+function fail(msg) { console.error('SMOKE FAIL: ' + msg); process.exit(1); }
+
+const required = [
+  'SKILL.md',
+  'scripts/listen-runner.mjs'
+];
+for (const rel of required) {
+  if (!fs.existsSync(path.join(SKILL_DIR, rel))) fail('missing ' + rel);
+}
+
+(async () => {
+  // 1. skill runner module exports
+  const mod = await import(pathToFileURL(path.join(SKILL_DIR, 'scripts', 'listen-runner.mjs')).href);
+  for (const fn of ['resolveApiToken', 'probeDurationMs', 'transcribeFile']) {
+    if (typeof mod[fn] !== 'function') fail('export ' + fn + ' is not a function');
+  }
+
+  // 2. bin help runs
+  const help = spawnSync(process.execPath, [BIN, 'help'], { encoding: 'utf8' });
+  if (help.status !== 0) fail('`fmode-listen help` exited ' + help.status);
+  if (!/fmode-listen/.test(help.stdout || '')) fail('help output missing banner');
+
+  // 3. bin path resolves
+  const p = spawnSync(process.execPath, [BIN, 'path'], { encoding: 'utf8' });
+  if (p.status !== 0) fail('`fmode-listen path` exited ' + p.status);
+  if (!/fmode-listen/.test(p.stdout || '')) fail('path output missing skill name');
+
+  console.log('SMOKE OK: fmode-listen structure + runner exports verified');
+  console.log('  install path: ' + (p.stdout || '').trim());
+})().catch(e => fail(e.message));

+ 17 - 0
skill-package-manifest.json

@@ -0,0 +1,17 @@
+{
+  "name": "fmode-listen",
+  "version": "0.1.1",
+  "description": "Claude Code 独立技能包:录音文件转写(讯飞 LFASR)通过 Fmode 网关 /api/listen/transcribe 完成。讯飞凭据仅服务端持有,客户端只需 fmode token,服务端按音频真实时长计费。支持中英多语种、方言、说话人分离。",
+  "plugin": "fmode-listen",
+  "skills": [
+    "fmode-listen"
+  ],
+  "entrySkill": "fmode-listen",
+  "npmPackage": "fmode-listen",
+  "smokeCommand": "npm run smoke",
+  "installCommand": "npx fmode-listen@latest install",
+  "workspaceInstallCommand": "npx fmode-listen@latest workspace",
+  "workspaceSkillPath": ".claude/skills/fmode-listen/SKILL.md",
+  "globalSkillPath": "%USERPROFILE%/.claude/skills/fmode-listen/SKILL.md",
+  "installHint": "工作区安装:npx fmode-listen@latest workspace,会写入 ./.claude/skills/fmode-listen/。用户级安装:npx fmode-listen@latest install,会写入 ~/.claude/skills/fmode-listen/。转写走 Fmode 网关 /api/listen/transcribe,凭据仅服务端,客户端只需 fmode token(环境变量 FMODE_API_TOKEN > ~/.fmode/config.json > ~/.claude/settings.json 的 env.ANTHROPIC_AUTH_TOKEN,即 Claude Code 的 sk- token,自动读取、无需手动配置)。"
+}

+ 126 - 0
skills/SKILL.md

@@ -0,0 +1,126 @@
+---
+name: fmode-listen
+description: "把录音文件转写成文字(讯飞「录音文件转写」LFASR),通过 Fmode 网关 /api/listen/transcribe 完成。适用场景:(1) 会议/采访/课程录音转文字, (2) 视频先抽音轨再转写, (3) 需要中英多语种/方言识别, (4) 需要说话人分离的多人对话整理。讯飞凭据仅服务端持有,客户端只需 fmode token,服务端按音频真实时长计费。"
+description_en: "Transcribe recorded audio to text (iFlytek LFASR) through the Fmode gateway /api/listen/transcribe. Use for: (1) meeting/interview/lecture transcription, (2) extracting audio from video then transcribing, (3) multi-language/dialect recognition, (4) speaker diarization for multi-speaker conversations. iFlytek credentials live only on the server; the client only needs an fmode token, and the server bills by actual audio duration."
+---
+
+# Fmode Listen — 录音转写网关技能
+
+## Overview
+
+本技能把本地音频文件交给 **Fmode 网关** `POST /api/listen/transcribe`,由服务端调用讯飞
+「录音文件转写」(LFASR 异步转写)完成识别。
+
+关键约束:
+- **客户端不持有讯飞凭据**——appId/apiKey/secretKey 仅在服务端。客户端只需携带 **fmode token**。
+- **计费在服务端**:服务端拿到真实音频时长后按 `ceil(分钟) × 单价` 扣费(与其它 Fmode APIG 模型同一套 newapi 计量统计),不足 1 分钟按 1 分钟计。余额不足返回 402 + 充值链接。
+- 音频当前由网关中转上传讯飞(客户端 → 网关 → 讯飞),无需本地存储讯飞密钥。
+
+> 与 `fmode-ffmpeg` 配合:视频或大体积音频先用 `npx fmode-ffmpeg exec -- -y -i input.mp4 -vn -ar 16000 -ac 1 out.wav` 转成 16kHz 单声道 wav,再交给本技能,能显著降低上传体积、提高识别稳定性。
+
+## 鉴权(必读)
+
+客户端只需提供 **fmode token**,运行器按以下优先级自动解析:
+
+1. 环境变量 `FMODE_API_TOKEN`
+2. `~/.fmode/config.json` 的 `fmodeApiToken` / `newapiToken` 字段(FmodeStudio 保存配置后写入)
+3. 项目 `./.fmode/config.json` 的 `fmodeApiToken` / `newapiToken` 字段
+4. `~/.claude/settings.json`(含 `settings.local.json` / 项目级 `.claude/`)的 `env.ANTHROPIC_AUTH_TOKEN`——**这就是 Claude Code 的 `sk-` token,运行器会自动读取,无需手动配置**。仅当 `sk-` 开头(排除真 Anthropic 的 `sk-ant-`)且 base 指向 fmode 时才采纳。
+
+> 这把 `sk-` 就是你在 Claude Code / FmodeStudio 里配的 fmode newapi token,装完技能即可命中。若运行器报「未找到 token」,那是缺 token、**不是「用不了」**——请勿点任何付费/充值弹窗,按上面任一来源补上即可。
+>
+> 不要在任何示例或代码里写讯飞 appId/apiKey/secretKey——它们只属于服务端。
+
+## 用法
+
+### 命令行直接转写
+
+```bash
+# 基础:转写一个录音文件(自动探测时长用于计费预估)
+npx --yes fmode-listen@latest transcribe -- meeting.mp3
+
+# 指定语言 + 说话人分离 + 写出完整 JSON
+npx --yes fmode-listen@latest transcribe -- meeting.mp3 \
+  --language autodialect --diarize --speakers 3 --out result.json
+
+# 自定义网关(默认 https://server.fmode.cn/api/listen)
+npx --yes fmode-listen@latest transcribe -- meeting.mp3 --gateway https://server.fmode.cn/api/listen
+```
+
+`transcribe` 后必须加 `--`,其后参数透传给运行器。stdout 输出纯文本转写结果;`--out` 额外写出网关返回的完整 JSON。
+
+### 在 Node 脚本中调用
+
+技能目录被复制进 `.claude/skills/` 时**不含 node_modules**,运行器是零依赖的 ESM,可直接 import:
+
+```js
+import { transcribeFile, probeDurationMs, resolveApiToken }
+  from './scripts/listen-runner.mjs';
+
+const { text, segments, raw } = await transcribeFile({
+  filePath: 'meeting.mp3',
+  language: 'autodialect',   // 默认 autodialect(中英自动+方言)
+  diarize: true,             // 说话人分离
+  speakers: 3,               // 预期说话人数(可选)
+  // durationMs: 123000,     // 可选;缺省自动 ffprobe 探测
+  // gateway: 'https://server.fmode.cn/api/listen',
+  // token: '...',           // 可选;缺省自动解析 fmode token
+});
+console.log(text);
+```
+
+## 参数说明
+
+| 参数 | 含义 | 默认 |
+|------|------|------|
+| `<audioFile>` | 本地音频文件路径(位置参数) | 必填 |
+| `--language` | 识别语言,如 `autodialect`(中英+方言自动)/ `cn` / `en` | `autodialect` |
+| `--diarize` | 开启说话人分离 | 关 |
+| `--speakers N` | 预期说话人数(配合 `--diarize`) | 自动 |
+| `--duration <ms>` | 音频时长(毫秒),用于计费预估;缺省自动探测 | 自动 |
+| `--gateway <url>` | 网关基址 | `https://server.fmode.cn/api/listen` |
+| `--out <file>` | 写出网关返回的完整 JSON | 不写 |
+
+## 网关接口
+
+```
+POST {gateway}/transcribe?fileName=meeting.mp3&duration=123000&language=autodialect&diarize=true&speakers=3
+Headers: Authorization: Bearer <fmode token>
+         Content-Type: application/octet-stream
+Body:    原始音频字节
+```
+
+成功响应:
+```json
+{ "code": 200, "data": { "text": "...", "segments": [ ... ] } }
+```
+
+余额不足:
+```json
+{ "code": 402, "mess": "余额不足,请充值后重试", "rechargeUrl": "..." }
+```
+
+## 计费口径
+
+- 按**音频真实时长**计费:`ceil(音频分钟) × 单价`,不足 1 分钟按 1 分钟计。
+- 服务端在转写成功、拿到讯飞返回的真实时长后扣费;扣费走与其它 Fmode 模型同一套 newapi 计量,用量在统一后台可查。
+- 客户端传的 `duration` 仅用于发起前的余额预校验,最终以服务端真实时长为准。
+
+## 安装为 Claude Code 技能
+
+```bash
+# 项目级 → ./.claude/skills/fmode-listen
+npx --yes fmode-listen@latest workspace
+
+# 用户级 → ~/.claude/skills/fmode-listen
+npx --yes fmode-listen@latest install
+```
+
+安装后可直接提示 Claude Code,例如:`把 meeting.mp3 转写成文字,开启说话人分离。`
+
+## 注意事项
+
+- 长音频转写是异步过程,网关会在服务端轮询讯飞直到完成再返回,请求耗时随时长增加,调用方注意超时设置。
+- 大文件/视频先用 `fmode-ffmpeg` 压成 16kHz 单声道 wav 再转写,省带宽且更稳。
+- 出现 401:检查 fmode token;出现 402:余额不足,按返回的 `rechargeUrl` 充值。
+- 切勿在客户端写入讯飞密钥;凭据只在服务端。

+ 318 - 0
skills/scripts/listen-runner.mjs

@@ -0,0 +1,318 @@
+/**
+ * fmode-listen 录音转写网关客户端
+ *
+ * 通过 Fmode 网关 POST /api/listen/transcribe 调用讯飞「录音文件转写」。
+ * 客户端不持有讯飞凭据——凭据仅在服务端。客户端只需携带 fmode token,
+ * 服务端鉴权后调用讯飞并按音频时长计费(ceil(分钟) × 单价)。
+ *
+ * 导出:
+ *   - resolveApiToken()    三级优先级获取 fmode token
+ *   - probeDurationMs()    用 ffprobe / fmode-ffmpeg 探测音频时长(可选)
+ *   - transcribeFile()     上传本地音频文件并返回转写结果
+ *
+ * CLI:
+ *   node listen-runner.mjs <audioFile> [--duration <ms>] [--language autodialect]
+ *        [--diarize] [--speakers N] [--gateway <baseUrl>] [--out <file.json>]
+ */
+
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+import { spawnSync } from 'child_process';
+
+// ============================================================
+// 配置
+// ============================================================
+
+// 网关基址:环境变量 > 默认线上地址
+const DEFAULT_GATEWAY = process.env.FMODE_LISTEN_GATEWAY
+  || 'https://server.fmode.cn/api/listen';
+
+// ============================================================
+// Token 解析(与 voc / fmode-vision 共享层一致)
+// ============================================================
+//
+// 关键修复:fmode 的 newapi SK 默认就是 Claude Code 的 env.ANTHROPIC_AUTH_TOKEN,
+// 存在 ~/.claude/settings.json(及 settings.local.json / 项目级 .claude/)。
+// 旧实现只读进程环境变量 ANTHROPIC_AUTH_TOKEN,从不读这个文件——用户按 Claude Code
+// 正常方式配好 SK,技能却「看不见」→ 判缺 token → 掉进旧付费弹窗死循环。
+// 这里直接读该文件,且校验 sk- 开头、排除真 Anthropic sk-ant-、base 指向 fmode。
+//
+// 取值优先级:
+//   1. 显式入参 token / 环境变量 FMODE_API_TOKEN
+//   2. ~/.fmode/config.json → fmodeApiToken / newapiToken(FmodeStudio 保存写这里)
+//   3. <cwd>/.fmode/config.json → fmodeApiToken / newapiToken
+//   4. 进程注入的 ANTHROPIC_AUTH_TOKEN(Claude Code 把 settings.env 注入子进程时)
+//   5. ~/.claude/settings.json 等文件里的 env.ANTHROPIC_AUTH_TOKEN(独立运行未被注入时)
+
+function readJsonMaybe(filePath) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return {};
+    return JSON.parse(fs.readFileSync(filePath, 'utf-8').replace(/^\uFEFF/, ''));
+  } catch {
+    return {};
+  }
+}
+
+// 合并读取 Claude Code 的 settings env(用户级 + 项目级,含 .local 覆盖文件)。
+function readClaudeSettingsEnv() {
+  const files = [
+    path.join(os.homedir(), '.claude', 'settings.json'),
+    path.join(os.homedir(), '.claude', 'settings.local.json'),
+    path.join(process.cwd(), '.claude', 'settings.json'),
+    path.join(process.cwd(), '.claude', 'settings.local.json'),
+  ];
+  const merged = {};
+  for (const filePath of files) {
+    const json = readJsonMaybe(filePath);
+    const env = json && typeof json.env === 'object' && json.env ? json.env : null;
+    if (!env) continue;
+    for (const [key, value] of Object.entries(env)) {
+      if (merged[key] === undefined && typeof value === 'string' && value.trim()) {
+        merged[key] = value;
+      }
+    }
+  }
+  return merged;
+}
+
+// 仅当 ANTHROPIC_AUTH_TOKEN 看起来是 fmode 的 newapi SK 时才采纳:
+// - 必须 sk- 开头,且排除真 Anthropic 官方 key(sk-ant- 开头);
+// - 若设了 ANTHROPIC_BASE_URL,必须指向 fmode(否则这把 token 是发往别处的)。
+function pickFmodeAnthropicToken(env) {
+  const token = env && typeof env.ANTHROPIC_AUTH_TOKEN === 'string' ? env.ANTHROPIC_AUTH_TOKEN.trim() : '';
+  if (!token || !/^sk-/i.test(token) || /^sk-ant-/i.test(token)) return '';
+  const base = String((env && (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_BASE)) || '').toLowerCase();
+  if (base && !base.includes('fmode')) return '';
+  return token;
+}
+
+/**
+ * 获取 fmode API token。
+ *
+ * @param {string} [projectRoot] 项目根目录,默认 process.cwd()
+ * @returns {{ token: string, source: string }}
+ */
+export function resolveApiToken(projectRoot) {
+  if (process.env.FMODE_API_TOKEN) {
+    return { token: process.env.FMODE_API_TOKEN, source: 'env:FMODE_API_TOKEN' };
+  }
+
+  const userConfigPath = path.join(os.homedir(), '.fmode', 'config.json');
+  const userToken = readTokenFromConfig(userConfigPath);
+  if (userToken) {
+    return { token: userToken, source: userConfigPath };
+  }
+
+  const root = projectRoot || process.cwd();
+  const projectConfigPath = path.join(root, '.fmode', 'config.json');
+  const projectToken = readTokenFromConfig(projectConfigPath);
+  if (projectToken) {
+    return { token: projectToken, source: projectConfigPath };
+  }
+
+  // Claude Code 默认入口:进程注入的 ANTHROPIC_AUTH_TOKEN(sk-、base 指向 fmode)
+  const injected = pickFmodeAnthropicToken(process.env);
+  if (injected) {
+    return { token: injected, source: 'env:ANTHROPIC_AUTH_TOKEN' };
+  }
+
+  // 兜底:直接读 ~/.claude/settings.json 等文件里的 env.ANTHROPIC_AUTH_TOKEN
+  const claudeEnv = readClaudeSettingsEnv();
+  const fromSettings = pickFmodeAnthropicToken(claudeEnv);
+  if (fromSettings) {
+    return { token: fromSettings, source: '~/.claude/settings.json:env.ANTHROPIC_AUTH_TOKEN' };
+  }
+
+  throw new Error(
+    '未找到 Fmode API token。请通过以下任一方式提供:\n' +
+    '  1. 环境变量 FMODE_API_TOKEN\n' +
+    '  2. ~/.fmode/config.json 中 fmodeApiToken 字段(FmodeStudio 保存配置后写入)\n' +
+    '  3. 项目 .fmode/config.json 中 fmodeApiToken 字段\n' +
+    '  4. ~/.claude/settings.json 的 env.ANTHROPIC_AUTH_TOKEN(Claude Code 的 sk- token,会自动读取)\n' +
+    '  注意:这是缺 token,不是「用不了」——请勿点任何付费/充值弹窗。'
+  );
+}
+
+function readTokenFromConfig(configPath) {
+  try {
+    if (!fs.existsSync(configPath)) return null;
+    const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8').replace(/^\uFEFF/, ''));
+    return cfg.fmodeApiToken || cfg.newapiToken || null;
+  } catch {
+    return null;
+  }
+}
+
+// ============================================================
+// 音频时长探测(可选,用于计费预估)
+// ============================================================
+
+/**
+ * 探测音频时长(毫秒)。优先用系统 ffprobe,其次 fmode-ffmpeg 的 ffprobe,
+ * 失败则返回 0(服务端会以真实时长计费)。
+ *
+ * @param {string} audioPath
+ * @returns {number} 毫秒,探测失败返回 0
+ */
+export function probeDurationMs(audioPath) {
+  const candidates = [];
+  if (process.env.FFPROBE_PATH) candidates.push(process.env.FFPROBE_PATH);
+  candidates.push('ffprobe');
+
+  const args = [
+    '-v', 'error',
+    '-show_entries', 'format=duration',
+    '-of', 'default=noprint_wrappers=1:nokey=1',
+    audioPath,
+  ];
+
+  for (const bin of candidates) {
+    try {
+      const r = spawnSync(bin, args, { encoding: 'utf-8' });
+      if (r.status === 0 && r.stdout) {
+        const sec = parseFloat(String(r.stdout).trim());
+        if (Number.isFinite(sec) && sec > 0) return Math.round(sec * 1000);
+      }
+    } catch { /* try next */ }
+  }
+
+  // 兜底:尝试 npx fmode-ffmpeg 的 ffprobe
+  try {
+    const r = spawnSync('npx', ['--yes', 'fmode-ffmpeg@latest', 'probe', '--', ...args], { encoding: 'utf-8' });
+    if (r.status === 0 && r.stdout) {
+      const sec = parseFloat(String(r.stdout).trim());
+      if (Number.isFinite(sec) && sec > 0) return Math.round(sec * 1000);
+    }
+  } catch { /* ignore */ }
+
+  return 0;
+}
+
+// ============================================================
+// 转写
+// ============================================================
+
+/**
+ * 上传本地音频文件到网关并转写。
+ *
+ * @param {Object} opts
+ * @param {string} opts.filePath      本地音频文件路径
+ * @param {number} [opts.durationMs]  音频时长(毫秒);缺省自动探测
+ * @param {string} [opts.language]    识别语言,默认 autodialect
+ * @param {boolean} [opts.diarize]    是否说话人分离
+ * @param {number} [opts.speakers]    预期说话人数
+ * @param {string} [opts.gateway]     网关基址,默认 DEFAULT_GATEWAY
+ * @param {string} [opts.token]       手动传入 fmode token,否则自动解析
+ * @returns {Promise<{ text: string, segments: any[], raw: object }>}
+ */
+export async function transcribeFile(opts) {
+  const {
+    filePath, language = 'autodialect', diarize = false,
+    speakers, gateway = DEFAULT_GATEWAY,
+  } = opts;
+
+  if (!filePath || !fs.existsSync(filePath)) {
+    throw new Error(`音频文件不存在: ${filePath}`);
+  }
+
+  const token = opts.token || resolveApiToken().token;
+  const audio = fs.readFileSync(filePath);
+  const fileName = path.basename(filePath);
+
+  let durationMs = Number(opts.durationMs || 0);
+  if (!durationMs) durationMs = probeDurationMs(filePath);
+
+  const params = new URLSearchParams();
+  params.set('fileName', fileName);
+  if (durationMs) params.set('duration', String(durationMs));
+  if (language) params.set('language', language);
+  if (diarize) params.set('diarize', 'true');
+  if (speakers) params.set('speakers', String(speakers));
+
+  const url = `${gateway.replace(/\/$/, '')}/transcribe?${params.toString()}`;
+
+  const res = await fetch(url, {
+    method: 'POST',
+    headers: {
+      'Authorization': `Bearer ${token}`,
+      'Content-Type': 'application/octet-stream',
+    },
+    body: audio,
+  });
+
+  let body;
+  const rawText = await res.text();
+  try { body = JSON.parse(rawText); } catch { body = { raw: rawText }; }
+
+  if (res.status === 402) {
+    const url = body && body.rechargeUrl ? `\n充值链接:${body.rechargeUrl}` : '';
+    throw new Error(`余额不足,请充值后重试。${url}`);
+  }
+  if (!res.ok || (body && body.code && body.code >= 400)) {
+    const mess = (body && (body.mess || body.error)) || rawText || `HTTP ${res.status}`;
+    throw new Error(`转写失败 (HTTP ${res.status}): ${mess}`);
+  }
+
+  const data = (body && body.data) || {};
+  return {
+    text: data.text || '',
+    segments: data.segments || [],
+    raw: body,
+  };
+}
+
+// ============================================================
+// CLI
+// ============================================================
+
+function parseCliArgs(argv) {
+  const args = { language: 'autodialect', diarize: false };
+  const positional = [];
+  for (let i = 0; i < argv.length; i++) {
+    const t = argv[i];
+    if (t === '--duration' && argv[i + 1]) args.durationMs = Number(argv[++i]);
+    else if (t.startsWith('--duration=')) args.durationMs = Number(t.slice(11));
+    else if (t === '--language' && argv[i + 1]) args.language = argv[++i];
+    else if (t.startsWith('--language=')) args.language = t.slice(11);
+    else if (t === '--diarize') args.diarize = true;
+    else if (t === '--speakers' && argv[i + 1]) args.speakers = Number(argv[++i]);
+    else if (t.startsWith('--speakers=')) args.speakers = Number(t.slice(11));
+    else if (t === '--gateway' && argv[i + 1]) args.gateway = argv[++i];
+    else if (t.startsWith('--gateway=')) args.gateway = t.slice(10);
+    else if (t === '--out' && argv[i + 1]) args.out = argv[++i];
+    else if (t.startsWith('--out=')) args.out = t.slice(6);
+    else if (!t.startsWith('--')) positional.push(t);
+  }
+  args.filePath = positional[0];
+  return args;
+}
+
+async function main() {
+  const args = parseCliArgs(process.argv.slice(2));
+  if (!args.filePath) {
+    console.error('用法: node listen-runner.mjs <audioFile> [--duration <ms>] [--language autodialect] [--diarize] [--speakers N] [--gateway <baseUrl>] [--out <file.json>]');
+    process.exit(1);
+  }
+  const result = await transcribeFile(args);
+  if (args.out) {
+    fs.writeFileSync(args.out, JSON.stringify(result.raw, null, 2));
+    console.error(`已写入: ${args.out}`);
+  }
+  console.log(result.text);
+}
+
+// 仅在直接运行时执行 CLI
+const isMain = (() => {
+  try {
+    return import.meta.url === `file://${process.argv[1]}`
+      || import.meta.url.endsWith(path.basename(process.argv[1] || ''));
+  } catch { return false; }
+})();
+
+if (isMain) {
+  main().catch((err) => {
+    console.error(`fmode-listen: ${err.message}`);
+    process.exit(1);
+  });
+}