Преглед изворни кода

feat(fmode): add fmode-vision/ffmpeg/listen standalone skill packages

Standalone npm skills. fmode-ffmpeg 0.1.1 adds download verify+repair (run -version probe, re-download on corrupt binary, fall back to FFMPEG_PATH/system ffmpeg). fmode-vision/listen 0.1.1 read sk- from settings.json (fmode base guarded).
gangvy пре 2 месеци
родитељ
комит
96775af9b0
27 измењених фајлова са 2864 додато и 0 уклоњено
  1. 8 0
      claude-code/fmode-ffmpeg/.claude-plugin/plugin.json
  2. 70 0
      claude-code/fmode-ffmpeg/README.md
  3. 299 0
      claude-code/fmode-ffmpeg/bin/fmode-ffmpeg.js
  4. 36 0
      claude-code/fmode-ffmpeg/package.json
  5. 53 0
      claude-code/fmode-ffmpeg/scripts/smoke.js
  6. 17 0
      claude-code/fmode-ffmpeg/skill-package-manifest.json
  7. 12 0
      claude-code/fmode-ffmpeg/skills/fmode-ffmpeg/README.md
  8. 115 0
      claude-code/fmode-ffmpeg/skills/fmode-ffmpeg/SKILL.md
  9. 84 0
      claude-code/fmode-ffmpeg/skills/fmode-ffmpeg/scripts/ffmpeg-runner.mjs
  10. 8 0
      claude-code/fmode-listen/.claude-plugin/plugin.json
  11. 83 0
      claude-code/fmode-listen/README.md
  12. 192 0
      claude-code/fmode-listen/bin/fmode-listen.js
  13. 33 0
      claude-code/fmode-listen/package.json
  14. 40 0
      claude-code/fmode-listen/scripts/smoke.js
  15. 17 0
      claude-code/fmode-listen/skill-package-manifest.json
  16. 126 0
      claude-code/fmode-listen/skills/fmode-listen/SKILL.md
  17. 318 0
      claude-code/fmode-listen/skills/fmode-listen/scripts/listen-runner.mjs
  18. 8 0
      claude-code/fmode-vision/.claude-plugin/plugin.json
  19. 46 0
      claude-code/fmode-vision/README.md
  20. 155 0
      claude-code/fmode-vision/bin/fmode-vision.js
  21. 30 0
      claude-code/fmode-vision/package.json
  22. 28 0
      claude-code/fmode-vision/scripts/smoke.js
  23. 17 0
      claude-code/fmode-vision/skill-package-manifest.json
  24. 185 0
      claude-code/fmode-vision/skills/fmode-vision/README.md
  25. 163 0
      claude-code/fmode-vision/skills/fmode-vision/SKILL.md
  26. 408 0
      claude-code/fmode-vision/skills/fmode-vision/scripts/prompts/room-measurement.mjs
  27. 313 0
      claude-code/fmode-vision/skills/fmode-vision/scripts/vision-client.mjs

+ 8 - 0
claude-code/fmode-ffmpeg/.claude-plugin/plugin.json

@@ -0,0 +1,8 @@
+{
+  "name": "fmode-ffmpeg",
+  "description": "Run ffmpeg/ffprobe for audio & video processing via a bundled static binary (ffmpeg-static). No system ffmpeg install required. Transcode, extract audio, grab frames, trim, compress, and probe media.",
+  "version": "0.1.1",
+  "author": {
+    "name": "fmode"
+  }
+}

+ 70 - 0
claude-code/fmode-ffmpeg/README.md

@@ -0,0 +1,70 @@
+# fmode-ffmpeg
+
+Claude Code skill + CLI that runs **ffmpeg / ffprobe** through a bundled static
+binary (`ffmpeg-static` / `ffprobe-static`). No system ffmpeg install required.
+
+## Run ffmpeg / ffprobe directly
+
+```bash
+# print the bundled binary paths
+npx fmode-ffmpeg@latest which
+npx fmode-ffmpeg@latest which-ffprobe
+
+# run ffmpeg (everything after `--` is passed through)
+npx fmode-ffmpeg@latest exec -- -y -i input.mp4 -ar 16000 -ac 1 out.wav
+
+# run ffprobe
+npx fmode-ffmpeg@latest probe -- -v quiet -print_format json -show_format input.mp4
+
+# ffmpeg -version
+npx fmode-ffmpeg@latest version
+```
+
+## Install the Claude Code skill
+
+```bash
+# project-level → ./.claude/skills/fmode-ffmpeg
+npx fmode-ffmpeg@latest workspace
+
+# user-level → ~/.claude/skills/fmode-ffmpeg
+npx fmode-ffmpeg@latest install
+```
+
+Then prompt Claude Code, e.g. `把 input.mp4 转成 16kHz 单声道 wav 音频。`
+
+## Binary resolution order
+
+1. `FFMPEG_PATH` / `FFPROBE_PATH` environment variable (if the file exists)
+2. bundled static binary (`ffmpeg-static` / `ffprobe-static`)
+3. `ffmpeg` / `ffprobe` on `PATH`
+
+The bundled binary is **verified before use** (it is actually launched with
+`-version`). If `ffmpeg-static`'s download was incomplete/corrupt, `fmode-ffmpeg`
+deletes it, clears the download cache, re-downloads once, and re-verifies; if it
+still won't run it falls back to system `ffmpeg`/`FFMPEG_PATH` with a clear hint.
+
+- `FMODE_FFMPEG_NO_REPAIR=1` — skip the auto re-download (fail fast to fallback).
+- `FMODE_FFMPEG_REPAIR_TIMEOUT_MS` — bound the re-download (default `180000`).
+
+### Windows / antivirus note
+
+If you see `not a valid Win32 application` / `EFTYPE`, or the binary "works once
+then stops", antivirus (e.g. Windows Defender) may be quarantining or altering
+the downloaded `ffmpeg.exe`. The most reliable fix is to install ffmpeg yourself
+(or whitelist it) and point **`FFMPEG_PATH`** (and `FFPROBE_PATH`) at it — that
+path takes priority and skips the bundled download entirely.
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `which` / `which-ffprobe` | print resolved binary path |
+| `exec -- <args>` / `run -- <args>` | run ffmpeg with passthrough args |
+| `probe -- <args>` | run ffprobe with passthrough args |
+| `version` | `ffmpeg -version` |
+| `workspace` / `install` | install the Claude Code skill |
+| `check` / `smoke` / `path` | verify install / run smoke test / print target |
+
+## License
+
+MIT

+ 299 - 0
claude-code/fmode-ffmpeg/bin/fmode-ffmpeg.js

@@ -0,0 +1,299 @@
+#!/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-ffmpeg';
+const SOURCE_ROOT = path.resolve(__dirname, '..');
+const SKILL_SOURCE = path.join(SOURCE_ROOT, 'skills', SKILL_NAME);
+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 INSTALL_COMMANDS = new Set(['install', 'workspace', 'install-workspace', 'check', 'smoke', 'path', 'help']);
+const BINARY_COMMANDS = new Set(['which', 'which-ffprobe', 'exec', 'run', 'probe', 'version']);
+
+function expandHome(value) {
+  return String(value || '').replace(/^~(?=$|[\\/])/, os.homedir());
+}
+
+// ---------------------------------------------------------------------------
+// ffmpeg / ffprobe binary resolution (with integrity verification + repair)
+// ---------------------------------------------------------------------------
+const VERSION_BANNER = /(?:ffmpeg|ffprobe) version/i;
+
+function truthyEnv(value) {
+  return value != null && value !== '' && value !== '0'
+    && String(value).toLowerCase() !== 'false';
+}
+
+// An existence check is not enough: an incomplete/corrupt ffmpeg-static download
+// can leave a wrong-sized or non-executable file on disk that fails at launch
+// (e.g. Windows "not a valid Win32 application" / EFTYPE). Verify the binary
+// actually runs before trusting it.
+function isRunnable(binPath) {
+  if (!binPath || !fs.existsSync(binPath)) return false;
+  const result = spawnSync(binPath, ['-version'], {
+    encoding: 'utf8', timeout: 20000, shell: false,
+  });
+  if (result.error || result.status !== 0) return false;
+  return VERSION_BANNER.test(`${result.stdout || ''}${result.stderr || ''}`);
+}
+
+function staticModuleInfo(moduleName) {
+  try {
+    const mod = require(moduleName);
+    const binPath = typeof mod === 'string' ? mod : (mod && (mod.path || mod));
+    const pkgDir = path.dirname(require.resolve(`${moduleName}/package.json`));
+    return { binPath, pkgDir };
+  } catch (_) {
+    return null;
+  }
+}
+
+// ffmpeg-static downloads its binary on install via `install.js`, caching the
+// download with @derhuerst/http-basic. install.js short-circuits when the binary
+// file already exists, and a corrupt cache entry would just be re-copied, so a
+// real repair must remove BOTH the binary and that download cache first.
+function repairFfmpegStatic(info) {
+  if (!info || !info.pkgDir) return false;
+  const installJs = path.join(info.pkgDir, 'install.js');
+  if (!fs.existsSync(installJs)) return false;
+  // install.js short-circuits if the binary file still exists, so it MUST be
+  // gone before we re-run it. fs.rmSync({force:true}) has been observed to
+  // silently no-op on a file on some Windows setups, so use unlinkSync and, if
+  // that fails (e.g. AV/lock), move the corrupt file aside as a fallback.
+  if (info.binPath && fs.existsSync(info.binPath)) {
+    try {
+      fs.unlinkSync(info.binPath);
+    } catch (_) {
+      try { fs.renameSync(info.binPath, `${info.binPath}.corrupt-${Date.now()}`); }
+      catch (_) { return false; }
+    }
+    if (fs.existsSync(info.binPath)) return false; // could not clear it; bail to fallback
+  }
+  try {
+    const envPaths = require(require.resolve('env-paths', { paths: [info.pkgDir] }));
+    const cacheDir = envPaths('ffmpeg-static').cache;
+    if (cacheDir) fs.rmSync(cacheDir, { recursive: true, force: true });
+  } catch (_) { /* best-effort cache clear */ }
+  // The download can hang on a flaky network, so bound it; otherwise resolution
+  // would block indefinitely. Redirect the installer's stdout to our stderr so
+  // `which` stdout stays clean.
+  const timeoutMs = Number(process.env.FMODE_FFMPEG_REPAIR_TIMEOUT_MS) || 180000;
+  const result = spawnSync(process.execPath, [installJs], {
+    cwd: info.pkgDir, stdio: ['ignore', 2, 2], shell: false,
+    env: process.env, timeout: timeoutMs,
+  });
+  return !result.error && result.status === 0;
+}
+
+function resolveFfmpeg() {
+  const envPath = process.env.FFMPEG_PATH;
+  if (envPath && fs.existsSync(envPath)) return envPath;
+
+  const info = staticModuleInfo('ffmpeg-static');
+  if (info && info.binPath) {
+    if (isRunnable(info.binPath)) return info.binPath;
+    if (!truthyEnv(process.env.FMODE_FFMPEG_NO_REPAIR)) {
+      console.error('fmode-ffmpeg: bundled ffmpeg failed to launch (corrupt/incomplete download); re-downloading once...');
+      if (repairFfmpegStatic(info)) {
+        const fresh = staticModuleInfo('ffmpeg-static');
+        if (fresh && isRunnable(fresh.binPath)) return fresh.binPath;
+      }
+      console.error('fmode-ffmpeg: bundled ffmpeg still unavailable after re-download. Falling back to system ffmpeg on PATH; set FFMPEG_PATH to a working ffmpeg to override.');
+    }
+  }
+  return 'ffmpeg';
+}
+
+function resolveFfprobe() {
+  const envPath = process.env.FFPROBE_PATH;
+  if (envPath && fs.existsSync(envPath)) return envPath;
+
+  const info = staticModuleInfo('ffprobe-static');
+  if (info && info.binPath) {
+    if (isRunnable(info.binPath)) return info.binPath;
+    // ffprobe-static ships the binary inside the package (no download step to
+    // retry), so a corrupt copy can only be fixed by reinstalling the package.
+    console.error('fmode-ffmpeg: bundled ffprobe failed to launch (corrupt install). Falling back to system ffprobe on PATH; set FFPROBE_PATH to override, or reinstall fmode-ffmpeg.');
+  }
+  return 'ffprobe';
+}
+
+function runBinary(binary, passthrough) {
+  const result = spawnSync(binary, passthrough, { stdio: 'inherit', shell: false });
+  if (result.error) {
+    console.error(`fmode-ffmpeg: failed to launch ${binary}: ${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)
+// ---------------------------------------------------------------------------
+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-ffmpeg — bundled ffmpeg/ffprobe runner + Claude Code skill installer',
+    '',
+    'Run ffmpeg / ffprobe (no system install needed):',
+    '  npx fmode-ffmpeg@latest which                      # print bundled ffmpeg binary path',
+    '  npx fmode-ffmpeg@latest which-ffprobe              # print bundled ffprobe binary path',
+    '  npx fmode-ffmpeg@latest exec -- -i in.mp4 out.mp3  # run ffmpeg with passthrough args',
+    '  npx fmode-ffmpeg@latest probe -- -show_format in.mp4  # run ffprobe with passthrough args',
+    '  npx fmode-ffmpeg@latest version                    # print ffmpeg -version',
+    '',
+    'Install the Claude Code skill:',
+    '  npx fmode-ffmpeg@latest workspace [--smoke]   # install into ./.claude/skills/fmode-ffmpeg',
+    '  npx fmode-ffmpeg@latest install [--smoke]     # install into ~/.claude/skills/fmode-ffmpeg',
+    '  npx fmode-ffmpeg@latest install --target <dir> [--force]',
+    '  npx fmode-ffmpeg@latest check',
+    '  npx fmode-ffmpeg@latest smoke',
+    '  npx fmode-ffmpeg@latest path',
+    '',
+    'Options:',
+    '  --workspace      Install into ./.claude/skills/fmode-ffmpeg',
+    '  --global         Install into ~/.claude/skills/fmode-ffmpeg (default)',
+    '  --target <dir>   Install into a custom directory',
+    '  --force          Allow overwriting a custom target',
+    '  --smoke          Run smoke checks after install',
+    '  --help, -h       Show help'
+  ].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/ffmpeg-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('ffmpeg is bundled (ffmpeg-static); no system install needed.');
+  console.log('Try this prompt in Claude Code:');
+  console.log('  把 input.mp4 转成 16kHz 单声道 wav 音频。');
+}
+
+function main() {
+  const argv = process.argv.slice(2);
+  const command = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
+
+  // Binary passthrough commands (handled before the installer arg parser).
+  if (BINARY_COMMANDS.has(command)) {
+    if (command === 'which') { console.log(resolveFfmpeg()); return; }
+    if (command === 'which-ffprobe') { console.log(resolveFfprobe()); return; }
+    if (command === 'version') { runBinary(resolveFfmpeg(), ['-version']); return; }
+    if (command === 'exec' || command === 'run') { runBinary(resolveFfmpeg(), passthroughArgs(argv)); return; }
+    if (command === 'probe') { runBinary(resolveFfprobe(), 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-ffmpeg failed: ${error.message}`); process.exit(1); }

+ 36 - 0
claude-code/fmode-ffmpeg/package.json

@@ -0,0 +1,36 @@
+{
+  "name": "fmode-ffmpeg",
+  "version": "0.1.1",
+  "description": "Claude Code skill: run ffmpeg/ffprobe for audio & video processing via a bundled static binary (ffmpeg-static). No system ffmpeg install required. Provides `npx fmode-ffmpeg exec`/`probe`/`which` passthrough plus a Claude Code skill.",
+  "type": "commonjs",
+  "bin": {
+    "fmode-ffmpeg": "bin/fmode-ffmpeg.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",
+    "ffmpeg",
+    "ffprobe",
+    "ffmpeg-static",
+    "audio",
+    "video",
+    "transcode"
+  ],
+  "license": "MIT",
+  "dependencies": {
+    "ffmpeg-static": "5.3.0",
+    "ffprobe-static": "3.1.0"
+  }
+}

+ 53 - 0
claude-code/fmode-ffmpeg/scripts/smoke.js

@@ -0,0 +1,53 @@
+#!/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-ffmpeg');
+const BIN = path.join(ROOT, 'bin', 'fmode-ffmpeg.js');
+
+function fail(msg) { console.error('SMOKE FAIL: ' + msg); process.exit(1); }
+
+const required = [
+  'SKILL.md',
+  'scripts/ffmpeg-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', 'ffmpeg-runner.mjs')).href);
+  for (const fn of ['ffmpegPath', 'ffprobePath', 'runFfmpeg', 'probeMedia', 'probeDurationMs']) {
+    if (typeof mod[fn] !== 'function') fail('export ' + fn + ' is not a function');
+  }
+
+  // 2. bundled ffmpeg binary resolves and runs
+  const which = spawnSync(process.execPath, [BIN, 'which'], { encoding: 'utf8' });
+  if (which.status !== 0) fail('`fmode-ffmpeg which` exited ' + which.status);
+  const ffmpegBin = (which.stdout || '').trim();
+  if (!ffmpegBin || !fs.existsSync(ffmpegBin)) fail('ffmpeg binary not resolved: ' + ffmpegBin);
+
+  const version = spawnSync(ffmpegBin, ['-version'], { encoding: 'utf8' });
+  if (version.status !== 0 || !/ffmpeg version/i.test(version.stdout || '')) {
+    fail('ffmpeg -version did not run');
+  }
+
+  // 3. bundled ffprobe binary resolves
+  const whichProbe = spawnSync(process.execPath, [BIN, 'which-ffprobe'], { encoding: 'utf8' });
+  if (whichProbe.status !== 0) fail('`fmode-ffmpeg which-ffprobe` exited ' + whichProbe.status);
+  const ffprobeBin = (whichProbe.stdout || '').trim();
+  if (!ffprobeBin || !fs.existsSync(ffprobeBin)) fail('ffprobe binary not resolved: ' + ffprobeBin);
+
+  const probeVersion = spawnSync(ffprobeBin, ['-version'], { encoding: 'utf8' });
+  if (probeVersion.status !== 0 || !/ffprobe version/i.test(probeVersion.stdout || '')) {
+    fail('ffprobe -version did not run');
+  }
+
+  console.log('SMOKE OK: fmode-ffmpeg structure + bundled ffmpeg/ffprobe verified');
+  console.log('  ffmpeg : ' + ffmpegBin);
+  console.log('  ffprobe: ' + ffprobeBin);
+})().catch(e => fail(e.message));

+ 17 - 0
claude-code/fmode-ffmpeg/skill-package-manifest.json

@@ -0,0 +1,17 @@
+{
+  "name": "fmode-ffmpeg",
+  "version": "0.1.1",
+  "description": "Claude Code 独立技能包:内置跨平台静态 ffmpeg/ffprobe 二进制(ffmpeg-static),无需系统安装即可转码、提取音轨、抽帧、裁剪、压缩,并用 ffprobe 探测媒体信息。",
+  "plugin": "fmode-ffmpeg",
+  "skills": [
+    "fmode-ffmpeg"
+  ],
+  "entrySkill": "fmode-ffmpeg",
+  "npmPackage": "fmode-ffmpeg",
+  "smokeCommand": "npm run smoke",
+  "installCommand": "npx fmode-ffmpeg@latest install",
+  "workspaceInstallCommand": "npx fmode-ffmpeg@latest workspace",
+  "workspaceSkillPath": ".claude/skills/fmode-ffmpeg/SKILL.md",
+  "globalSkillPath": "%USERPROFILE%/.claude/skills/fmode-ffmpeg/SKILL.md",
+  "installHint": "工作区安装:npx fmode-ffmpeg@latest workspace,会写入 ./.claude/skills/fmode-ffmpeg/。用户级安装:npx fmode-ffmpeg@latest install,会写入 ~/.claude/skills/fmode-ffmpeg/。二进制内置(ffmpeg-static/ffprobe-static),无需系统安装 ffmpeg;可用 FFMPEG_PATH/FFPROBE_PATH 覆盖。"
+}

+ 12 - 0
claude-code/fmode-ffmpeg/skills/fmode-ffmpeg/README.md

@@ -0,0 +1,12 @@
+# fmode-ffmpeg (skill)
+
+Claude Code skill that runs **ffmpeg / ffprobe** via a bundled static binary
+(`ffmpeg-static` / `ffprobe-static`) — no system ffmpeg install required.
+
+See `SKILL.md` for the full skill definition and recipes. The helper module
+`scripts/ffmpeg-runner.mjs` exposes `ffmpegPath()`, `ffprobePath()`,
+`runFfmpeg()`, `probeMedia()`, and `probeDurationMs()`.
+
+Resolution order for the binaries: `FFMPEG_PATH` / `FFPROBE_PATH` env var →
+`npx --yes fmode-ffmpeg which` (bundled static binary) → `ffmpeg` / `ffprobe`
+on `PATH`.

+ 115 - 0
claude-code/fmode-ffmpeg/skills/fmode-ffmpeg/SKILL.md

@@ -0,0 +1,115 @@
+---
+name: fmode-ffmpeg
+description: "使用内置的静态 ffmpeg/ffprobe 二进制处理音视频,无需系统安装 ffmpeg。适用场景:(1) 音视频格式转换/转码, (2) 提取音轨用于转写/识别, (3) 抽取视频帧, (4) 裁剪/拼接/压缩, (5) 用 ffprobe 探测时长、码率、流信息"
+description_en: "Process audio & video with a bundled static ffmpeg/ffprobe binary (no system install). Use for: (1) format conversion/transcoding, (2) extracting audio tracks for transcription, (3) extracting video frames, (4) trimming/concatenating/compressing, (5) probing duration/bitrate/stream info with ffprobe"
+---
+
+# Fmode FFmpeg — 音视频处理技能
+
+## Overview
+
+本技能内置跨平台静态 `ffmpeg` 与 `ffprobe` 二进制(来自 npm `ffmpeg-static` /
+`ffprobe-static`),**无需用户预先安装 ffmpeg**。用户可能要求你转码、提取音轨、
+抽帧、裁剪、压缩,或读取媒体文件的时长/分辨率/码率等信息。
+
+> 与 `fmode-listen`(音频转写计费网关)配合:先用本技能把视频/任意音频统一转成
+> 16kHz 单声道 wav,再交给转写网关,能显著降低体积并提高识别稳定性。
+
+## 二进制获取(关键)
+
+技能目录被复制进 `.claude/skills/` 时**不包含 node_modules**,所以不要直接
+`import 'ffmpeg-static'`。统一通过 `npx fmode-ffmpeg` 解析/调用二进制:
+
+| 需求 | 命令 |
+|------|------|
+| 拿到 ffmpeg 路径 | `npx --yes fmode-ffmpeg which` |
+| 拿到 ffprobe 路径 | `npx --yes fmode-ffmpeg which-ffprobe` |
+| 直接跑 ffmpeg | `npx --yes fmode-ffmpeg exec -- <ffmpeg 参数>` |
+| 直接跑 ffprobe | `npx --yes fmode-ffmpeg probe -- <ffprobe 参数>` |
+| 查看版本 | `npx --yes fmode-ffmpeg version` |
+
+> 注意 `exec` / `probe` 后必须加 `--`,其后的参数会原样透传给二进制。
+> 如机器上已装 ffmpeg,可设环境变量 `FFMPEG_PATH` / `FFPROBE_PATH` 直接复用。
+
+也可在 Node 脚本里用本技能的 `scripts/ffmpeg-runner.mjs`:
+
+```js
+import { runFfmpeg, probeMedia, probeDurationMs } from './scripts/ffmpeg-runner.mjs';
+
+// 转码:把 mp4 转成 16kHz 单声道 wav
+await runFfmpeg(['-y', '-i', 'input.mp4', '-ar', '16000', '-ac', '1', 'out.wav'], { inherit: true });
+
+// 探测:拿到完整 ffprobe JSON
+const info = await probeMedia('input.mp4');
+
+// 便捷:拿到时长(毫秒)
+const ms = await probeDurationMs('input.mp4');
+```
+
+## 常用配方(直接用 CLI)
+
+### 提取音轨为 16kHz 单声道 wav(转写前处理)
+```bash
+npx --yes fmode-ffmpeg exec -- -y -i input.mp4 -vn -ar 16000 -ac 1 -c:a pcm_s16le out.wav
+```
+
+### 转 mp3(128k)
+```bash
+npx --yes fmode-ffmpeg exec -- -y -i input.wav -b:a 128k out.mp3
+```
+
+### 抽取关键帧(每秒 1 张)
+```bash
+npx --yes fmode-ffmpeg exec -- -y -i input.mp4 -vf fps=1 frame_%04d.jpg
+```
+
+### 抽取指定时间点单帧
+```bash
+npx --yes fmode-ffmpeg exec -- -y -ss 00:00:05 -i input.mp4 -frames:v 1 frame.jpg
+```
+
+### 裁剪片段(从 10s 起 30s)
+```bash
+npx --yes fmode-ffmpeg exec -- -y -ss 10 -i input.mp4 -t 30 -c copy clip.mp4
+```
+
+### 压缩视频(H.264 CRF 28)
+```bash
+npx --yes fmode-ffmpeg exec -- -y -i input.mp4 -c:v libx264 -crf 28 -preset veryfast -c:a aac smaller.mp4
+```
+
+### 探测时长 / 流信息
+```bash
+npx --yes fmode-ffmpeg probe -- -v quiet -print_format json -show_format -show_streams input.mp4
+```
+
+## 工作流建议
+
+```
+拿到媒体文件
+├── 先 probe 拿到时长/分辨率/编码 → 决定处理参数
+├── 需要转写音频? → 提取 16kHz 单声道 wav → 交给 fmode-listen
+├── 需要画面分析? → 抽帧 jpg → 交给 fmode-vision
+└── 仅转码/裁剪/压缩 → 单条 exec 完成
+```
+
+## 注意事项
+
+- 始终带 `-y` 避免覆盖确认卡住非交互流程。
+- 大文件转码耗时长,必要时用 `-ss/-t` 先取片段验证参数。
+- 路径含空格时在 shell 里加引号;透传参数按 ffmpeg 原生语法书写。
+- 输出体积/质量权衡:音频转写场景用 `-ar 16000 -ac 1` 足够且最省。
+
+## 二进制损坏 / 杀软干扰排查(Windows 常见)
+
+`fmode-ffmpeg` 在使用前会**真跑 `-version` 校验**内置二进制;若 `ffmpeg-static`
+下载不完整/损坏,会自动删除、清缓存、重下一次再校验,仍失败则回退系统 ffmpeg。
+
+如果遇到 `不是有效的 Win32 应用程序` / `EFTYPE`,或二进制「刚下好能跑、过会儿又
+跑不了」,多半是 **杀毒软件(如 Windows Defender 实时保护)** 在隔离/篡改下载下来
+的 `ffmpeg.exe`。最稳妥的解决办法:自行安装(或加白名单)一个可信 ffmpeg,并设
+环境变量 **`FFMPEG_PATH`**(及 `FFPROBE_PATH`)指向它——该路径优先级最高、会直接
+跳过内置下载那一套。
+
+- `FMODE_FFMPEG_NO_REPAIR=1`:关闭自动重下,直接走兜底。
+- `FMODE_FFMPEG_REPAIR_TIMEOUT_MS`:限制重下耗时(默认 180000ms,防卡死)。

+ 84 - 0
claude-code/fmode-ffmpeg/skills/fmode-ffmpeg/scripts/ffmpeg-runner.mjs

@@ -0,0 +1,84 @@
+// fmode-ffmpeg runner — resolve and invoke the bundled ffmpeg/ffprobe binaries.
+//
+// The skill directory is copied into .claude/skills/ WITHOUT node_modules, so we
+// never `import 'ffmpeg-static'` directly. Instead we shell out to the published
+// package via `npx --yes fmode-ffmpeg ...`, which resolves the platform binary
+// from the package's own dependencies. Resolved paths are cached per process.
+//
+// You can override resolution with the FFMPEG_PATH / FFPROBE_PATH env vars, which
+// is also the fast path when ffmpeg is already on the machine.
+
+import { spawn, spawnSync } from 'node:child_process';
+import { existsSync } from 'node:fs';
+
+const NPX_PKG = 'fmode-ffmpeg@latest';
+let _ffmpeg = null;
+let _ffprobe = null;
+
+function npxResolve(subcommand) {
+  const res = spawnSync('npx', ['--yes', NPX_PKG, subcommand], { encoding: 'utf8' });
+  if (res.status === 0) {
+    const out = (res.stdout || '').trim().split('\n').pop().trim();
+    if (out && existsSync(out)) return out;
+  }
+  return null;
+}
+
+/** Absolute path to a usable ffmpeg binary. Falls back to "ffmpeg" on PATH. */
+export function ffmpegPath() {
+  if (_ffmpeg) return _ffmpeg;
+  if (process.env.FFMPEG_PATH && existsSync(process.env.FFMPEG_PATH)) {
+    return (_ffmpeg = process.env.FFMPEG_PATH);
+  }
+  return (_ffmpeg = npxResolve('which') || 'ffmpeg');
+}
+
+/** Absolute path to a usable ffprobe binary. Falls back to "ffprobe" on PATH. */
+export function ffprobePath() {
+  if (_ffprobe) return _ffprobe;
+  if (process.env.FFPROBE_PATH && existsSync(process.env.FFPROBE_PATH)) {
+    return (_ffprobe = process.env.FFPROBE_PATH);
+  }
+  return (_ffprobe = npxResolve('which-ffprobe') || 'ffprobe');
+}
+
+/**
+ * Run ffmpeg with the given argument array. Resolves with
+ * { code, stdout, stderr }. Does not throw on non-zero exit; inspect `code`.
+ */
+export function runFfmpeg(args, { inherit = false } = {}) {
+  return new Promise((resolve, reject) => {
+    const child = spawn(ffmpegPath(), args, inherit ? { stdio: 'inherit' } : {});
+    let stdout = '';
+    let stderr = '';
+    if (!inherit) {
+      child.stdout.on('data', d => { stdout += d; });
+      child.stderr.on('data', d => { stderr += d; });
+    }
+    child.on('error', reject);
+    child.on('close', code => resolve({ code, stdout, stderr }));
+  });
+}
+
+/** Run ffprobe and parse JSON output (`-of json` is added automatically). */
+export async function probeMedia(input, extraArgs = []) {
+  const args = ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', ...extraArgs, input];
+  const res = await new Promise((resolve, reject) => {
+    const child = spawn(ffprobePath(), args);
+    let stdout = '';
+    let stderr = '';
+    child.stdout.on('data', d => { stdout += d; });
+    child.stderr.on('data', d => { stderr += d; });
+    child.on('error', reject);
+    child.on('close', code => resolve({ code, stdout, stderr }));
+  });
+  if (res.code !== 0) throw new Error('ffprobe failed: ' + res.stderr);
+  return JSON.parse(res.stdout);
+}
+
+/** Convenience: media duration in milliseconds (rounded), or null if unknown. */
+export async function probeDurationMs(input) {
+  const info = await probeMedia(input);
+  const secs = info && info.format && info.format.duration ? parseFloat(info.format.duration) : NaN;
+  return Number.isFinite(secs) ? Math.round(secs * 1000) : null;
+}

+ 8 - 0
claude-code/fmode-listen/.claude-plugin/plugin.json

@@ -0,0 +1,8 @@
+{
+  "name": "fmode-listen",
+  "description": "录音文件转写(讯飞 LFASR)通过 Fmode 网关 /api/listen/transcribe 完成。讯飞凭据仅服务端持有,客户端只需 fmode token,服务端按音频时长计费。支持中英多语种、方言、说话人分离。",
+  "version": "0.1.1",
+  "author": {
+    "name": "fmode"
+  }
+}

+ 83 - 0
claude-code/fmode-listen/README.md

@@ -0,0 +1,83 @@
+# fmode-listen
+
+Claude Code skill + CLI that transcribes audio files via the **Fmode gateway**
+(`POST /api/listen/transcribe`, backed by iFlytek LFASR). iFlytek credentials
+live only on the server — the client just needs an **fmode token**, and the
+server bills by actual audio duration.
+
+## Transcribe directly
+
+```bash
+# transcribe an audio file (auto-probes duration for the pre-check)
+npx fmode-listen@latest transcribe -- meeting.mp3
+
+# language + speaker diarization + dump full JSON
+npx fmode-listen@latest transcribe -- meeting.mp3 \
+  --language autodialect --diarize --speakers 3 --out result.json
+
+# custom gateway (default https://server.fmode.cn/api/listen)
+npx fmode-listen@latest transcribe -- meeting.mp3 --gateway https://server.fmode.cn/api/listen
+```
+
+`transcribe` prints the plain transcript to stdout. Everything after `--` is
+passed through to the runner.
+
+## Auth (fmode token only)
+
+The runner resolves the token in this order:
+
+1. `FMODE_API_TOKEN` environment variable
+2. `~/.fmode/config.json` → `fmodeApiToken` / `newapiToken` (written by FmodeStudio)
+3. `./.fmode/config.json` → `fmodeApiToken` / `newapiToken`
+4. `~/.claude/settings.json` (plus `settings.local.json` / project `.claude/`) → `env.ANTHROPIC_AUTH_TOKEN` — **this is Claude Code's `sk-` token; the runner reads it automatically, no manual config needed**. Only accepted when it starts with `sk-` (real Anthropic `sk-ant-` is excluded) and the base points to fmode.
+
+> A "token not found" error means the token is *missing*, not that the service is unavailable — do not click any payment/top-up popup; just supply the token via any source above.
+
+Never put iFlytek `appId/apiKey/secretKey` on the client — they belong to the
+server only.
+
+## Install the Claude Code skill
+
+```bash
+# project-level → ./.claude/skills/fmode-listen
+npx fmode-listen@latest workspace
+
+# user-level → ~/.claude/skills/fmode-listen
+npx fmode-listen@latest install
+```
+
+Then prompt Claude Code, e.g. `把 meeting.mp3 转写成文字,开启说话人分离。`
+
+## Billing
+
+- Billed by **actual audio duration**: `ceil(minutes) × unit price`, minimum 1 minute.
+- The server charges after transcription succeeds (real duration from iFlytek),
+  through the same newapi metering as other Fmode models. The client-supplied
+  `duration` is only used for the pre-flight balance check.
+- Insufficient balance returns HTTP 402 with a `rechargeUrl`.
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `transcribe -- <file> [opts]` / `run -- ...` | transcribe an audio file via the gateway |
+| `workspace` / `install` | install the Claude Code skill |
+| `check` / `smoke` / `path` | verify install / run smoke test / print target |
+
+## Programmatic use
+
+```js
+import { transcribeFile, probeDurationMs, resolveApiToken }
+  from './skills/fmode-listen/scripts/listen-runner.mjs';
+
+const { text, segments, raw } = await transcribeFile({
+  filePath: 'meeting.mp3',
+  language: 'autodialect',
+  diarize: true,
+  speakers: 3,
+});
+```
+
+## License
+
+MIT

+ 192 - 0
claude-code/fmode-listen/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); }

+ 33 - 0
claude-code/fmode-listen/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "fmode-listen",
+  "version": "0.1.1",
+  "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"
+}

+ 40 - 0
claude-code/fmode-listen/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
claude-code/fmode-listen/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
claude-code/fmode-listen/skills/fmode-listen/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
claude-code/fmode-listen/skills/fmode-listen/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);
+  });
+}

+ 8 - 0
claude-code/fmode-vision/.claude-plugin/plugin.json

@@ -0,0 +1,8 @@
+{
+  "name": "fmode-vision",
+  "description": "Analyze images and videos via Fmode API vision models. Single-pass and multi-pass focused analysis with structured JSON output, plus a renovation room-measurement prompt pipeline.",
+  "version": "0.1.1",
+  "author": {
+    "name": "fmode"
+  }
+}

+ 46 - 0
claude-code/fmode-vision/README.md

@@ -0,0 +1,46 @@
+# fmode-vision
+
+Claude Code skill for analyzing images and videos via Fmode API vision models (`api.fmode.cn`). Supports single-pass analysis, multi-pass focused analysis with intermediate caching, video-frame analysis, batch processing, and a renovation room-measurement (毛坯房量尺) 5-pass prompt pipeline.
+
+## Install
+
+Project workspace (writes `./.claude/skills/fmode-vision/`):
+
+```bash
+npx fmode-vision@latest workspace
+```
+
+User level for all workspaces (writes `~/.claude/skills/fmode-vision/`):
+
+```bash
+npx fmode-vision@latest install
+```
+
+Restart the Claude Code session afterwards so it discovers the skill.
+
+## Token
+
+The skill auto-detects the Fmode API token in this priority order:
+
+1. `FMODE_API_TOKEN` environment variable
+2. `~/.fmode/config.json` → `fmodeApiToken` (or `newapiToken`)
+3. `<project>/.fmode/config.json` → `fmodeApiToken` (or `newapiToken`)
+4. `ANTHROPIC_AUTH_TOKEN` environment variable (zero-config inside Claude Code, since it is the same Fmode gateway token)
+
+If none are found, the skill returns a clear message listing these four options.
+
+## Usage
+
+In Claude Code, just ask in natural language, e.g.:
+
+```
+帮我分析这张图片里的关键内容,输出结构化信息。
+```
+
+The skill calls `POST https://api.fmode.cn/v1/chat/completions` with the default vision model `doubao-seed-2-0-pro-260215`. Usage is billed against the Fmode token.
+
+## Verify
+
+```bash
+npm run smoke
+```

+ 155 - 0
claude-code/fmode-vision/bin/fmode-vision.js

@@ -0,0 +1,155 @@
+#!/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-vision';
+const SOURCE_ROOT = path.resolve(__dirname, '..');
+const SKILL_SOURCE = path.join(SOURCE_ROOT, 'skills', SKILL_NAME);
+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');
+
+function expandHome(value) {
+  return String(value || '').replace(/^~(?=$|[\\/])/, os.homedir());
+}
+
+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-vision skill installer',
+    '',
+    'Usage:',
+    '  npx fmode-vision@latest workspace [--smoke]   # install into ./.claude/skills/fmode-vision',
+    '  npx fmode-vision@latest install [--smoke]      # install into ~/.claude/skills/fmode-vision',
+    '  npx fmode-vision@latest install --target <dir> [--force]',
+    '  npx fmode-vision@latest check',
+    '  npx fmode-vision@latest smoke',
+    '  npx fmode-vision@latest path',
+    '',
+    'Options:',
+    '  --workspace      Install into ./.claude/skills/fmode-vision',
+    '  --global         Install into ~/.claude/skills/fmode-vision (default)',
+    '  --target <dir>   Install into a custom directory',
+    '  --force          Allow overwriting a custom target',
+    '  --smoke          Run smoke checks after install',
+    '  --help, -h       Show help'
+  ].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/vision-client.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('Token: set FMODE_API_TOKEN, or ~/.fmode/config.json -> fmodeApiToken, or rely on ANTHROPIC_AUTH_TOKEN.');
+  console.log('');
+  console.log('Try this prompt in Claude Code:');
+  console.log('  帮我分析这张图片里的关键内容,输出结构化信息。');
+}
+
+function main() {
+  const args = parseArgs(process.argv.slice(2));
+  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-vision failed: ${error.message}`); process.exit(1); }

+ 30 - 0
claude-code/fmode-vision/package.json

@@ -0,0 +1,30 @@
+{
+  "name": "fmode-vision",
+  "version": "0.1.1",
+  "description": "Claude Code skill: analyze images and videos via Fmode API vision models (api.fmode.cn). Single-pass and multi-pass focused analysis with structured JSON output. Auto-reads token from FMODE_API_TOKEN, ~/.fmode/config.json, project .fmode/config.json, or ANTHROPIC_AUTH_TOKEN.",
+  "type": "commonjs",
+  "bin": {
+    "fmode-vision": "bin/fmode-vision.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",
+    "vision",
+    "image-analysis",
+    "doubao"
+  ],
+  "license": "MIT",
+  "dependencies": {}
+}

+ 28 - 0
claude-code/fmode-vision/scripts/smoke.js

@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const path = require('path');
+const { pathToFileURL } = require('url');
+
+const ROOT = path.resolve(__dirname, '..');
+const SKILL_DIR = path.join(ROOT, 'skills', 'fmode-vision');
+
+function fail(msg) { console.error('SMOKE FAIL: ' + msg); process.exit(1); }
+
+const required = [
+  'SKILL.md',
+  'scripts/vision-client.mjs',
+  'scripts/prompts/room-measurement.mjs'
+];
+for (const rel of required) {
+  if (!fs.existsSync(path.join(SKILL_DIR, rel))) fail('missing ' + rel);
+}
+
+(async () => {
+  const mod = await import(pathToFileURL(path.join(SKILL_DIR, 'scripts', 'vision-client.mjs')).href);
+  for (const fn of ['resolveApiToken', 'callVisionAPI', 'callMultiPass', 'extractJSON']) {
+    if (typeof mod[fn] !== 'function') fail('export ' + fn + ' is not a function');
+  }
+  const { parsed } = mod.extractJSON('text {"a":1} tail');
+  if (!parsed || parsed.a !== 1) fail('extractJSON did not parse JSON');
+  console.log('SMOKE OK: fmode-vision package structure + module exports verified');
+})().catch(e => fail(e.message));

+ 17 - 0
claude-code/fmode-vision/skill-package-manifest.json

@@ -0,0 +1,17 @@
+{
+  "name": "fmode-vision",
+  "version": "0.1.1",
+  "description": "Claude Code 独立技能包:通过 Fmode API 视觉模型对图片、视频进行分析。支持单轮分析、多轮聚焦分析、视频帧分析与批量处理,并内置毛坯房量尺 5-pass 分析提示词。",
+  "plugin": "fmode-vision",
+  "skills": [
+    "fmode-vision"
+  ],
+  "entrySkill": "fmode-vision",
+  "npmPackage": "fmode-vision",
+  "smokeCommand": "npm run smoke",
+  "installCommand": "npx fmode-vision@latest install",
+  "workspaceInstallCommand": "npx fmode-vision@latest workspace",
+  "workspaceSkillPath": ".claude/skills/fmode-vision/SKILL.md",
+  "globalSkillPath": "%USERPROFILE%/.claude/skills/fmode-vision/SKILL.md",
+  "installHint": "工作区安装:npx fmode-vision@latest workspace,会写入 ./.claude/skills/fmode-vision/。用户级安装:npx fmode-vision@latest install,会写入 ~/.claude/skills/fmode-vision/。Token 优先级:FMODE_API_TOKEN > ~/.fmode/config.json > 项目 .fmode/config.json > ~/.claude/settings.json 的 env.ANTHROPIC_AUTH_TOKEN(Claude Code 的 sk- token,自动读取,无需手动配置)。"
+}

+ 185 - 0
claude-code/fmode-vision/skills/fmode-vision/README.md

@@ -0,0 +1,185 @@
+# Fmode Vision Skill — 维护文档
+
+## 项目结构
+
+```
+.claude/skills/fmode-vision/
+├── SKILL.md                       # 技能入口,Claude 读取后知道何时及如何使用本技能
+├── README.md                      # 本文件:开发者维护文档
+├── .skillfish.json                # 技能元信息(版本、来源仓库)
+└── scripts/
+    ├── vision-client.mjs           # 核心:通用视觉 API 客户端
+    └── prompts/
+        └── room-measurement.mjs    # 领域模块:毛坯房量尺 5-pass 提示词
+```
+
+## 核心逻辑
+
+### 1. Token 解析链 (`resolveApiToken`)
+
+三级优先级,短路返回:
+
+```
+FMODE_API_TOKEN 环境变量
+  → ~/.fmode/config.json 的 fmodeApiToken / newapiToken 字段
+    → <cwd>/.fmode/config.json 的 fmodeApiToken / newapiToken 字段
+      → 抛出异常(提示用户配置)
+```
+
+设计原因:环境变量适合 CI/CD;用户级配置适合个人开发机;项目级配置适合团队共享(加入 .gitignore)。
+
+### 2. API 调用流程 (`callVisionAPI`)
+
+```
+输入: imagePath | imageBase64 | imageUrl | videoUrl
+  |
+  ├─ 解析 token
+  ├─ 构造 messages 数组
+  │   ├─ system prompt
+  │   └─ user content:
+  │       ├─ text part(用户提示词)
+  │       └─ image_url / video_url part(视觉内容)
+  ├─ POST https://api.fmode.cn/v1/chat/completions
+  │   body: { model, messages, temperature, max_tokens }
+  ├─ 响应的 content 字符串 → extractJSON()
+  └─ 返回 { raw, parsed, error, usage }
+```
+
+### 3. 多轮分析模式 (`callMultiPass`)
+
+核心理念:每轮独立调用 API,各自聚焦一个分析维度,最后一轮合并。这比单轮全量分析精度更高。
+
+```
+输入: imagePath + passes[{name, systemPrompt, userPrompt, maxTokens}]
+  |
+  for each pass:
+  ├─ 检查 cacheDir/pass<N>.json 是否存在
+  │   ├─ 存在 → 跳过,读取缓存
+  │   └─ 不存在 → callVisionAPI() → 写入缓存
+  ├─ sleep(delayMs) 避免限流
+  |
+  └─ 返回 results[]
+```
+
+缓存设计:
+- 每轮结果独立缓存,支持断点续跑
+- 缓存 key = pass 序号,与提示词内容无关
+- 如需强制重新分析,删除对应缓存文件即可
+- 提示词迭代时,建议手动清理缓存
+
+### 4. JSON 提取 (`extractJSON`)
+
+LLM 响应可能被 markdown 代码块包裹(```json ... ```),也可能前后有解释文字。用正则 `/\{[\s\S]*\}/` 提取第一个 JSON 对象。
+
+### 5. 毛坯房 5-pass 专用流程 (`room-measurement.mjs`)
+
+继承自 `analyze-photos-v4.mjs`,5 轮各有独立职责:
+
+| Pass | 名称 | 分析焦点 | tokens |
+|------|------|---------|--------|
+| 1 | spatial | 空间结构:透视类型、墙面多边形、阴阳角、天地面 | 2000 |
+| 2 | ceiling | 吊顶特征:cornice/trayStep/beam/bulkhead | 1000 |
+| 3 | openings | 门窗洞口:双层框架(outer+inner polygon) | 2500 |
+| 4 | obstacles | 障碍物:插座/开关/电箱/踢脚线/风口等 | 1500 |
+| 5 | merge | 文本合并:场景描述、房间类型、测量计划、质量评估 | 2000 |
+
+质量验证:
+- 踢脚线 height > 10% → 警告(应为 2-5%)
+- 吊顶特征 polygon 顶点 > 4 → 警告
+
+## 配置说明
+
+### API Token
+
+方式一:环境变量
+```bash
+export FMODE_API_TOKEN="sk-xxxxxxxx"
+```
+
+方式二:用户级配置 `~/.fmode/config.json`
+```json
+{
+  "fmodeApiToken": "sk-xxxxxxxx"
+}
+```
+
+方式三:项目级配置 `<project>/.fmode/config.json`(需加入 .gitignore)
+```json
+{
+  "fmodeApiToken": "sk-xxxxxxxx"
+}
+```
+
+### 可用模型
+
+| 模型 ID | 用途 | 备注 |
+|---------|------|------|
+| `doubao-seed-2-0-pro-260215` | 视觉理解(默认) | 豆包视觉模型,性价比高 |
+| `gpt-4o` | 视觉理解 | 如账号有权限 |
+
+模型列表可能更新,以 Fmode API 返回为准。
+
+## 扩展指南
+
+### 添加新的提示词模板
+
+在 `scripts/prompts/` 下新建 `.mjs` 文件:
+
+```js
+import { callVisionAPI, callMultiPass } from '../vision-client.mjs';
+
+export const MY_SYSTEM_PROMPT = `...`;
+export const MY_USER_PROMPT = `...`;
+
+export async function analyzeSomething(imagePath) {
+  const result = await callVisionAPI({
+    imagePath,
+    systemPrompt: MY_SYSTEM_PROMPT,
+    userPrompt: MY_USER_PROMPT,
+    maxTokens: 1000,
+  });
+  return result.parsed;
+}
+```
+
+### 添加新模型
+
+在 `vision-client.mjs` 的 `DEFAULT_CONFIG` 中调整默认模型,或调用时传入 `model` 参数:
+
+```js
+const result = await callVisionAPI({
+  imagePath: '/path/to/img.jpg',
+  systemPrompt: '...',
+  userPrompt: '...',
+  model: 'gpt-4o',  // 覆盖默认模型
+});
+```
+
+### 多轮分析自定义
+
+```js
+import { callMultiPass } from './vision-client.mjs';
+
+const results = await callMultiPass({
+  imagePath: '/path/to/img.jpg',
+  cacheDir: '/tmp/my-analysis/img-001/',
+  passes: [
+    { name: 'overview', systemPrompt: '...', userPrompt: '描述整体场景', maxTokens: 500 },
+    { name: 'details', systemPrompt: '...', userPrompt: '标注细节元素', maxTokens: 1500 },
+    { name: 'verify',  systemPrompt: '...', userPrompt: '验证前两轮一致性', maxTokens: 1000 },
+  ],
+});
+```
+
+## 依赖
+
+仅使用 Node.js 内置模块:`fs`, `path`, `os`。无需 `npm install`。
+
+全局 `fetch` 需要 Node.js 18+(已内置)。
+
+## 源文件参考
+
+本技能从以下文件提取和通用化:
+- `d:\workspace\hundun\lami-scale-canvas\scripts\analyze-photos-v4.mjs` — 原始 5-pass 量尺分析实现
+
+版本追踪:lami-scale-canvas 仓库 `analyze-photos-v4.mjs` 如有更新,需同步检查本技能是否需要升级。

+ 163 - 0
claude-code/fmode-vision/skills/fmode-vision/SKILL.md

@@ -0,0 +1,163 @@
+---
+name: fmode-vision
+description: "通过 Fmode API 调用视觉模型对图片、视频进行分析。适用场景:(1) 图片内容识别与结构化提取, (2) 多轮聚焦分析获取高精度结果, (3) 视频帧分析, (4) 视觉素材批量处理"
+description_en: "Analyze images and videos via Fmode API vision models. Use for: (1) Image content recognition and structured extraction, (2) Multi-pass focused analysis for high-precision results, (3) Video frame analysis, (4) Batch visual material processing"
+---
+
+# Fmode Vision — 视觉识别技能
+
+## Overview
+
+本技能封装 Fmode API (api.fmode.cn) 的视觉模型调用,支持单轮和多轮分析。用户可能要求你分析图片、处理视频帧、或对视觉素材进行结构化信息提取。
+
+## Token 获取
+
+客户端只需 fmode token(`sk-` 开头的 fmode newapi key)。运行器 `resolveApiToken()` 按以下优先级自动解析(与 `fmode-listen` 一致):
+
+1. **环境变量** `FMODE_API_TOKEN`(最高优先级)
+2. **用户级配置** `~/.fmode/config.json` → `fmodeApiToken` / `newapiToken` 字段(FmodeStudio 保存配置后写入)
+3. **项目级配置** `<project>/.fmode/config.json` → `fmodeApiToken` / `newapiToken`
+4. **进程注入的** `ANTHROPIC_AUTH_TOKEN`(Claude Code 把 settings.env 注入子进程时;`sk-` 开头、base 指向 fmode,排除真 Anthropic 的 `sk-ant-`)
+5. **`~/.claude/settings.json`**(含 `settings.local.json` / 项目级 `.claude/`)的 `env.ANTHROPIC_AUTH_TOKEN`——**这就是 Claude Code 里配的 fmode `sk-` token,运行器会自动读取,无需手动配置**
+
+> 若运行器报「未找到 token」,那是**缺 token,不是「用不了」**——按上面任一来源补上即可(最常见是 `~/.claude/settings.json` 的 `sk-`)。**缺 token 时请勿点任何付费/充值弹窗**;只有真正调用返回 `402 余额不足` 才需要充值,本技能走 NewAPI(fmode-api)计费,充值入口为 `https://app.fmode.cn/dev/studio/?balance=fmodeapi`。
+
+## API 调用规范
+
+- **Base URL**: `https://api.fmode.cn`
+- **Endpoint**: `POST /v1/chat/completions`
+- **Auth**: `Authorization: Bearer <token>`
+- **默认视觉模型**: `doubao-seed-2-0-pro-260215`
+- **备选模型**: `gpt-4o`, `gpt-4-vision-preview`(如有权限)
+
+### 请求体结构
+
+```json
+{
+  "model": "doubao-seed-2-0-pro-260215",
+  "messages": [
+    { "role": "system", "content": "系统提示词" },
+    {
+      "role": "user",
+      "content": [
+        { "type": "text", "text": "用户提示词" },
+        { "type": "image_url", "image_url": { "url": "data:image/jpeg;base64,<base64>" } }
+      ]
+    }
+  ],
+  "temperature": 0.12,
+  "max_tokens": 2000
+}
+```
+
+### 视觉内容支持
+
+| 类型 | 传递方式 | 适用场景 |
+|------|---------|---------|
+| 本地图片 | `data:image/<fmt>;base64,<data>` | jpg/png/webp |
+| 远程图片 | 直接 URL | 需模型支持公网访问 |
+| 视频 | `type: "video_url"` | 模型自动抽帧 |
+
+## 核心工作流
+
+### 决策树
+
+```
+需要分析视觉内容?
+├── 简单描述/单维度提取 → 单轮分析 (callVisionAPI)
+├── 多维度精确标注 → 多轮聚焦分析 (callMultiPass)
+│   ├── 每轮独立调用 API,专注一个维度
+│   ├── 中间结果写入缓存目录
+│   └── 最后一轮合并所有结果
+└── 批量处理 → 遍历 + 单轮/多轮
+```
+
+### 单轮分析
+
+使用 `scripts/vision-client.mjs` 的 `callVisionAPI()` 函数:
+
+```js
+import { callVisionAPI, resolveApiToken } from './scripts/vision-client.mjs';
+
+const result = await callVisionAPI({
+  imagePath: '/path/to/image.jpg',
+  systemPrompt: '你是一位影像分析专家...',
+  userPrompt: '请描述这张图片中的关键元素...',
+  maxTokens: 1000,
+});
+// result = { raw, parsed, error, usage }
+```
+
+### 多轮聚焦分析
+
+使用 `callMultiPass()` 封装,适用于需要从不同维度精确分析的场景:
+
+```js
+import { callMultiPass } from './scripts/vision-client.mjs';
+
+const passes = [
+  { name: 'structure', systemPrompt: '...', userPrompt: '...', maxTokens: 2000 },
+  { name: 'details', systemPrompt: '...', userPrompt: '...', maxTokens: 1000 },
+];
+
+const results = await callMultiPass({
+  imagePath: '/path/to/image.jpg',
+  passes,
+  cacheDir: '/tmp/analysis/image-id/',
+});
+```
+
+## 提示词工程
+
+### 结构化输出
+
+始终要求模型输出严格 JSON,在 system prompt 中给出完整 schema:
+
+```
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "field1": "value",
+  "field2": [{ "sub": "value" }]
+}
+```
+
+### 聚焦原则
+
+多轮分析中每轮只关注一个维度,明确告知模型忽略其他内容:
+```
+## 规则
+1. 只标注 X 类元素,忽略 Y、Z 等其他所有元素
+2. 每个元素标注精确的 boundingBox
+```
+
+### JSON 提取
+
+模型可能包裹 markdown 代码块,使用 `extractJSON()` 提取:
+
+```js
+import { extractJSON } from './scripts/vision-client.mjs';
+const parsed = extractJSON(rawResponse);
+```
+
+## 结果缓存
+
+多轮分析支持中间结果缓存:
+- 每轮结果写入 `cacheDir/pass<N>.json`
+- 重新运行时自动跳过已有缓存
+- 如需强制重新分析,删除对应缓存文件
+
+## 领域模块
+
+### 毛坯房量尺分析
+
+`scripts/prompts/room-measurement.mjs` 提供 5-pass 量尺分析提示词:
+- Pass 1: 空间结构(透视/墙面/阴阳角)
+- Pass 2: 吊顶特征(cornice/trayStep/beam/bulkhead)
+- Pass 3: 门窗洞口(双层框架)
+- Pass 4: 障碍物(精确 boundingBox)
+- Pass 5: 合并 + 测量计划
+
+```js
+import { processPhoto } from './scripts/prompts/room-measurement.mjs';
+const merged = await processPhoto('/path/to/photo.jpg', 'photo-001', 'photo-001.jpg');
+```

+ 408 - 0
claude-code/fmode-vision/skills/fmode-vision/scripts/prompts/room-measurement.mjs

@@ -0,0 +1,408 @@
+/**
+ * 毛坯房量尺 — 5轮聚焦提示词模块
+ *
+ * 从 analyze-photos-v4.mjs 移植,API 调用委托给 vision-client.mjs。
+ *
+ * 使用示例:
+ *   import { processPhoto, PASS_CONFIGS } from './prompts/room-measurement.mjs';
+ *   const result = await processPhoto('/path/to/photo.jpg', 'img-001', 'room-a.jpg');
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { callVisionAPI } from '../vision-client.mjs';
+
+// ============================================================
+// 5轮聚焦提示词
+// ============================================================
+
+export const PASS1_SYSTEM = `你是一位建筑空间分析专家。你的任务是精确分析毛坯房照片的**空间结构**。
+
+## 规则
+1. **透视类型**:判断一点透视/两点透视/三点透视。
+   - 一点透视:正面墙正对镜头,水平线汇聚到画面中心
+   - 两点透视:墙角在画面中心附近,两侧墙面分别向左右消失
+   - 三点透视:仰拍/俯拍导致垂直线也汇聚
+   - 特别注意:如果看到两个墙面以夹角呈现(墙角在画面中心附近),必须报告 twoPoint
+
+2. **墙面多边形**:每面可见墙标注**精确的4个角点**(四边形),沿建筑实际边缘。
+   - surfaceType: facing(正面)/leftWall(左墙)/rightWall(右墙)
+   - 每条边放3个等分测量点(measurePoints)
+
+3. **天花/地面区域**:各标注4个角点的多边形
+
+4. **阴阳角**:标注位置(x,y)
+
+5. **忽略**所有小物件、家具、装饰、门窗、吊顶细节——这些会在后续分析中处理
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 1,
+  "perspective": {"type": "onePoint|twoPoint|threePoint", "description": "透视说明", "vanishingPoints": [{"x": 50, "y": 40}]},
+  "surfaces": {
+    "walls": [
+      {"id": "w1", "label": "正面主墙", "surfaceType": "facing",
+       "polygon": [{"x":20,"y":25},{"x":75,"y":25},{"x":75,"y":82},{"x":20,"y":80}],
+       "measureLines": [
+         {"label":"顶边3点","type":"horizontal","edge":"top","startPoint":{"x":20,"y":25},"endPoint":{"x":75,"y":25},"measurePoints":[{"x":20,"y":25},{"x":47.5,"y":25},{"x":75,"y":25}]},
+         {"label":"底边3点","type":"horizontal","edge":"bottom","startPoint":{"x":20,"y":80},"endPoint":{"x":75,"y":82},"measurePoints":[{"x":20,"y":80},{"x":47.5,"y":81},{"x":75,"y":82}]},
+         {"label":"左边3点","type":"vertical","edge":"left","startPoint":{"x":20,"y":25},"endPoint":{"x":20,"y":80},"measurePoints":[{"x":20,"y":25},{"x":20,"y":52.5},{"x":20,"y":80}]},
+         {"label":"右边3点","type":"vertical","edge":"right","startPoint":{"x":75,"y":25},"endPoint":{"x":75,"y":82},"measurePoints":[{"x":75,"y":25},{"x":75,"y":53.5},{"x":75,"y":82}]}
+       ]}
+    ],
+    "floorRegion": {"polygon": [{"x":0,"y":80},{"x":100,"y":80},{"x":100,"y":100},{"x":0,"y":100}], "label": "可见地面"},
+    "ceilingRegion": {"polygon": [{"x":0,"y":0},{"x":100,"y":0},{"x":100,"y":20},{"x":0,"y":20}], "label": "可见天花"}
+  },
+  "corners": [
+    {"id":"c1","type":"internal","label":"左阴角","position":{"x":20,"y":55}},
+    {"id":"c2","type":"internal","label":"右阴角","position":{"x":75,"y":55}}
+  ]
+}`;
+
+export const PASS1_USER = `请分析这张照片的**空间结构**:
+1. 判断透视类型(一点/两点/三点),找消失点
+2. 标注每面可见墙的4角多边形,区分facing/leftWall/rightWall
+3. 标注天花/地面区域
+4. 标注阴阳角位置
+
+只输出JSON,不包含其他内容:`;
+
+export const PASS2_SYSTEM = `你是一位吊顶与天花结构分析专家。你的任务是精确分析照片中的**天花板特征**。
+
+## 规则
+1. **只标注天花板上的结构特征**,忽略墙面、地面、门窗、障碍物
+2. **关键:每个特征必须用4个角点的简单四边形标注**。即使实际形状不规则,也只能用4点近似。禁止使用5点或更多点。
+3. 特征类型:
+   - cornice: 石膏线/阴角线(天花与墙面交界处的装饰线条)
+   - trayStep: 吊顶叠级/双眼皮(不同高度的吊顶分界线)
+   - beam: 梁/下返结构
+   - bulkhead: 窗帘盒/设备带(局部下返区域)
+   - soffit: 管道包封/检修口
+4. polygon的4个点按顺时针方向标注
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 2,
+  "ceilingFeatures": [
+    {"id":"cf1","type":"cornice","label":"石膏阴角线",
+     "polygon": [{"x":0,"y":8},{"x":100,"y":8},{"x":100,"y":12},{"x":0,"y":12}]},
+    {"id":"cf2","type":"trayStep","label":"第一层叠级线",
+     "polygon": [{"x":20,"y":22},{"x":80,"y":22},{"x":80,"y":26},{"x":20,"y":26}]}
+  ]
+}
+
+如果没有可见的天花特征,返回空数组:{"pass":2,"ceilingFeatures":[]}`;
+
+export const PASS2_USER = `请分析这张照片的**天花板特征**:
+1. 石膏线/阴角线(cornice)
+2. 吊顶叠级/双眼皮(trayStep)
+3. 梁/下返结构(beam)
+4. 窗帘盒/设备带(bulkhead)
+
+记住:每个特征只能用4个角点标注!简单四边形!
+
+只输出JSON:`;
+
+export const PASS3_SYSTEM = `你是一位门窗洞口测量专家。你的任务是精确分析照片中的**所有门洞和窗洞**。
+
+## 规则
+1. **只标注门洞和窗洞**,忽略其他所有元素(墙壁、天花、障碍物等)
+2. 每个洞口标注**双层框架**:
+   - outerPolygon: 洞口在墙面上的外轮廓(4个角点,即墙面上的实际开口边缘)
+   - innerPolygon: 门扇/窗扇/玻璃区域的内轮廓(4个角点)
+   - frameThickness: 门套/窗套线宽度(百分比),如无套线则为0
+3. 测量线沿外框放置:上中下宽度3点 + 左中右高度3点
+4. 如果无可见洞口,返回空数组
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 3,
+  "openings": [
+    {"id":"d1","type":"door","label":"入户门",
+     "frame": {
+       "outerPolygon": [{"x":35,"y":20},{"x":55,"y":18},{"x":55,"y":80},{"x":35,"y":82}],
+       "innerPolygon": [{"x":37,"y":22},{"x":53,"y":20},{"x":53,"y":78},{"x":37,"y":80}],
+       "frameThickness": 2.0
+     },
+     "measureLines": [
+       {"label":"门洞上口宽","type":"horizontal","startPoint":{"x":35,"y":20},"endPoint":{"x":55,"y":18},"measurePoints":[{"x":35,"y":20},{"x":45,"y":19},{"x":55,"y":18}]},
+       {"label":"门洞左口高","type":"vertical","startPoint":{"x":35,"y":20},"endPoint":{"x":35,"y":82},"measurePoints":[{"x":35,"y":20},{"x":35,"y":51},{"x":35,"y":82}]}
+     ]}
+  ]
+}`;
+
+export const PASS3_USER = `请分析这张照片的**所有门洞和窗洞**:
+1. 标注外层框架(outerPolygon,墙上开口的精确边缘)
+2. 标注内层框架(innerPolygon,门扇/玻璃边缘)
+3. 标注门套/窗套厚度(frameThickness)
+4. 放置测量点
+
+只输出JSON:`;
+
+export const PASS4_SYSTEM = `你是一位全屋定制障碍物检测专家。你的任务是精确标注照片中**所有可见障碍物**的包围盒。
+
+## 核心原则
+每个包围盒(boundingBox)告诉测量人员"需要测量这个矩形区域的实际尺寸"。你必须非常精确——贴合物体的真实可见边缘。
+
+## 障碍物类型
+- outlet(插座): 86型约2%×2%, 118型约3%×2%
+- switch(开关): 同插座
+- electricBox(电箱): 箱体外框,通常5-15%
+- vent(风口): 格栅外框在吊顶/墙上
+- pipe(管道): 管道与墙/地接触范围
+- baseboard(踢脚线): 墙底水平条带
+- doorFrame(门套线): 门套在墙上的宽度条带
+- windowFrame(窗套线): 窗套在墙上的范围
+- gasMeter(燃气表): 表箱外框
+- floorDrain(地漏): 地面位置
+- downlight(筒灯): 天花位置
+
+## ⚠️ 踢脚线高度规则(非常重要!)
+- 踢脚线(baseboard)的高度必须在 2%-5% 之间
+- 这是踢脚线条带**本身**的高度,不是从踢脚线到墙顶的距离
+- 正面墙(facing)踢脚线:沿着墙底的水平窄条,height = 2-4%
+- 侧墙(leftWall/rightWall)踢脚线:height = 2-5%(不要被透视缩短误导!)
+- **如果标注的height > 10%,一定是错误的——请重新检查!**那是整面墙的高度,不是踢脚线
+- 侧墙的踢脚线:看墙底部那条水平的细线/条带,标注那条条带的高度
+
+## 包围盒格式
+boundingBox: { x, y, width, height } — 全部百分比
+- x, y: 包围盒左上角相对于图片的百分比位置
+- width, height: 包围盒的宽高百分比
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 4,
+  "obstacles": [
+    {"id":"obs1","type":"outlet","label":"五孔插座(86型)","boundingBox":{"x":42,"y":56,"width":2.5,"height":3.2}},
+    {"id":"obs2","type":"baseboard","label":"木质踢脚线","boundingBox":{"x":20,"y":80,"width":55,"height":3}},
+    {"id":"obs3","type":"vent","label":"空调出风口","boundingBox":{"x":8,"y":10,"width":14,"height":4}}
+  ]
+}`;
+
+export const PASS4_USER = `请分析这张照片的**所有障碍物**:
+1. 插座、开关、电箱
+2. 风口(空调、新风、排风)
+3. 管道
+4. 踢脚线(⚠️ height必须2-5%,不能是整面墙高度!)
+5. 门套线、窗套线
+6. 燃气表、地漏
+7. 筒灯、射灯
+
+每个障碍物用精确的boundingBox{x,y,width,height}标注。
+只输出JSON:`;
+
+export const PASS5_SYSTEM = `你是一位全屋定制测量专家。你有4份针对同一房间的分析数据,分别来自不同专家的独立观察。请将它们合并为一份完整的测量分析报告。
+
+## 你的任务
+1. 阅读4份数据,理解空间结构
+2. 写出 sceneDescription(完整的场景描述,2-3句话)
+3. 判断 roomType(卧室/客厅/厨房/卫生间/阳台/走廊/储物间/其他)
+4. 生成 measurementPlan(测量计划),将所有元素关联到测量步骤
+5. 评估 photoQuality(是否广角、畸变程度、是否需要补拍)
+6. 列出 issues(如有遮挡、光线不足等问题)
+
+## 测量计划规则
+- 每面墙至少一个步骤(3点宽+3点高)
+- 每个门洞/窗洞一个步骤
+- 每组同类障碍物可以合并为一个步骤(如"测量所有插座位置")
+- 步骤按重要性排序:required > recommended > optional
+- elementIds必须引用实际存在的ID(来自输入数据)
+- 工具:激光测距仪(长距离)、卷尺(小尺寸)、水平仪(垂直度)
+
+## 输出格式(严格JSON,无markdown代码块)
+{
+  "pass": 5,
+  "sceneDescription": "完整的场景描述...",
+  "roomType": "卧室",
+  "measurementPlan": [
+    {"step":1,"action":"测量正面主墙顶中底3点宽度与左中右3点高度","target":"w1","tool":"激光测距仪","priority":"required","elementIds":["w1"]}
+  ],
+  "photoQuality": {"isWideAngle":true,"distortionLevel":"low","recommendReshoot":false,"reshootAdvice":""},
+  "issues": []
+}`;
+
+export const PASS5_USER_TEMPLATE = `以下是一个房间的4份独立分析数据。请将它们合并:
+
+=== 空间结构 ===
+__PASS1__
+
+=== 吊顶特征 ===
+__PASS2__
+
+=== 门窗洞口 ===
+__PASS3__
+
+=== 障碍物 ===
+__PASS4__
+
+请生成完整的测量分析报告。只输出JSON:`;
+
+// ============================================================
+// 轮次配置(供 callMultiPass 使用)
+// ============================================================
+
+export const PASS_CONFIGS = [
+  { name: 'spatial', systemPrompt: PASS1_SYSTEM, userPrompt: PASS1_USER, maxTokens: 2000 },
+  { name: 'ceiling', systemPrompt: PASS2_SYSTEM, userPrompt: PASS2_USER, maxTokens: 1000 },
+  { name: 'openings', systemPrompt: PASS3_SYSTEM, userPrompt: PASS3_USER, maxTokens: 2500 },
+  { name: 'obstacles', systemPrompt: PASS4_SYSTEM, userPrompt: PASS4_USER, maxTokens: 1500 },
+];
+
+// ============================================================
+// 合并函数
+// ============================================================
+
+export function mergeResults(photoId, fileName, passResults) {
+  const p1 = passResults[0]?.parsed || {};
+  const p2 = passResults[1]?.parsed || {};
+  const p3 = passResults[2]?.parsed || {};
+  const p4 = passResults[3]?.parsed || {};
+  const p5 = passResults[4]?.parsed || {};
+
+  const merged = {
+    version: 'v4-multipass',
+    photoId,
+    fileName,
+    analyzedAt: new Date().toISOString(),
+    passes: passResults.map((p, i) => ({
+      pass: i + 1,
+      name: p.name || `pass${i + 1}`,
+      status: p.error ? 'error' : 'ok',
+      error: p.error || null,
+      usage: p.usage || null,
+    })),
+    parsed: {
+      sceneDescription: p5.sceneDescription || '',
+      roomType: p5.roomType || '',
+      perspective: p1.perspective || { type: 'onePoint', description: '', vanishingPoints: [] },
+      surfaces: p1.surfaces || { walls: [], floorRegion: null, ceilingRegion: null },
+      openings: p3.openings || [],
+      ceilingFeatures: p2.ceilingFeatures || [],
+      corners: p1.corners || [],
+      obstacles: p4.obstacles || [],
+      measurementPlan: p5.measurementPlan || [],
+      issues: p5.issues || [],
+      photoQuality: p5.photoQuality || { isWideAngle: false, distortionLevel: 'unknown', recommendReshoot: false, reshootAdvice: '' },
+    },
+  };
+
+  // 质量验证:踢脚线高度检查
+  const suspiciousBaseboards = (merged.parsed.obstacles || []).filter(
+    o => o.type === 'baseboard' && o.boundingBox?.height > 10
+  );
+  if (suspiciousBaseboards.length > 0) {
+    console.log(`  ⚠ 发现 ${suspiciousBaseboards.length} 个异常踢脚线高度>10%:`);
+    suspiciousBaseboards.forEach(o => {
+      console.log(`    ${o.id}: height=${o.boundingBox.height}% (预计2-5%)`);
+    });
+  }
+
+  // 质量验证:吊顶特征顶点数检查
+  const complexCeilings = (merged.parsed.ceilingFeatures || []).filter(
+    cf => cf.polygon && cf.polygon.length > 4
+  );
+  if (complexCeilings.length > 0) {
+    console.log(`  ⚠ 发现 ${complexCeilings.length} 个吊顶特征顶点>4:`);
+    complexCeilings.forEach(cf => {
+      console.log(`    ${cf.id}: ${cf.polygon.length}点 (期望4点)`);
+    });
+  }
+
+  return merged;
+}
+
+// ============================================================
+// 主流程:处理单张照片
+// ============================================================
+
+/**
+ * 对单张毛坯房照片执行 5-pass 分析
+ *
+ * @param {string} imagePath  图片路径
+ * @param {string} photoId    照片 ID(用于缓存目录命名)
+ * @param {string} fileName   原始文件名
+ * @param {Object} [opts]
+ * @param {string} [opts.cacheDir]   缓存目录,默认 './output/v4/<photoId>'
+ * @param {string} [opts.model]      模型名
+ * @returns {Promise<Object>} 合并后的分析结果
+ */
+export async function processPhoto(imagePath, photoId, fileName, opts = {}) {
+  const cacheDir = opts.cacheDir || path.resolve('./output/v4', photoId);
+
+  // Pass 1-4: 视觉分析
+  const passResults = [];
+
+  for (const cfg of PASS_CONFIGS) {
+    const passNum = cfg.name === 'spatial' ? 1 : cfg.name === 'ceiling' ? 2 : cfg.name === 'openings' ? 3 : 4;
+    const cacheFile = path.join(cacheDir, `pass${passNum}.json`);
+
+    if (fs.existsSync(cacheFile)) {
+      console.log(`  Pass ${passNum} (${cfg.name}): 已有缓存,跳过`);
+      passResults.push(JSON.parse(fs.readFileSync(cacheFile, 'utf-8')));
+      continue;
+    }
+
+    console.log(`  Pass ${passNum} (${cfg.name}, ${cfg.maxTokens}t)...`);
+    try {
+      const result = await callVisionAPI({
+        imagePath,
+        systemPrompt: cfg.systemPrompt,
+        userPrompt: cfg.userPrompt,
+        maxTokens: cfg.maxTokens,
+        model: opts.model,
+      });
+      const entry = { pass: passNum, name: cfg.name, ...result };
+      if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
+      fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
+      passResults.push(entry);
+      console.log(`    ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
+    } catch (e) {
+      console.log(`    ✗ ${e.message}`);
+      const entry = { pass: passNum, name: cfg.name, error: e.message, parsed: null, usage: null };
+      if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
+      fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
+      passResults.push(entry);
+    }
+
+    await new Promise(r => setTimeout(r, 1500));
+  }
+
+  // Pass 5: 文本合并
+  const pass5File = path.join(cacheDir, 'pass5.json');
+  if (fs.existsSync(pass5File)) {
+    console.log('  Pass 5 (merge): 已有缓存,跳过');
+    passResults.push(JSON.parse(fs.readFileSync(pass5File, 'utf-8')));
+  } else {
+    console.log('  Pass 5 (merge, 2000t)...');
+    const p1Json = JSON.stringify(passResults[0]?.parsed || {}, null, 2);
+    const p2Json = JSON.stringify(passResults[1]?.parsed || {}, null, 2);
+    const p3Json = JSON.stringify(passResults[2]?.parsed || {}, null, 2);
+    const p4Json = JSON.stringify(passResults[3]?.parsed || {}, null, 2);
+    const mergePrompt = PASS5_USER_TEMPLATE
+      .replace('__PASS1__', p1Json)
+      .replace('__PASS2__', p2Json)
+      .replace('__PASS3__', p3Json)
+      .replace('__PASS4__', p4Json);
+
+    try {
+      const result = await callVisionAPI({
+        systemPrompt: PASS5_SYSTEM,
+        userPrompt: mergePrompt,
+        maxTokens: 2000,
+        model: opts.model,
+      });
+      const entry = { pass: 5, name: 'merge', ...result };
+      fs.writeFileSync(pass5File, JSON.stringify(entry, null, 2));
+      passResults.push(entry);
+      console.log(`    ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
+    } catch (e) {
+      console.log(`    ✗ ${e.message}`);
+      const entry = { pass: 5, name: 'merge', error: e.message, parsed: null, usage: null };
+      fs.writeFileSync(pass5File, JSON.stringify(entry, null, 2));
+      passResults.push(entry);
+    }
+  }
+
+  return mergeResults(photoId, fileName, passResults);
+}

+ 313 - 0
claude-code/fmode-vision/skills/fmode-vision/scripts/vision-client.mjs

@@ -0,0 +1,313 @@
+/**
+ * Fmode Vision API 通用客户端
+ *
+ * 功能:
+ *   - resolveApiToken()    三级优先级获取 API token
+ *   - callVisionAPI()      单轮视觉分析
+ *   - callMultiPass()      多轮聚焦分析(支持缓存)
+ *   - extractJSON()        从 LLM 响应提取 JSON
+ */
+
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+
+// ============================================================
+// Token 解析(与 voc / fmode-listen 共享层一致)
+// ============================================================
+//
+// 关键修复: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。
+
+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。取值优先级:
+ *   1. 环境变量 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(独立运行未被注入时)
+ *
+ * @param {string} [projectRoot] 项目根目录,默认 process.cwd()
+ * @returns {{ token: string, source: string }}
+ */
+export function resolveApiToken(projectRoot) {
+  // 1. 环境变量
+  if (process.env.FMODE_API_TOKEN) {
+    return { token: process.env.FMODE_API_TOKEN, source: 'env:FMODE_API_TOKEN' };
+  }
+
+  // 2. 用户级配置 ~/.fmode/config.json
+  const userConfigPath = path.join(os.homedir(), '.fmode', 'config.json');
+  const userToken = readTokenFromConfig(userConfigPath);
+  if (userToken) {
+    return { token: userToken, source: userConfigPath };
+  }
+
+  // 3. 项目级配置 <project>/.fmode/config.json
+  const root = projectRoot || process.cwd();
+  const projectConfigPath = path.join(root, '.fmode', 'config.json');
+  const projectToken = readTokenFromConfig(projectConfigPath);
+  if (projectToken) {
+    return { token: projectToken, source: projectConfigPath };
+  }
+
+  // 4. Claude Code 默认入口:进程注入的 ANTHROPIC_AUTH_TOKEN(sk-、base 指向 fmode)
+  const injected = pickFmodeAnthropicToken(process.env);
+  if (injected) {
+    return { token: injected, source: 'env:ANTHROPIC_AUTH_TOKEN' };
+  }
+
+  // 5. 兜底:直接读 ~/.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 raw = fs.readFileSync(configPath, 'utf-8').replace(/^\uFEFF/, '');
+    const cfg = JSON.parse(raw);
+    return cfg.fmodeApiToken || cfg.newapiToken || null;
+  } catch {
+    return null;
+  }
+}
+
+// ============================================================
+// API 调用
+// ============================================================
+
+const DEFAULT_CONFIG = {
+  apiBase: 'https://api.fmode.cn',
+  model: 'doubao-seed-2-0-pro-260215',
+  temperature: 0.12,
+  maxTokens: 2000,
+};
+
+/**
+ * 调用 Fmode Vision API
+ *
+ * @param {Object} opts
+ * @param {string} [opts.imagePath]    本地图片路径
+ * @param {string} [opts.imageBase64]  图片 base64 数据(与 imagePath 二选一)
+ * @param {string} [opts.imageUrl]     远程图片 URL
+ * @param {string} [opts.videoUrl]     视频 URL
+ * @param {string} opts.systemPrompt   系统提示词
+ * @param {string} opts.userPrompt     用户提示词
+ * @param {string} [opts.model]        模型名,默认 doubao-seed-2-0-pro-260215
+ * @param {number} [opts.temperature]  默认 0.12
+ * @param {number} [opts.maxTokens]    默认 2000
+ * @param {string} [opts.apiToken]     手动传入 token,否则自动解析
+ * @returns {Promise<{ raw: string, parsed: object|null, error: string|null, usage: object|null }>}
+ */
+export async function callVisionAPI(opts) {
+  const {
+    imagePath, imageBase64, imageUrl, videoUrl,
+    systemPrompt, userPrompt,
+    model, temperature, maxTokens, apiToken,
+  } = opts;
+
+  const token = apiToken || resolveApiToken().token;
+  const messages = [{ role: 'system', content: systemPrompt }];
+
+  const userContent = [{ type: 'text', text: userPrompt }];
+
+  // 视觉内容
+  if (imagePath) {
+    const buffer = fs.readFileSync(imagePath);
+    const ext = path.extname(imagePath).slice(1).toLowerCase();
+    const mime = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
+    const b64 = buffer.toString('base64');
+    userContent.push({
+      type: 'image_url',
+      image_url: { url: `data:${mime};base64,${b64}` },
+    });
+  } else if (imageBase64) {
+    userContent.push({
+      type: 'image_url',
+      image_url: { url: imageBase64 },
+    });
+  } else if (imageUrl) {
+    userContent.push({
+      type: 'image_url',
+      image_url: { url: imageUrl },
+    });
+  } else if (videoUrl) {
+    userContent.push({
+      type: 'video_url',
+      video_url: { url: videoUrl },
+    });
+  }
+
+  messages.push({ role: 'user', content: userContent });
+
+  const body = {
+    model: model || DEFAULT_CONFIG.model,
+    messages,
+    temperature: temperature ?? DEFAULT_CONFIG.temperature,
+    max_tokens: maxTokens || DEFAULT_CONFIG.maxTokens,
+  };
+
+  const res = await fetch(`${DEFAULT_CONFIG.apiBase}/v1/chat/completions`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${token}`,
+    },
+    body: JSON.stringify(body),
+  });
+
+  if (!res.ok) {
+    const errText = await res.text();
+    throw new Error(`API ${res.status}: ${errText}`);
+  }
+
+  const data = await res.json();
+  const content = data.choices?.[0]?.message?.content || '';
+
+  const { parsed, error } = extractJSON(content);
+
+  return { raw: content, parsed, error, usage: data.usage || null };
+}
+
+// ============================================================
+// JSON 提取
+// ============================================================
+
+/**
+ * 从 LLM 响应中提取 JSON 对象
+ * 容忍 markdown 代码块包裹、前后文字
+ */
+export function extractJSON(rawContent) {
+  const m = rawContent.match(/\{[\s\S]*\}/);
+  if (!m) return { parsed: null, error: 'No JSON object in response' };
+
+  try {
+    return { parsed: JSON.parse(m[0]), error: null };
+  } catch (e) {
+    return { parsed: null, error: e.message };
+  }
+}
+
+// ============================================================
+// 多轮分析
+// ============================================================
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+/**
+ * 多轮聚焦分析
+ * 每轮独立调用 API,中间结果写入缓存目录。已有缓存则跳过。
+ *
+ * @param {Object} opts
+ * @param {string} opts.imagePath       图片路径
+ * @param {Array}  opts.passes           轮次配置数组
+ *   [{ name: string, systemPrompt: string, userPrompt: string, maxTokens?: number }]
+ * @param {string} opts.cacheDir         缓存目录
+ * @param {string} [opts.model]          模型名
+ * @param {number} [opts.delayMs=1500]   轮次间延迟
+ * @returns {Promise<Array<{ pass: number, name: string, raw: string, parsed: object|null, error: string|null, usage: object|null }>>}
+ */
+export async function callMultiPass(opts) {
+  const { imagePath, passes, cacheDir, model, delayMs = 1500 } = opts;
+
+  if (!fs.existsSync(cacheDir)) {
+    fs.mkdirSync(cacheDir, { recursive: true });
+  }
+
+  const results = [];
+
+  for (let i = 0; i < passes.length; i++) {
+    const p = passes[i];
+    const passNum = i + 1;
+    const cacheFile = path.join(cacheDir, `pass${passNum}.json`);
+
+    // 检查缓存
+    if (fs.existsSync(cacheFile)) {
+      console.log(`  Pass ${passNum} (${p.name}): 已有缓存,跳过`);
+      results.push(JSON.parse(fs.readFileSync(cacheFile, 'utf-8')));
+      continue;
+    }
+
+    console.log(`  Pass ${passNum} (${p.name}, ${p.maxTokens || 2000}t)...`);
+    try {
+      const result = await callVisionAPI({
+        imagePath,
+        systemPrompt: p.systemPrompt,
+        userPrompt: p.userPrompt,
+        maxTokens: p.maxTokens,
+        model,
+      });
+      const entry = { pass: passNum, name: p.name, ...result };
+      fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
+      results.push(entry);
+      console.log(`    ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
+    } catch (e) {
+      console.log(`    ✗ ${e.message}`);
+      const entry = { pass: passNum, name: p.name, error: e.message, parsed: null, usage: null };
+      fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
+      results.push(entry);
+    }
+
+    if (i < passes.length - 1) await sleep(delayMs);
+  }
+
+  return results;
+}