#!/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); }