skill-case-get.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. #!/usr/bin/env node
  2. // Copyright (c) 未来飞马
  3. //
  4. // This Source Code Form is subject to the terms of the Mozilla Public
  5. // License, v. 2.0. If a copy of the MPL was not distributed with this
  6. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  7. //
  8. // Trademark Notice:
  9. // The MPL-2.0 license grants copyright permissions for source code only.
  10. // It does NOT grant any rights to use trademarks including "未来飞马",
  11. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  12. // Any use of these trademarks requires separate written permission.
  13. /**
  14. * skill-case-get 安装器 + 运行器透传
  15. *
  16. * skill-case-get workspace [--smoke] 安装到 ./.claude/skills/skill-case-get
  17. * skill-case-get install [--smoke] 安装到 ~/.claude/skills/skill-case-get
  18. * skill-case-get install --target <dir> [--force]
  19. * skill-case-get check | path | smoke | selftest
  20. * skill-case-get ingest [参数...] 透传给 scripts/case-intake.mjs
  21. * skill-case-get plan|submit|reorder|dictionary [...]
  22. */
  23. const fs = require('fs');
  24. const os = require('os');
  25. const path = require('path');
  26. const { spawnSync } = require('child_process');
  27. const SKILL_NAME = 'skill-case-get';
  28. // 本包就是技能本体:skills/skill-case-get/ 同时是 npm 包根与技能目录
  29. // (与 skill-listen 的「包根 / 内含 skills/<name>」布局不同,产物清单以任务书为准)。
  30. const SKILL_SOURCE = path.resolve(__dirname, '..');
  31. const SOURCE_ROOT = SKILL_SOURCE;
  32. const RUNNER = path.join(SKILL_SOURCE, 'scripts', 'case-intake.mjs');
  33. const WORKSPACE_ROOT = process.cwd();
  34. const GLOBAL_TARGET = path.join(os.homedir(), '.claude', 'skills', SKILL_NAME);
  35. const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.claude', 'skills', SKILL_NAME);
  36. const WORKSPACE_SKILLS_ROOT = path.join(WORKSPACE_ROOT, '.claude', 'skills');
  37. const GLOBAL_SKILLS_ROOT = path.join(os.homedir(), '.claude', 'skills');
  38. // 透传给 case-intake.mjs 的子命令
  39. const RUNNER_COMMANDS = new Set(['ingest', 'plan', 'submit', 'reorder', 'dictionary', 'selftest']);
  40. const REQUIRED_ENTRIES = [
  41. 'SKILL.md',
  42. 'README.md',
  43. 'package.json',
  44. 'skill-package-manifest.json',
  45. 'references/tag-dictionary.json',
  46. 'scripts/case-intake.mjs',
  47. 'scripts/office_extract.py',
  48. 'scripts/vision-bridge.mjs',
  49. 'scripts/listen-bridge.mjs',
  50. 'scripts/cloud-client.mjs',
  51. 'scripts/grouping.mjs',
  52. 'scripts/classify.mjs',
  53. 'scripts/image-size.mjs',
  54. 'scripts/lib.mjs',
  55. ];
  56. function expandHome(value) {
  57. return String(value || '').replace(/^~(?=$|[\\/])/, os.homedir());
  58. }
  59. // ---------------------------------------------------------------------------
  60. // 运行器透传
  61. // ---------------------------------------------------------------------------
  62. function runRunner(passthrough) {
  63. const result = spawnSync(process.execPath, [RUNNER, ...passthrough], { stdio: 'inherit', shell: false });
  64. if (result.error) {
  65. console.error(`skill-case-get: 无法启动运行器:${result.error.message}`);
  66. process.exit(1);
  67. }
  68. process.exit(result.status == null ? 1 : result.status);
  69. }
  70. function passthroughArgs(argv) {
  71. const rest = argv.slice(1);
  72. if (rest[0] === '--') return rest.slice(1);
  73. return rest;
  74. }
  75. // ---------------------------------------------------------------------------
  76. // 子命令
  77. // ---------------------------------------------------------------------------
  78. function parseArgs(argv) {
  79. const first = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
  80. const args = { command: first, target: GLOBAL_TARGET, smoke: false, force: false, help: false };
  81. if (first === 'workspace' || first === 'install-workspace') {
  82. args.command = 'install';
  83. args.target = WORKSPACE_TARGET;
  84. }
  85. for (let i = first === argv[0] ? 1 : 0; i < argv.length; i++) {
  86. const token = argv[i];
  87. if (token === '--target' && argv[i + 1]) args.target = argv[++i];
  88. else if (token.startsWith('--target=')) args.target = token.slice('--target='.length);
  89. else if (token === '--workspace') args.target = WORKSPACE_TARGET;
  90. else if (token === '--global') args.target = GLOBAL_TARGET;
  91. else if (token === '--smoke') args.smoke = true;
  92. else if (token === '--force') args.force = true;
  93. else if (token === '--help' || token === '-h') args.help = true;
  94. }
  95. args.target = path.resolve(expandHome(args.target));
  96. return args;
  97. }
  98. function printHelp() {
  99. console.log([
  100. 'skill-case-get — 案例采集 / 拆解 / 归档技能 + FmodeCode / Claude Code 安装器',
  101. '',
  102. '把素材(连续多图 / Word / PPT / 图文混合 / 裸图 / 视频)拆解成案例:',
  103. ' npx skill-case-get@latest ingest -- --image a.jpg --image b.jpg --authorization authorized',
  104. ' npx skill-case-get@latest plan -- --input ./素材目录',
  105. ' npx skill-case-get@latest submit -- --package case-get-outputs/<runId>/case-package.json',
  106. ' npx skill-case-get@latest reorder -- --material-assets <file> --move "3:1"',
  107. ' npx skill-case-get@latest selftest',
  108. '',
  109. '安装 Skill:',
  110. ' npx skill-case-get@latest workspace [--smoke] # 安装到 ./.claude/skills/skill-case-get',
  111. ' npx skill-case-get@latest install [--smoke] # 安装到 ~/.claude/skills/skill-case-get',
  112. ' npx skill-case-get@latest install --target <dir> [--force]',
  113. ' npx skill-case-get@latest check',
  114. ' npx skill-case-get@latest path',
  115. '',
  116. 'Options:',
  117. ' --workspace 安装到 ./.claude/skills/skill-case-get',
  118. ' --global 安装到 ~/.claude/skills/skill-case-get(默认)',
  119. ' --target <dir> 安装到自定义目录',
  120. ' --force 允许覆盖自定义目录',
  121. ' --smoke 安装后运行冒烟检查',
  122. ' --help, -h 显示帮助',
  123. '',
  124. '依赖:python3(.docx/.pptx 解包,仅标准库);可选 ffmpeg(视频抽帧)。',
  125. '图片理解复用 skill-vision,音视频转写复用 skill-listen——未安装时会在 warnings 里提示,不伪造结果。',
  126. ].join('\n'));
  127. }
  128. function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); }
  129. function isInside(parentDir, childDir) {
  130. const relative = path.relative(path.resolve(parentDir), path.resolve(childDir));
  131. return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
  132. }
  133. function canOverwriteTarget(targetDir, force) {
  134. return force
  135. || path.resolve(targetDir) === path.resolve(GLOBAL_TARGET)
  136. || isInside(WORKSPACE_SKILLS_ROOT, targetDir)
  137. || isInside(GLOBAL_SKILLS_ROOT, targetDir);
  138. }
  139. const SKIP_DIR_NAMES = new Set(['node_modules', 'outputs', '.git', '.claude']);
  140. function copyDirRecursive(source, destination, guard) {
  141. const stat = fs.statSync(source);
  142. if (stat.isDirectory()) {
  143. ensureDir(destination);
  144. for (const child of fs.readdirSync(source)) {
  145. // outputs 是运行产物;.claude 是安装目标所在;node_modules / .git 永远不拷
  146. if (SKIP_DIR_NAMES.has(child)) continue;
  147. const childSource = path.join(source, child);
  148. // 目标目录恰好长在源目录里(--target 指到源内)时跳过,否则会自己拷自己直到路径超长
  149. if (guard) {
  150. let childReal = '';
  151. try { childReal = fs.realpathSync(childSource); } catch { childReal = childSource; }
  152. if (childReal === guard || isInside(childReal, guard)) continue;
  153. }
  154. copyDirRecursive(childSource, path.join(destination, child), guard);
  155. }
  156. return;
  157. }
  158. ensureDir(path.dirname(destination));
  159. fs.copyFileSync(source, destination);
  160. // 保留可执行位(.py / .mjs 直接跑)
  161. try {
  162. const mode = fs.statSync(source).mode;
  163. if (mode & 0o111) fs.chmodSync(destination, mode);
  164. } catch { /* 某些文件系统不支持,忽略 */ }
  165. }
  166. function installSkill(target, force) {
  167. if (!fs.existsSync(SKILL_SOURCE)) {
  168. throw new Error(`Skill 源目录不存在:${SKILL_SOURCE}`);
  169. }
  170. const resolvedTarget = path.resolve(target);
  171. if (path.resolve(SKILL_SOURCE) === resolvedTarget) {
  172. throw new Error('安装目标与源目录相同,无需安装');
  173. }
  174. if (fs.existsSync(target)) {
  175. if (!canOverwriteTarget(target, force)) {
  176. throw new Error(`拒绝覆盖自定义目录(需要 --force):${target}`);
  177. }
  178. fs.rmSync(target, { recursive: true, force: true });
  179. }
  180. ensureDir(target);
  181. let guard = resolvedTarget;
  182. try { guard = fs.realpathSync(resolvedTarget); } catch { guard = resolvedTarget; }
  183. copyDirRecursive(SKILL_SOURCE, target, guard);
  184. }
  185. function checkSkill(target) {
  186. const missing = REQUIRED_ENTRIES.filter((entry) => !fs.existsSync(path.join(target, entry)));
  187. if (missing.length) {
  188. throw new Error(`安装目标缺少必需文件:${missing.join(', ')}`);
  189. }
  190. // 标签字典必须是合法 JSON,否则技能跑不起来
  191. const dictPath = path.join(target, 'references', 'tag-dictionary.json');
  192. const dict = JSON.parse(fs.readFileSync(dictPath, 'utf8'));
  193. if (!dict.dimensions) throw new Error('标签字典缺少 dimensions');
  194. return {
  195. status: 'ok',
  196. skill: SKILL_NAME,
  197. target,
  198. required: REQUIRED_ENTRIES,
  199. dimensions: Object.keys(dict.dimensions).length,
  200. };
  201. }
  202. function runSmoke() {
  203. const result = spawnSync(process.execPath, ['scripts/smoke.mjs'], { cwd: SOURCE_ROOT, stdio: 'inherit', shell: false });
  204. if (result.status !== 0) throw new Error('smoke 未通过');
  205. }
  206. function printNextSteps(target) {
  207. const workspaceMode = isInside(WORKSPACE_SKILLS_ROOT, target);
  208. console.log('');
  209. console.log('安装完成。');
  210. console.log(`Skill 位置:${target}`);
  211. console.log('');
  212. if (workspaceMode) {
  213. console.log('项目级技能已就绪。若当前 VSCode FmodeCode / Claude Code 会话是开着的,重启一次。');
  214. } else {
  215. console.log('用户级技能已就绪,对所有 FmodeCode / Claude Code 工作区生效。');
  216. }
  217. console.log('');
  218. console.log('在 FmodeCode / Claude Code 里可以直接这样说:');
  219. console.log(' 这 9 张图是同一个案例素材,帮我拆解、打标,然后归档到待审清单。');
  220. console.log('');
  221. console.log('提醒:无授权(authorizationStatus != authorized)一律不入库;新案例先进待审清单,不进公共素材库。');
  222. }
  223. function main() {
  224. const argv = process.argv.slice(2);
  225. const command = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
  226. // 运行器透传放在前面:这些子命令的参数要原样交给 case-intake.mjs
  227. if (RUNNER_COMMANDS.has(command)) {
  228. runRunner(passthroughArgs(argv));
  229. return;
  230. }
  231. const args = parseArgs(argv);
  232. if (args.help || args.command === 'help') { printHelp(); return; }
  233. if (args.command === 'path') { console.log(args.target); return; }
  234. if (args.command === 'install') {
  235. installSkill(args.target, args.force);
  236. console.log(JSON.stringify(checkSkill(args.target), null, 2));
  237. if (args.smoke) runSmoke();
  238. printNextSteps(args.target);
  239. return;
  240. }
  241. if (args.command === 'check') { console.log(JSON.stringify(checkSkill(args.target), null, 2)); return; }
  242. if (args.command === 'smoke') { runSmoke(); return; }
  243. printHelp();
  244. process.exitCode = 1;
  245. }
  246. try { main(); }
  247. catch (error) { console.error(`skill-case-get 失败:${error.message}`); process.exit(1); }