|
|
@@ -1,131 +1,132 @@
|
|
|
#!/usr/bin/env node
|
|
|
-import { spawnSync } from "node:child_process";
|
|
|
-import fs from "node:fs";
|
|
|
-import os from "node:os";
|
|
|
-import path from "node:path";
|
|
|
-import { fileURLToPath } from "node:url";
|
|
|
+import fs from 'node:fs';
|
|
|
+import os from 'node:os';
|
|
|
+import path from 'node:path';
|
|
|
+import { spawnSync } from 'node:child_process';
|
|
|
+import { fileURLToPath } from 'node:url';
|
|
|
+import { checkPackage } from '../install.js';
|
|
|
|
|
|
-const SOURCE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
-const INVOCATION_ROOT = path.resolve(process.cwd());
|
|
|
-const invocationRelativeToSource = path.relative(SOURCE_ROOT, INVOCATION_ROOT);
|
|
|
-const invokedFromSource = invocationRelativeToSource === ""
|
|
|
- || (!invocationRelativeToSource.startsWith("..") && !path.isAbsolute(invocationRelativeToSource));
|
|
|
-const WORKSPACE_ROOT = invokedFromSource ? path.dirname(SOURCE_ROOT) : INVOCATION_ROOT;
|
|
|
-const DEFAULT_TARGET = path.join(os.homedir(), ".claude", "plugins", "fmode-image-set");
|
|
|
-const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, ".claude", "skills", "fmode-image-set");
|
|
|
-const EXCLUDED = new Set(["node_modules", "生成结果", "memory", "outputs", ".tmp", ".git", ".idea", ".env", ".env.local"]);
|
|
|
-EXCLUDED.add(".claude");
|
|
|
-EXCLUDED.add(".codex");
|
|
|
+const sourceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
+const exclusions = new Set(['node_modules', '.git', '.env', '.env.local', '.claude', '.codex', 'outputs', 'output', '生成结果', 'memory', '.tmp']);
|
|
|
|
|
|
-function parseArgs(argv) {
|
|
|
- const command = argv[0] && !argv[0].startsWith("--") ? argv[0] : "install";
|
|
|
- const options = {
|
|
|
- command,
|
|
|
- target: command === "workspace" ? WORKSPACE_TARGET : DEFAULT_TARGET,
|
|
|
- workspace: command === "workspace",
|
|
|
- smoke: argv.includes("--smoke"),
|
|
|
- skipInstall: argv.includes("--skip-install"),
|
|
|
- force: argv.includes("--force"),
|
|
|
- };
|
|
|
- const targetIndex = argv.indexOf("--target");
|
|
|
- if (targetIndex >= 0 && argv[targetIndex + 1]) options.target = path.resolve(argv[targetIndex + 1]);
|
|
|
- return options;
|
|
|
+function inside(parent, child) {
|
|
|
+ const p = path.resolve(parent).toLowerCase();
|
|
|
+ const c = path.resolve(child).toLowerCase();
|
|
|
+ return c === p || c.startsWith(`${p}${path.sep}`);
|
|
|
+}
|
|
|
+
|
|
|
+function copyPackage(target, force) {
|
|
|
+ if (inside(sourceRoot, target)) throw new Error('安装目标不能位于源码包内部。');
|
|
|
+ if (fs.existsSync(target)) {
|
|
|
+ if (!force) throw new Error(`目标已存在:${target}。如需覆盖请传入 --force。`);
|
|
|
+ fs.rmSync(target, { recursive: true, force: true });
|
|
|
+ }
|
|
|
+ fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
|
+ copyTree(sourceRoot, target, true);
|
|
|
+ // v0.2 removed these providers. Explicit pruning prevents stale runtime files
|
|
|
+ // when upgrading from an older installation on filesystems with unusual copy behavior.
|
|
|
+ for (const legacy of ['qwen-vision.mjs', 'jimeng.mjs']) {
|
|
|
+ const legacyPath = path.join(target, 'mcp', 'src', 'providers', legacy);
|
|
|
+ fs.rmSync(legacyPath, { force: true });
|
|
|
+ if (fs.existsSync(legacyPath)) throw new Error(`无法清理旧 Provider,请先重启/关闭 Claude Code 后重试:${legacy}`);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-function isInside(parent, candidate) {
|
|
|
- const relative = path.relative(path.resolve(parent), path.resolve(candidate));
|
|
|
- return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
|
|
|
+function copyTree(source, target, isPackageCopy = false) {
|
|
|
+ const stat = fs.lstatSync(source);
|
|
|
+ if (stat.isSymbolicLink()) return;
|
|
|
+ if (stat.isDirectory()) {
|
|
|
+ fs.mkdirSync(target, { recursive: true });
|
|
|
+ for (const entry of fs.readdirSync(source)) {
|
|
|
+ if (isPackageCopy && (exclusions.has(entry) || entry.endsWith('.tgz') || entry.endsWith('.log'))) continue;
|
|
|
+ copyTree(path.join(source, entry), path.join(target, entry), isPackageCopy);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ fs.copyFileSync(source, target);
|
|
|
}
|
|
|
|
|
|
-function safeTarget(target, force) {
|
|
|
- return force
|
|
|
- || path.resolve(target) === path.resolve(DEFAULT_TARGET)
|
|
|
- || isInside(path.join(WORKSPACE_ROOT, ".claude", "plugins"), target)
|
|
|
- || isInside(path.join(WORKSPACE_ROOT, ".claude", "skills"), target);
|
|
|
+function readJsonMaybe(file) {
|
|
|
+ try { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')) : {}; }
|
|
|
+ catch { throw new Error(`JSON 无法解析:${file}`); }
|
|
|
}
|
|
|
|
|
|
-function copyPackage(source, target) {
|
|
|
- fs.mkdirSync(target, { recursive: true });
|
|
|
- for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
|
- if (EXCLUDED.has(entry.name) || entry.name.endsWith(".tgz")) continue;
|
|
|
- const from = path.join(source, entry.name);
|
|
|
- const to = path.join(target, entry.name);
|
|
|
- if (entry.isDirectory()) copyPackage(from, to);
|
|
|
- else if (entry.isFile()) fs.copyFileSync(from, to);
|
|
|
- }
|
|
|
+function writeMcp(workspace, installedRoot) {
|
|
|
+ const mcpFile = path.join(workspace, '.mcp.json');
|
|
|
+ const current = readJsonMaybe(mcpFile);
|
|
|
+ current.mcpServers = current.mcpServers || {};
|
|
|
+ current.mcpServers['fmode-image-set'] = {
|
|
|
+ command: 'node', args: [path.join(installedRoot, 'mcp', 'src', 'server.mjs')], cwd: installedRoot,
|
|
|
+ env: { FMODE_WORKSPACE_ROOT: workspace }
|
|
|
+ };
|
|
|
+ fs.writeFileSync(mcpFile, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
|
|
|
+ JSON.parse(fs.readFileSync(mcpFile, 'utf8'));
|
|
|
+ fs.writeFileSync(path.join(installedRoot, '.mcp.json'), `${JSON.stringify({ mcpServers: { 'fmode-image-set': current.mcpServers['fmode-image-set'] } }, null, 2)}\n`, 'utf8');
|
|
|
}
|
|
|
|
|
|
-function run(command, args, cwd) {
|
|
|
- const useCmd = process.platform === "win32" && command === "npm";
|
|
|
- const executable = useCmd ? "cmd.exe" : command;
|
|
|
- const finalArgs = useCmd ? ["/d", "/s", "/c", "npm", ...args] : args;
|
|
|
- const child = spawnSync(executable, finalArgs, { cwd, stdio: "inherit", encoding: "utf8" });
|
|
|
- if (child.status !== 0) throw new Error(`${command} failed with exit ${child.status}`);
|
|
|
+function exposeWorkspaceSkills(workspace, installedRoot, force) {
|
|
|
+ const manifest = readJsonMaybe(path.join(installedRoot, 'skill-package-manifest.json'));
|
|
|
+ for (const name of manifest.skills) {
|
|
|
+ const source = path.join(installedRoot, 'skills', name);
|
|
|
+ const target = path.join(workspace, '.claude', 'skills', name);
|
|
|
+ if (fs.existsSync(target)) {
|
|
|
+ if (!force) throw new Error(`Skill 已存在:${target}。使用 --force 覆盖。`);
|
|
|
+ fs.rmSync(target, { recursive: true, force: true });
|
|
|
+ }
|
|
|
+ fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
|
+ copyTree(source, target, false);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-function readJsonMaybe(filePath) {
|
|
|
- if (!fs.existsSync(filePath)) return {};
|
|
|
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
|
+function runNode(args, cwd) {
|
|
|
+ const result = spawnSync(process.execPath, args, { cwd, stdio: 'inherit' });
|
|
|
+ if (result.status !== 0) throw new Error(`命令失败:node ${args.join(' ')}`);
|
|
|
}
|
|
|
|
|
|
-function writeWorkspaceActivation(target) {
|
|
|
- const configPath = path.join(WORKSPACE_ROOT, ".mcp.json");
|
|
|
- const existing = readJsonMaybe(configPath);
|
|
|
- const config = {
|
|
|
- ...existing,
|
|
|
- mcpServers: {
|
|
|
- ...(existing.mcpServers || {}),
|
|
|
- "fmode-image-set": {
|
|
|
- command: "node",
|
|
|
- args: [path.join(target, "mcp", "src", "server.mjs")],
|
|
|
- cwd: target,
|
|
|
- },
|
|
|
- },
|
|
|
- };
|
|
|
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
|
+function installDependencies(target, skipInstall) {
|
|
|
+ if (skipInstall) return;
|
|
|
+ const result = spawnSync('npm', ['ci', '--omit=dev'], { cwd: target, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
|
+ if (result.status !== 0) throw new Error('依赖安装失败。');
|
|
|
+}
|
|
|
|
|
|
- const packagedSkill = path.join(target, "skills", "fmode-image-set");
|
|
|
- copyPackage(packagedSkill, target);
|
|
|
+function parseFlags(args) {
|
|
|
+ return { force: args.includes('--force'), smoke: args.includes('--smoke'), skipInstall: args.includes('--skip-install') };
|
|
|
}
|
|
|
|
|
|
-function install(options) {
|
|
|
- if (fs.existsSync(options.target)) {
|
|
|
- if (!safeTarget(options.target, options.force)) {
|
|
|
- throw new Error(`Target exists; use --force only for an intentional custom replacement: ${options.target}`);
|
|
|
- }
|
|
|
- fs.rmSync(options.target, { recursive: true, force: true });
|
|
|
+async function workspaceInstall(args) {
|
|
|
+ const flags = parseFlags(args);
|
|
|
+ const workspace = process.cwd();
|
|
|
+ const target = path.join(workspace, '.claude', 'plugins', 'fmode-image-set');
|
|
|
+ copyPackage(target, flags.force);
|
|
|
+ installDependencies(target, flags.skipInstall);
|
|
|
+ exposeWorkspaceSkills(workspace, target, flags.force);
|
|
|
+ writeMcp(workspace, target);
|
|
|
+ if (flags.smoke) {
|
|
|
+ runNode(['scripts/smoke-package.mjs'], target);
|
|
|
+ runNode(['scripts/smoke-mcp.mjs'], target);
|
|
|
}
|
|
|
- copyPackage(SOURCE_ROOT, options.target);
|
|
|
- if (options.workspace) writeWorkspaceActivation(options.target);
|
|
|
- if (!options.skipInstall) run("npm", ["install", "--omit=dev", "--ignore-scripts"], options.target);
|
|
|
- run(process.execPath, ["install.js", options.smoke ? "--smoke" : "--check", "--skip-install"], options.target);
|
|
|
- console.log(`Installed Fmode Image Set: ${options.target}`);
|
|
|
- if (options.workspace) console.log("Restart the Claude Code session so it can discover the new Skill and MCP tools.");
|
|
|
+ console.log(`Workspace installed: ${target}`);
|
|
|
+ console.log('Restart the Claude Code session.');
|
|
|
}
|
|
|
|
|
|
-function main() {
|
|
|
- const options = parseArgs(process.argv.slice(2));
|
|
|
- if (options.command === "install" || options.command === "workspace") return install(options);
|
|
|
- if (options.command === "check") return run(process.execPath, ["install.js", "--check"], SOURCE_ROOT);
|
|
|
- if (options.command === "smoke") return run(process.execPath, ["install.js", "--smoke", "--skip-install"], SOURCE_ROOT);
|
|
|
- if (options.command === "path") return console.log(options.target);
|
|
|
- if (options.command === "run") {
|
|
|
- return run(process.execPath, ["mcp/src/tools/image-set-run.mjs", ...process.argv.slice(3)], SOURCE_ROOT);
|
|
|
- }
|
|
|
- console.log([
|
|
|
- "Usage:",
|
|
|
- " fmode-image-set install [--smoke]",
|
|
|
- " fmode-image-set workspace [--smoke]",
|
|
|
- " fmode-image-set check",
|
|
|
- " fmode-image-set smoke",
|
|
|
- " fmode-image-set run <start|execute|resume|status> [options]",
|
|
|
- " fmode-image-set path",
|
|
|
- ].join("\n"));
|
|
|
+async function globalInstall(args) {
|
|
|
+ const flags = parseFlags(args);
|
|
|
+ const target = path.join(os.homedir(), '.claude', 'plugins', 'fmode-image-set');
|
|
|
+ copyPackage(target, flags.force);
|
|
|
+ installDependencies(target, flags.skipInstall);
|
|
|
+ writeMcp(target, target);
|
|
|
+ if (flags.smoke) runNode(['scripts/acceptance.mjs'], target);
|
|
|
+ console.log(`Global plugin installed: ${target}`);
|
|
|
}
|
|
|
|
|
|
-try {
|
|
|
- main();
|
|
|
-} catch (error) {
|
|
|
- console.error(`fmode-image-set failed: ${error.message}`);
|
|
|
- process.exit(1);
|
|
|
+async function main() {
|
|
|
+ const [command = 'help', ...args] = process.argv.slice(2);
|
|
|
+ if (command === 'workspace') return workspaceInstall(args);
|
|
|
+ if (command === 'install') return globalInstall(args);
|
|
|
+ if (command === 'check') { const result = checkPackage(sourceRoot); console.log(JSON.stringify(result, null, 2)); process.exitCode = result.ok ? 0 : 1; return; }
|
|
|
+ if (command === 'smoke') { runNode(['scripts/acceptance.mjs'], sourceRoot); return; }
|
|
|
+ if (command === 'path') { console.log(sourceRoot); return; }
|
|
|
+ console.log('Usage: fmode-image-set <workspace|install|check|smoke|path> [--force] [--smoke] [--skip-install]');
|
|
|
}
|
|
|
+
|
|
|
+main().catch(error => { console.error(error.message); process.exit(1); });
|