#!/usr/bin/env node 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 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 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 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 readJsonMaybe(file) { try { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')) : {}; } catch { throw new Error(`JSON 无法解析:${file}`); } } 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 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 runNode(args, cwd) { const result = spawnSync(process.execPath, args, { cwd, stdio: 'inherit' }); if (result.status !== 0) throw new Error(`命令失败:node ${args.join(' ')}`); } 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('依赖安装失败。'); } function parseFlags(args) { return { force: args.includes('--force'), smoke: args.includes('--smoke'), skipInstall: args.includes('--skip-install') }; } 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); } console.log(`Workspace installed: ${target}`); console.log('Restart the Claude Code session.'); } 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}`); } 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 [--force] [--smoke] [--skip-install]'); } main().catch(error => { console.error(error.message); process.exit(1); });