| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- #!/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 <dir> [--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/<name>」布局不同,产物清单以任务书为准)。
- 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/<runId>/case-package.json',
- ' npx skill-case-get@latest reorder -- --material-assets <file> --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 <dir> [--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 <dir> 安装到自定义目录',
- ' --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); }
|