#!/usr/bin/env node // Copyright (c) 未来飞马 // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // Trademark Notice: // The MPL-2.0 license grants copyright permissions for source code only. // It does NOT grant any rights to use trademarks including "未来飞马", // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步". // Any use of these trademarks requires separate written permission. /** * skill-case-get 安装器 + 运行器透传 * * skill-case-get workspace [--smoke] 安装到 ./.claude/skills/skill-case-get * skill-case-get install [--smoke] 安装到 ~/.claude/skills/skill-case-get * skill-case-get install --target [--force] * skill-case-get check | path | smoke | selftest * skill-case-get ingest [参数...] 透传给 scripts/case-intake.mjs * skill-case-get plan|submit|reorder|dictionary [...] */ const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); const SKILL_NAME = 'skill-case-get'; // 本包就是技能本体:skills/skill-case-get/ 同时是 npm 包根与技能目录 // (与 skill-listen 的「包根 / 内含 skills/」布局不同,产物清单以任务书为准)。 const SKILL_SOURCE = path.resolve(__dirname, '..'); const SOURCE_ROOT = SKILL_SOURCE; const RUNNER = path.join(SKILL_SOURCE, 'scripts', 'case-intake.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'); // 透传给 case-intake.mjs 的子命令 const RUNNER_COMMANDS = new Set(['ingest', 'plan', 'submit', 'reorder', 'dictionary', 'selftest']); const REQUIRED_ENTRIES = [ 'SKILL.md', 'README.md', 'package.json', 'skill-package-manifest.json', 'references/tag-dictionary.json', 'scripts/case-intake.mjs', 'scripts/office_extract.py', 'scripts/vision-bridge.mjs', 'scripts/listen-bridge.mjs', 'scripts/cloud-client.mjs', 'scripts/grouping.mjs', 'scripts/classify.mjs', 'scripts/image-size.mjs', 'scripts/lib.mjs', ]; function expandHome(value) { return String(value || '').replace(/^~(?=$|[\\/])/, os.homedir()); } // --------------------------------------------------------------------------- // 运行器透传 // --------------------------------------------------------------------------- function runRunner(passthrough) { const result = spawnSync(process.execPath, [RUNNER, ...passthrough], { stdio: 'inherit', shell: false }); if (result.error) { console.error(`skill-case-get: 无法启动运行器:${result.error.message}`); process.exit(1); } process.exit(result.status == null ? 1 : result.status); } function passthroughArgs(argv) { const rest = argv.slice(1); if (rest[0] === '--') return rest.slice(1); return rest; } // --------------------------------------------------------------------------- // 子命令 // --------------------------------------------------------------------------- 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([ 'skill-case-get — 案例采集 / 拆解 / 归档技能 + FmodeCode / Claude Code 安装器', '', '把素材(连续多图 / Word / PPT / 图文混合 / 裸图 / 视频)拆解成案例:', ' npx skill-case-get@latest ingest -- --image a.jpg --image b.jpg --authorization authorized', ' npx skill-case-get@latest plan -- --input ./素材目录', ' npx skill-case-get@latest submit -- --package case-get-outputs//case-package.json', ' npx skill-case-get@latest reorder -- --material-assets --move "3:1"', ' npx skill-case-get@latest selftest', '', '安装 Skill:', ' npx skill-case-get@latest workspace [--smoke] # 安装到 ./.claude/skills/skill-case-get', ' npx skill-case-get@latest install [--smoke] # 安装到 ~/.claude/skills/skill-case-get', ' npx skill-case-get@latest install --target [--force]', ' npx skill-case-get@latest check', ' npx skill-case-get@latest path', '', 'Options:', ' --workspace 安装到 ./.claude/skills/skill-case-get', ' --global 安装到 ~/.claude/skills/skill-case-get(默认)', ' --target 安装到自定义目录', ' --force 允许覆盖自定义目录', ' --smoke 安装后运行冒烟检查', ' --help, -h 显示帮助', '', '依赖:python3(.docx/.pptx 解包,仅标准库);可选 ffmpeg(视频抽帧)。', '图片理解复用 skill-vision,音视频转写复用 skill-listen——未安装时会在 warnings 里提示,不伪造结果。', ].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); } const SKIP_DIR_NAMES = new Set(['node_modules', 'outputs', '.git', '.claude']); function copyDirRecursive(source, destination, guard) { const stat = fs.statSync(source); if (stat.isDirectory()) { ensureDir(destination); for (const child of fs.readdirSync(source)) { // outputs 是运行产物;.claude 是安装目标所在;node_modules / .git 永远不拷 if (SKIP_DIR_NAMES.has(child)) continue; const childSource = path.join(source, child); // 目标目录恰好长在源目录里(--target 指到源内)时跳过,否则会自己拷自己直到路径超长 if (guard) { let childReal = ''; try { childReal = fs.realpathSync(childSource); } catch { childReal = childSource; } if (childReal === guard || isInside(childReal, guard)) continue; } copyDirRecursive(childSource, path.join(destination, child), guard); } return; } ensureDir(path.dirname(destination)); fs.copyFileSync(source, destination); // 保留可执行位(.py / .mjs 直接跑) try { const mode = fs.statSync(source).mode; if (mode & 0o111) fs.chmodSync(destination, mode); } catch { /* 某些文件系统不支持,忽略 */ } } function installSkill(target, force) { if (!fs.existsSync(SKILL_SOURCE)) { throw new Error(`Skill 源目录不存在:${SKILL_SOURCE}`); } const resolvedTarget = path.resolve(target); if (path.resolve(SKILL_SOURCE) === resolvedTarget) { throw new Error('安装目标与源目录相同,无需安装'); } if (fs.existsSync(target)) { if (!canOverwriteTarget(target, force)) { throw new Error(`拒绝覆盖自定义目录(需要 --force):${target}`); } fs.rmSync(target, { recursive: true, force: true }); } ensureDir(target); let guard = resolvedTarget; try { guard = fs.realpathSync(resolvedTarget); } catch { guard = resolvedTarget; } copyDirRecursive(SKILL_SOURCE, target, guard); } function checkSkill(target) { const missing = REQUIRED_ENTRIES.filter((entry) => !fs.existsSync(path.join(target, entry))); if (missing.length) { throw new Error(`安装目标缺少必需文件:${missing.join(', ')}`); } // 标签字典必须是合法 JSON,否则技能跑不起来 const dictPath = path.join(target, 'references', 'tag-dictionary.json'); const dict = JSON.parse(fs.readFileSync(dictPath, 'utf8')); if (!dict.dimensions) throw new Error('标签字典缺少 dimensions'); return { status: 'ok', skill: SKILL_NAME, target, required: REQUIRED_ENTRIES, dimensions: Object.keys(dict.dimensions).length, }; } function runSmoke() { const result = spawnSync(process.execPath, ['scripts/smoke.mjs'], { cwd: SOURCE_ROOT, stdio: 'inherit', shell: false }); if (result.status !== 0) throw new Error('smoke 未通过'); } function printNextSteps(target) { const workspaceMode = isInside(WORKSPACE_SKILLS_ROOT, target); console.log(''); console.log('安装完成。'); console.log(`Skill 位置:${target}`); console.log(''); if (workspaceMode) { console.log('项目级技能已就绪。若当前 VSCode FmodeCode / Claude Code 会话是开着的,重启一次。'); } else { console.log('用户级技能已就绪,对所有 FmodeCode / Claude Code 工作区生效。'); } console.log(''); console.log('在 FmodeCode / Claude Code 里可以直接这样说:'); console.log(' 这 9 张图是同一个案例素材,帮我拆解、打标,然后归档到待审清单。'); console.log(''); console.log('提醒:无授权(authorizationStatus != authorized)一律不入库;新案例先进待审清单,不进公共素材库。'); } function main() { const argv = process.argv.slice(2); const command = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install'; // 运行器透传放在前面:这些子命令的参数要原样交给 case-intake.mjs 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(`skill-case-get 失败:${error.message}`); process.exit(1); }