novel-writer-cn
Version:
AI 驱动的中文小说创作工具 - 基于结构化工作流的智能写作助手
1,208 lines (1,203 loc) • 59 kB
JavaScript
#!/usr/bin/env node
import { Command } from '@commander-js/extra-typings';
import chalk from 'chalk';
import path from 'path';
import fs from 'fs-extra';
import ora from 'ora';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { getVersion, getVersionInfo } from './version.js';
import { PluginManager } from './plugins/manager.js';
import { ensureProjectRoot, getProjectInfo } from './utils/project.js';
import { displayProjectBanner, selectAIAssistant, selectWritingMethod, selectScriptType, confirmExpertMode, displayStep, isInteractive } from './utils/interactive.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const program = new Command();
const AI_CONFIGS = [
{ name: 'claude', dir: '.claude', commandsDir: 'commands', displayName: 'Claude Code' },
{ name: 'cursor', dir: '.cursor', commandsDir: 'commands', displayName: 'Cursor' },
{ name: 'gemini', dir: '.gemini', commandsDir: 'commands', displayName: 'Gemini CLI' },
{ name: 'windsurf', dir: '.windsurf', commandsDir: 'workflows', displayName: 'Windsurf' },
{ name: 'roocode', dir: '.roo', commandsDir: 'commands', displayName: 'Roo Code' },
{ name: 'copilot', dir: '.github', commandsDir: 'prompts', displayName: 'GitHub Copilot', extraDirs: ['.vscode'] },
{ name: 'qwen', dir: '.qwen', commandsDir: 'commands', displayName: 'Qwen Code' },
{ name: 'opencode', dir: '.opencode', commandsDir: 'command', displayName: 'OpenCode' },
{ name: 'codex', dir: '.codex', commandsDir: 'prompts', displayName: 'Codex CLI' },
{ name: 'kilocode', dir: '.kilocode', commandsDir: 'workflows', displayName: 'Kilo Code' },
{ name: 'auggie', dir: '.augment', commandsDir: 'commands', displayName: 'Auggie CLI' },
{ name: 'codebuddy', dir: '.codebuddy', commandsDir: 'commands', displayName: 'CodeBuddy' },
{ name: 'q', dir: '.amazonq', commandsDir: 'prompts', displayName: 'Amazon Q Developer' }
];
// 辅助函数:处理命令模板生成 Markdown 格式
function generateMarkdownCommand(template, scriptPath) {
// 直接替换 {SCRIPT} 并返回完整内容,保留所有 frontmatter 包括 scripts 部分
return template.replace(/{SCRIPT}/g, scriptPath);
}
// 辅助函数:生成 TOML 格式命令
function generateTomlCommand(template, scriptPath) {
// 提取 description
const descMatch = template.match(/description:\s*(.+)/);
const description = descMatch ? descMatch[1].trim() : '命令说明';
// 移除 YAML frontmatter
const content = template.replace(/^---[\s\S]*?---\n/, '');
// 替换 {SCRIPT}
const processedContent = content.replace(/{SCRIPT}/g, scriptPath);
// 规范化换行符,避免 Windows CRLF 导致 TOML 解析失败
const normalizedContent = processedContent.replace(/\r\n/g, '\n');
const promptValue = JSON.stringify(normalizedContent);
const escapedDescription = description
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
return `description = "${escapedDescription}"
prompt = ${promptValue}
`;
}
// 显示欢迎横幅
function displayBanner() {
const banner = `
╔═══════════════════════════════════════╗
║ 📚 Novel Writer 📝 ║
║ AI 驱动的中文小说创作工具 ║
╚═══════════════════════════════════════╝
`;
console.log(chalk.cyan(banner));
console.log(chalk.gray(` ${getVersionInfo()}\n`));
}
displayBanner();
program
.name('novel')
.description(chalk.cyan('Novel Writer - AI 驱动的中文小说创作工具初始化'))
.version(getVersion(), '-v, --version', '显示版本号')
.helpOption('-h, --help', '显示帮助信息');
// init 命令 - 初始化小说项目(类似 specify init)
program
.command('init')
.argument('[name]', '小说项目名称')
.option('--here', '在当前目录初始化')
.option('--ai <type>', '选择 AI 助手: claude | cursor | gemini | windsurf | roocode | copilot | qwen | opencode | codex | kilocode | auggie | codebuddy | q')
.option('--all', '为所有支持的 AI 助手生成配置')
.option('--method <type>', '选择写作方法: three-act | hero-journey | story-circle | seven-point | pixar | snowflake')
.option('--no-git', '跳过 Git 初始化')
.option('--with-experts', '包含专家模式')
.option('--plugins <names>', '预装插件,逗号分隔')
.description('初始化一个新的小说项目')
.action(async (name, options) => {
// 如果是交互式终端且没有明确指定参数,显示交互选择
const shouldShowInteractive = isInteractive() && !options.all;
const needsAISelection = shouldShowInteractive && !options.ai;
const needsMethodSelection = shouldShowInteractive && !options.method;
const needsExpertConfirm = shouldShowInteractive && !options.withExperts;
if (needsAISelection || needsMethodSelection || needsExpertConfirm) {
// 显示项目横幅
displayProjectBanner();
let stepCount = 0;
const totalSteps = 4;
// 交互式选择 AI 助手
if (needsAISelection) {
stepCount++;
displayStep(stepCount, totalSteps, '选择 AI 助手');
options.ai = await selectAIAssistant(AI_CONFIGS);
console.log('');
}
// 交互式选择写作方法
if (needsMethodSelection) {
stepCount++;
displayStep(stepCount, totalSteps, '选择写作方法');
options.method = await selectWritingMethod();
console.log('');
}
// 交互式选择脚本类型
stepCount++;
displayStep(stepCount, totalSteps, '选择脚本类型');
const selectedScriptType = await selectScriptType();
console.log('');
// 交互式确认专家模式
if (needsExpertConfirm) {
stepCount++;
displayStep(stepCount, totalSteps, '专家模式');
const enableExperts = await confirmExpertMode();
if (enableExperts) {
options.withExperts = true;
}
console.log('');
}
}
// 设置默认值(如果没有通过交互或参数指定)
if (!options.ai)
options.ai = 'claude';
if (!options.method)
options.method = 'three-act';
const spinner = ora('正在初始化小说项目...').start();
try {
// 确定项目路径
let projectPath;
if (options.here) {
projectPath = process.cwd();
name = path.basename(projectPath);
}
else {
if (!name) {
spinner.fail('请提供项目名称或使用 --here 参数');
process.exit(1);
}
projectPath = path.join(process.cwd(), name);
if (await fs.pathExists(projectPath)) {
spinner.fail(`项目目录 "${name}" 已存在`);
process.exit(1);
}
await fs.ensureDir(projectPath);
}
// 创建基础项目结构
const baseDirs = [
'.specify',
'.specify/memory',
'.specify/scripts',
'.specify/scripts/bash',
'.specify/scripts/powershell',
'.specify/templates',
'stories',
'spec',
'spec/tracking',
'spec/knowledge'
];
for (const dir of baseDirs) {
await fs.ensureDir(path.join(projectPath, dir));
}
// 根据 AI 类型创建特定目录
const aiDirs = [];
if (options.all) {
// 创建所有 AI 目录
aiDirs.push('.claude/commands', '.cursor/commands', '.gemini/commands', '.windsurf/workflows', '.roo/commands', '.github/prompts', '.vscode', '.qwen/commands', '.opencode/command', '.codex/prompts', '.kilocode/workflows', '.augment/commands', '.codebuddy/commands', '.amazonq/prompts');
}
else {
// 根据选择的 AI 创建目录
switch (options.ai) {
case 'claude':
aiDirs.push('.claude/commands');
break;
case 'cursor':
aiDirs.push('.cursor/commands');
break;
case 'gemini':
aiDirs.push('.gemini/commands');
break;
case 'windsurf':
aiDirs.push('.windsurf/workflows');
break;
case 'roocode':
aiDirs.push('.roo/commands');
break;
case 'copilot':
aiDirs.push('.github/prompts', '.vscode');
break;
case 'qwen':
aiDirs.push('.qwen/commands');
break;
case 'opencode':
aiDirs.push('.opencode/command');
break;
case 'codex':
aiDirs.push('.codex/prompts');
break;
case 'kilocode':
aiDirs.push('.kilocode/workflows');
break;
case 'auggie':
aiDirs.push('.augment/commands');
break;
case 'codebuddy':
aiDirs.push('.codebuddy/commands');
break;
case 'q':
aiDirs.push('.amazonq/prompts');
break;
}
}
for (const dir of aiDirs) {
await fs.ensureDir(path.join(projectPath, dir));
}
// 创建基础配置文件
const config = {
name,
type: 'novel',
ai: options.ai,
method: options.method || 'three-act',
created: new Date().toISOString(),
version: getVersion()
};
await fs.writeJson(path.join(projectPath, '.specify', 'config.json'), config, { spaces: 2 });
// 从构建产物复制 AI 配置和命令文件
const packageRoot = path.resolve(__dirname, '..');
const scriptsDir = path.join(packageRoot, 'scripts');
const sourceMap = {
'claude': 'dist/claude',
'gemini': 'dist/gemini',
'cursor': 'dist/cursor',
'windsurf': 'dist/windsurf',
'roocode': 'dist/roocode',
'copilot': 'dist/copilot',
'qwen': 'dist/qwen',
'opencode': 'dist/opencode',
'codex': 'dist/codex',
'kilocode': 'dist/kilocode',
'auggie': 'dist/auggie',
'codebuddy': 'dist/codebuddy',
'q': 'dist/q'
};
// 确定需要复制的 AI 平台
const targetAI = [];
if (options.all) {
targetAI.push('claude', 'gemini', 'cursor', 'windsurf', 'roocode', 'copilot', 'qwen', 'opencode', 'codex', 'kilocode', 'auggie', 'codebuddy', 'q');
}
else {
targetAI.push(options.ai);
}
// 复制 AI 配置目录(包含命令文件和 .specify 目录)
for (const ai of targetAI) {
const sourceDir = path.join(packageRoot, sourceMap[ai]);
if (await fs.pathExists(sourceDir)) {
// 复制整个构建产物目录到项目
await fs.copy(sourceDir, projectPath, { overwrite: false });
spinner.text = `已安装 ${ai} 配置...`;
}
else {
console.log(chalk.yellow(`\n警告: ${ai} 构建产物未找到,请运行 npm run build:commands`));
}
}
// 复制脚本文件到用户项目的 .specify/scripts 目录(构建产物已包含)
// 注意:.specify 目录已由上面的 fs.copy 复制,此处仅作为备份逻辑
if (await fs.pathExists(scriptsDir) && !await fs.pathExists(path.join(projectPath, '.specify', 'scripts'))) {
const userScriptsDir = path.join(projectPath, '.specify', 'scripts');
await fs.copy(scriptsDir, userScriptsDir);
// 设置 bash 脚本执行权限
const bashDir = path.join(userScriptsDir, 'bash');
if (await fs.pathExists(bashDir)) {
const bashFiles = await fs.readdir(bashDir);
for (const file of bashFiles) {
if (file.endsWith('.sh')) {
const filePath = path.join(bashDir, file);
await fs.chmod(filePath, 0o755);
}
}
}
}
// 复制模板文件到 .specify/templates 目录
const fullTemplatesDir = path.join(packageRoot, 'templates');
if (await fs.pathExists(fullTemplatesDir)) {
const userTemplatesDir = path.join(projectPath, '.specify', 'templates');
await fs.copy(fullTemplatesDir, userTemplatesDir);
}
// 复制 memory 文件到 .specify/memory 目录
const memoryDir = path.join(packageRoot, 'memory');
if (await fs.pathExists(memoryDir)) {
const userMemoryDir = path.join(projectPath, '.specify', 'memory');
await fs.copy(memoryDir, userMemoryDir);
}
// 复制追踪文件模板到 spec/tracking 目录
const trackingTemplatesDir = path.join(packageRoot, 'templates', 'tracking');
if (await fs.pathExists(trackingTemplatesDir)) {
const userTrackingDir = path.join(projectPath, 'spec', 'tracking');
await fs.copy(trackingTemplatesDir, userTrackingDir);
}
// 复制知识库模板到 spec/knowledge 目录
const knowledgeTemplatesDir = path.join(packageRoot, 'templates', 'knowledge');
if (await fs.pathExists(knowledgeTemplatesDir)) {
const userKnowledgeDir = path.join(projectPath, 'spec', 'knowledge');
await fs.copy(knowledgeTemplatesDir, userKnowledgeDir);
// 更新模板中的日期
const knowledgeFiles = await fs.readdir(userKnowledgeDir);
const currentDate = new Date().toISOString().split('T')[0];
for (const file of knowledgeFiles) {
if (file.endsWith('.md')) {
const filePath = path.join(userKnowledgeDir, file);
let content = await fs.readFile(filePath, 'utf-8');
content = content.replace(/\[日期\]/g, currentDate);
await fs.writeFile(filePath, content);
}
}
}
// 复制 spec 目录结构(包括预设和反AI检测规范)
// 注意:构建产物已包含 spec/presets 等,此处作为后备确保完整性
const specDir = path.join(packageRoot, 'spec');
if (await fs.pathExists(specDir)) {
const userSpecDir = path.join(projectPath, 'spec');
// 遍历并复制所有 spec 子目录
const specItems = await fs.readdir(specDir);
for (const item of specItems) {
const sourcePath = path.join(specDir, item);
const targetPath = path.join(userSpecDir, item);
// presets、checklists、config.json 等直接复制(不覆盖已存在的)
// tracking 和 knowledge 已在前面从 templates 复制,跳过
if (item !== 'tracking' && item !== 'knowledge') {
await fs.copy(sourcePath, targetPath, { overwrite: false });
}
}
}
// 为 Gemini 复制额外的配置文件
if (aiDirs.some(dir => dir.includes('.gemini'))) {
// 复制 settings.json
const geminiSettingsSource = path.join(packageRoot, 'templates', 'gemini-settings.json');
const geminiSettingsDest = path.join(projectPath, '.gemini', 'settings.json');
if (await fs.pathExists(geminiSettingsSource)) {
await fs.copy(geminiSettingsSource, geminiSettingsDest);
console.log(' ✓ 已复制 Gemini settings.json');
}
// 复制 GEMINI.md
const geminiMdSource = path.join(packageRoot, 'templates', 'GEMINI.md');
const geminiMdDest = path.join(projectPath, '.gemini', 'GEMINI.md');
if (await fs.pathExists(geminiMdSource)) {
await fs.copy(geminiMdSource, geminiMdDest);
console.log(' ✓ 已复制 GEMINI.md');
}
}
// 为 GitHub Copilot 复制 VS Code settings
if (aiDirs.some(dir => dir.includes('.github') || dir.includes('.vscode'))) {
const vscodeSettingsSource = path.join(packageRoot, 'templates', 'vscode-settings.json');
const vscodeSettingsDest = path.join(projectPath, '.vscode', 'settings.json');
if (await fs.pathExists(vscodeSettingsSource)) {
await fs.copy(vscodeSettingsSource, vscodeSettingsDest);
console.log(' ✓ 已复制 GitHub Copilot settings.json');
}
}
// 如果指定了 --with-experts,复制专家文件和 expert 命令
if (options.withExperts) {
spinner.text = '安装专家模式...';
// 复制专家目录
const expertsSourceDir = path.join(packageRoot, 'experts');
if (await fs.pathExists(expertsSourceDir)) {
const userExpertsDir = path.join(projectPath, 'experts');
await fs.copy(expertsSourceDir, userExpertsDir);
}
// 复制 expert 命令到各个 AI 目录
const expertCommandSource = path.join(packageRoot, 'templates', 'commands', 'expert.md');
if (await fs.pathExists(expertCommandSource)) {
const expertContent = await fs.readFile(expertCommandSource, 'utf-8');
for (const aiDir of aiDirs) {
if (aiDir.includes('claude') || aiDir.includes('cursor')) {
const expertPath = path.join(projectPath, aiDir, 'expert.md');
await fs.writeFile(expertPath, expertContent);
}
// Windsurf 使用 workflows 目录
if (aiDir.includes('windsurf')) {
const expertPath = path.join(projectPath, aiDir, 'expert.md');
await fs.writeFile(expertPath, expertContent);
}
// Roo Code 使用 Markdown 命令目录
if (aiDir.includes('.roo')) {
const expertPath = path.join(projectPath, aiDir, 'expert.md');
await fs.writeFile(expertPath, expertContent);
}
// Gemini 格式处理
if (aiDir.includes('gemini')) {
const expertPath = path.join(projectPath, aiDir, 'expert.toml');
const expertToml = generateTomlCommand(expertContent, '');
await fs.writeFile(expertPath, expertToml);
}
}
}
}
// 如果指定了 --plugins,安装插件
if (options.plugins) {
spinner.text = '安装插件...';
const pluginNames = options.plugins.split(',').map((p) => p.trim());
const pluginManager = new PluginManager(projectPath);
for (const pluginName of pluginNames) {
// 检查内置插件
const builtinPluginPath = path.join(packageRoot, 'plugins', pluginName);
if (await fs.pathExists(builtinPluginPath)) {
await pluginManager.installPlugin(pluginName, builtinPluginPath);
}
else {
console.log(chalk.yellow(`\n警告: 插件 "${pluginName}" 未找到`));
}
}
}
// Git 初始化
if (options.git !== false) {
try {
execSync('git init', { cwd: projectPath, stdio: 'ignore' });
// 创建 .gitignore
const gitignore = `# 临时文件
*.tmp
*.swp
.DS_Store
# 编辑器配置
.vscode/
.idea/
# AI 缓存
.ai-cache/
# 节点模块
node_modules/
`;
await fs.writeFile(path.join(projectPath, '.gitignore'), gitignore);
execSync('git add .', { cwd: projectPath, stdio: 'ignore' });
execSync('git commit -m "初始化小说项目"', { cwd: projectPath, stdio: 'ignore' });
}
catch {
console.log(chalk.yellow('\n提示: Git 初始化失败,但项目已创建成功'));
}
}
spinner.succeed(chalk.green(`小说项目 "${name}" 创建成功!`));
// 显示后续步骤
console.log('\n' + chalk.cyan('接下来:'));
console.log(chalk.gray('─────────────────────────────'));
if (!options.here) {
console.log(` 1. ${chalk.white(`cd ${name}`)} - 进入项目目录`);
}
const aiName = {
'claude': 'Claude Code',
'cursor': 'Cursor',
'gemini': 'Gemini',
'windsurf': 'Windsurf',
'roocode': 'Roo Code',
'copilot': 'GitHub Copilot',
'qwen': 'Qwen Code',
'opencode': 'OpenCode',
'codex': 'Codex CLI',
'kilocode': 'Kilo Code',
'auggie': 'Auggie CLI',
'codebuddy': 'CodeBuddy',
'q': 'Amazon Q Developer'
}[options.ai] || 'AI 助手';
if (options.all) {
console.log(` 2. ${chalk.white('在任意 AI 助手中打开项目(Claude Code、Cursor、Gemini、Windsurf、Roo Code、GitHub Copilot、Qwen Code、OpenCode、Codex CLI、Kilo Code、Auggie CLI、CodeBuddy、Amazon Q Developer)')}`);
}
else {
console.log(` 2. ${chalk.white(`在 ${aiName} 中打开项目`)}`);
}
console.log(` 3. 使用以下斜杠命令开始创作:`);
console.log('\n' + chalk.yellow(' 📝 七步方法论:'));
console.log(` ${chalk.cyan('/constitution')} - 创建创作宪法,定义核心原则`);
console.log(` ${chalk.cyan('/specify')} - 定义故事规格,明确要创造什么`);
console.log(` ${chalk.cyan('/clarify')} - 澄清关键决策点,明确模糊之处`);
console.log(` ${chalk.cyan('/plan')} - 制定技术方案,决定如何创作`);
console.log(` ${chalk.cyan('/tasks')} - 分解执行任务,生成可执行清单`);
console.log(` ${chalk.cyan('/write')} - AI 辅助写作章节内容`);
console.log(` ${chalk.cyan('/analyze')} - 综合验证分析,确保质量一致`);
console.log('\n' + chalk.yellow(' 📊 追踪管理命令:'));
console.log(` ${chalk.cyan('/plot-check')} - 检查情节一致性`);
console.log(` ${chalk.cyan('/timeline')} - 管理故事时间线`);
console.log(` ${chalk.cyan('/relations')} - 追踪角色关系`);
console.log(` ${chalk.cyan('/world-check')} - 验证世界观设定`);
console.log(` ${chalk.cyan('/track')} - 综合追踪与智能分析`);
// 如果安装了专家模式,显示提示
if (options.withExperts) {
console.log('\n' + chalk.yellow(' 🎓 专家模式:'));
console.log(` ${chalk.cyan('/expert')} - 列出可用专家`);
console.log(` ${chalk.cyan('/expert plot')} - 剧情结构专家`);
console.log(` ${chalk.cyan('/expert character')} - 人物塑造专家`);
}
// 如果安装了插件,显示插件命令
if (options.plugins) {
const installedPlugins = options.plugins.split(',').map((p) => p.trim());
if (installedPlugins.includes('translate')) {
console.log('\n' + chalk.yellow(' 🌍 翻译插件:'));
console.log(` ${chalk.cyan('/translate')} - 中英文翻译`);
console.log(` ${chalk.cyan('/polish')} - 英文润色`);
}
}
console.log('\n' + chalk.gray('推荐流程: constitution → specify → clarify → plan → tasks → write → analyze'));
console.log(chalk.dim('提示: 斜杠命令在 AI 助手内部使用,不是在终端中'));
}
catch (error) {
spinner.fail(chalk.red('项目初始化失败'));
console.error(error);
process.exit(1);
}
});
// check 命令 - 检查环境
program
.command('check')
.description('检查系统环境和 AI 工具')
.action(() => {
console.log(chalk.cyan('检查系统环境...\n'));
const checks = [
{ name: 'Node.js', command: 'node --version', installed: false },
{ name: 'Git', command: 'git --version', installed: false },
{ name: 'Claude CLI', command: 'claude --version', installed: false },
{ name: 'Cursor', command: 'cursor --version', installed: false },
{ name: 'Gemini CLI', command: 'gemini --version', installed: false }
];
checks.forEach(check => {
try {
execSync(check.command, { stdio: 'ignore' });
check.installed = true;
console.log(chalk.green('✓') + ` ${check.name} 已安装`);
}
catch {
console.log(chalk.yellow('⚠') + ` ${check.name} 未安装`);
}
});
const hasAI = checks.slice(2).some(c => c.installed);
if (!hasAI) {
console.log('\n' + chalk.yellow('警告: 未检测到 AI 助手工具'));
console.log('请安装以下任一工具:');
console.log(' • Claude: https://claude.ai');
console.log(' • Cursor: https://cursor.sh');
console.log(' • Gemini: https://gemini.google.com');
console.log(' • Roo Code: https://roocode.com');
}
else {
console.log('\n' + chalk.green('环境检查通过!'));
}
});
// plugins 命令 - 插件管理
program
.command('plugins')
.description('插件管理')
.action(() => {
// 显示插件子命令帮助
console.log(chalk.cyan('\n📦 插件管理命令:\n'));
console.log(' novel plugins list - 列出已安装的插件');
console.log(' novel plugins add <name> - 安装插件');
console.log(' novel plugins remove <name> - 移除插件');
console.log('\n' + chalk.gray('可用插件:'));
console.log(' translate - 中英文翻译插件');
console.log(' authentic-voice - 真实人声写作插件');
});
program
.command('plugins:list')
.description('列出已安装的插件')
.action(async () => {
try {
// 检测项目
const projectPath = await ensureProjectRoot();
const projectInfo = await getProjectInfo(projectPath);
if (!projectInfo) {
console.log(chalk.red('❌ 无法读取项目信息'));
process.exit(1);
}
const pluginManager = new PluginManager(projectPath);
const plugins = await pluginManager.listPlugins();
console.log(chalk.cyan('\n📦 已安装的插件\n'));
console.log(chalk.gray(`项目: ${path.basename(projectPath)}`));
console.log(chalk.gray(`AI 配置: ${projectInfo.installedAI.join(', ') || '无'}\n`));
if (plugins.length === 0) {
console.log(chalk.yellow('暂无插件'));
console.log(chalk.gray('\n使用 "novel plugins:add <name>" 安装插件'));
console.log(chalk.gray('可用插件: translate, authentic-voice, book-analysis, genre-knowledge\n'));
return;
}
for (const plugin of plugins) {
console.log(chalk.yellow(` ${plugin.name}`) + ` (v${plugin.version})`);
console.log(chalk.gray(` ${plugin.description}`));
if (plugin.commands && plugin.commands.length > 0) {
console.log(chalk.gray(` 命令: ${plugin.commands.map(c => `/${c.id}`).join(', ')}`));
}
if (plugin.experts && plugin.experts.length > 0) {
console.log(chalk.gray(` 专家: ${plugin.experts.map(e => e.title).join(', ')}`));
}
console.log('');
}
}
catch (error) {
if (error.message === 'NOT_IN_PROJECT') {
console.log(chalk.red('\n❌ 当前目录不是 novel-writer 项目'));
console.log(chalk.gray(' 请在项目根目录运行此命令\n'));
process.exit(1);
}
console.error(chalk.red('❌ 列出插件失败:'), error);
process.exit(1);
}
});
program
.command('plugins:add <name>')
.description('安装插件')
.action(async (name) => {
try {
// 1. 检测项目
const projectPath = await ensureProjectRoot();
const projectInfo = await getProjectInfo(projectPath);
if (!projectInfo) {
console.log(chalk.red('❌ 无法读取项目信息'));
process.exit(1);
}
console.log(chalk.cyan('\n📦 Novel Writer 插件安装\n'));
console.log(chalk.gray(`项目版本: ${projectInfo.version}`));
console.log(chalk.gray(`AI 配置: ${projectInfo.installedAI.join(', ') || '无'}\n`));
// 2. 查找插件
const packageRoot = path.resolve(__dirname, '..');
const builtinPluginPath = path.join(packageRoot, 'plugins', name);
if (!await fs.pathExists(builtinPluginPath)) {
console.log(chalk.red(`❌ 插件 ${name} 未找到\n`));
console.log(chalk.gray('可用插件:'));
console.log(chalk.gray(' - translate (翻译出海插件)'));
console.log(chalk.gray(' - authentic-voice (真实人声插件)'));
console.log(chalk.gray(' - book-analysis (拆书分析插件)'));
console.log(chalk.gray(' - genre-knowledge (类型知识库插件)'));
process.exit(1);
}
// 3. 读取插件配置
const pluginConfigPath = path.join(builtinPluginPath, 'config.yaml');
const yaml = await import('js-yaml');
const pluginConfigContent = await fs.readFile(pluginConfigPath, 'utf-8');
const pluginConfig = yaml.load(pluginConfigContent);
// 4. 显示插件信息
console.log(chalk.cyan(`准备安装: ${pluginConfig.description || name}`));
console.log(chalk.gray(`版本: ${pluginConfig.version}`));
if (pluginConfig.commands && pluginConfig.commands.length > 0) {
console.log(chalk.gray(`命令数量: ${pluginConfig.commands.length}`));
}
if (pluginConfig.experts && pluginConfig.experts.length > 0) {
console.log(chalk.gray(`专家模式: ${pluginConfig.experts.length} 个`));
}
if (projectInfo.installedAI.length > 0) {
console.log(chalk.gray(`目标 AI: ${projectInfo.installedAI.join(', ')}\n`));
}
else {
console.log(chalk.yellow('\n⚠️ 未检测到 AI 配置目录'));
console.log(chalk.gray(' 插件将被复制,但命令不会被注入到任何 AI 平台\n'));
}
// 5. 安装插件
const spinner = ora('正在安装插件...').start();
const pluginManager = new PluginManager(projectPath);
await pluginManager.installPlugin(name, builtinPluginPath);
spinner.succeed(chalk.green('插件安装成功!\n'));
// 6. 显示后续步骤
if (pluginConfig.commands && pluginConfig.commands.length > 0) {
console.log(chalk.cyan('可用命令:'));
for (const cmd of pluginConfig.commands) {
console.log(chalk.gray(` /${cmd.id} - ${cmd.description || ''}`));
}
}
if (pluginConfig.experts && pluginConfig.experts.length > 0) {
console.log(chalk.cyan('\n专家模式:'));
for (const expert of pluginConfig.experts) {
console.log(chalk.gray(` /expert ${expert.id} - ${expert.title || ''}`));
}
}
console.log('');
}
catch (error) {
if (error.message === 'NOT_IN_PROJECT') {
console.log(chalk.red('\n❌ 当前目录不是 novel-writer 项目'));
console.log(chalk.gray(' 请在项目根目录运行此命令,或使用 novel init 创建新项目\n'));
process.exit(1);
}
console.log(chalk.red('\n❌ 安装插件失败'));
console.error(chalk.gray(error.message || error));
console.log('');
process.exit(1);
}
});
program
.command('plugins:remove <name>')
.description('移除插件')
.action(async (name) => {
try {
// 检测项目
const projectPath = await ensureProjectRoot();
const projectInfo = await getProjectInfo(projectPath);
if (!projectInfo) {
console.log(chalk.red('❌ 无法读取项目信息'));
process.exit(1);
}
const pluginManager = new PluginManager(projectPath);
console.log(chalk.cyan('\n📦 Novel Writer 插件移除\n'));
console.log(chalk.gray(`准备移除插件: ${name}`));
console.log(chalk.gray(`AI 配置: ${projectInfo.installedAI.join(', ') || '无'}\n`));
const spinner = ora('正在移除插件...').start();
await pluginManager.removePlugin(name);
spinner.succeed(chalk.green('插件移除成功!\n'));
}
catch (error) {
if (error.message === 'NOT_IN_PROJECT') {
console.log(chalk.red('\n❌ 当前目录不是 novel-writer 项目'));
console.log(chalk.gray(' 请在项目根目录运行此命令\n'));
process.exit(1);
}
console.log(chalk.red('\n❌ 移除插件失败'));
console.error(chalk.gray(error.message || error));
console.log('');
process.exit(1);
}
});
/**
* 交互式选择要更新的内容
*/
async function selectUpdateContentInteractive() {
const inquirer = (await import('inquirer')).default;
const answers = await inquirer.prompt([
{
type: 'checkbox',
name: 'content',
message: '选择要更新的内容:',
choices: [
{ name: '命令文件 (Commands)', value: 'commands', checked: true },
{ name: '脚本文件 (Scripts)', value: 'scripts', checked: true },
{ name: '写作规范和预设 (Spec/Presets)', value: 'spec', checked: true },
{ name: '专家模式文件 (Experts)', value: 'experts', checked: false },
{ name: '模板文件 (Templates)', value: 'templates', checked: false },
{ name: '记忆文件 (Memory)', value: 'memory', checked: false }
]
}
]);
return {
commands: answers.content.includes('commands'),
scripts: answers.content.includes('scripts'),
templates: answers.content.includes('templates'),
memory: answers.content.includes('memory'),
spec: answers.content.includes('spec'),
experts: answers.content.includes('experts')
};
}
/**
* 更新命令文件
*/
async function updateCommands(targetAI, projectPath, packageRoot, dryRun) {
let count = 0;
const sourceMap = {
'claude': 'dist/claude',
'gemini': 'dist/gemini',
'cursor': 'dist/cursor',
'windsurf': 'dist/windsurf',
'roocode': 'dist/roocode',
'copilot': 'dist/copilot',
'qwen': 'dist/qwen',
'opencode': 'dist/opencode',
'codex': 'dist/codex',
'kilocode': 'dist/kilocode',
'auggie': 'dist/auggie',
'codebuddy': 'dist/codebuddy',
'q': 'dist/q'
};
for (const ai of targetAI) {
const sourceDir = path.join(packageRoot, sourceMap[ai]);
const aiConfig = AI_CONFIGS.find(c => c.name === ai);
if (!aiConfig)
continue;
if (await fs.pathExists(sourceDir)) {
const targetDir = path.join(projectPath, aiConfig.dir);
// 复制命令文件目录
const sourceCommandsDir = path.join(sourceDir, aiConfig.dir, aiConfig.commandsDir);
const targetCommandsDir = path.join(targetDir, aiConfig.commandsDir);
if (await fs.pathExists(sourceCommandsDir)) {
if (!dryRun) {
await fs.copy(sourceCommandsDir, targetCommandsDir, { overwrite: true });
}
// 统计命令文件数
const commandFiles = await fs.readdir(sourceCommandsDir);
const cmdCount = commandFiles.filter(f => f.endsWith('.md') || f.endsWith('.toml')).length;
count += cmdCount;
console.log(chalk.gray(` ✓ ${aiConfig.displayName}: ${cmdCount} 个文件`));
}
// 处理额外目录 (如 GitHub Copilot 的 .vscode)
if (aiConfig.extraDirs) {
for (const extraDir of aiConfig.extraDirs) {
const sourceExtraDir = path.join(sourceDir, extraDir);
const targetExtraDir = path.join(projectPath, extraDir);
if (await fs.pathExists(sourceExtraDir)) {
if (!dryRun) {
await fs.copy(sourceExtraDir, targetExtraDir, { overwrite: true });
}
console.log(chalk.gray(` ✓ ${aiConfig.displayName}: 已更新 ${extraDir}`));
}
}
}
}
else {
console.log(chalk.yellow(` ⚠ ${aiConfig?.displayName || ai}: 构建产物未找到`));
}
}
return count;
}
/**
* 更新脚本文件
*/
async function updateScripts(projectPath, packageRoot, dryRun) {
const scriptsSource = path.join(packageRoot, 'scripts');
const scriptsDest = path.join(projectPath, '.specify', 'scripts');
if (!await fs.pathExists(scriptsSource)) {
console.log(chalk.yellow(' ⚠ 脚本源文件未找到'));
return 0;
}
if (!dryRun) {
await fs.copy(scriptsSource, scriptsDest, { overwrite: true });
// 设置 bash 脚本执行权限
const bashDir = path.join(scriptsDest, 'bash');
if (await fs.pathExists(bashDir)) {
const bashFiles = await fs.readdir(bashDir);
for (const file of bashFiles) {
if (file.endsWith('.sh')) {
const filePath = path.join(bashDir, file);
await fs.chmod(filePath, 0o755);
}
}
}
}
// 统计脚本数量
const bashScripts = await fs.readdir(path.join(scriptsSource, 'bash'));
const psScripts = await fs.readdir(path.join(scriptsSource, 'powershell'));
const totalScripts = bashScripts.length + psScripts.length;
console.log(chalk.gray(` ✓ 更新 ${bashScripts.length} 个 bash 脚本`));
console.log(chalk.gray(` ✓ 更新 ${psScripts.length} 个 powershell 脚本`));
return totalScripts;
}
/**
* 更新模板文件
*/
async function updateTemplates(projectPath, packageRoot, dryRun) {
const templatesSource = path.join(packageRoot, 'templates');
const templatesDest = path.join(projectPath, '.specify', 'templates');
if (!await fs.pathExists(templatesSource)) {
console.log(chalk.yellow(' ⚠ 模板源文件未找到'));
return 0;
}
if (!dryRun) {
await fs.copy(templatesSource, templatesDest, { overwrite: true });
}
// 统计模板文件
const files = await fs.readdir(templatesSource);
const templateCount = files.filter(f => f.endsWith('.md') || f.endsWith('.yaml')).length;
console.log(chalk.gray(` ✓ 更新 ${templateCount} 个模板文件`));
return templateCount;
}
/**
* 更新记忆文件
*/
async function updateMemory(projectPath, packageRoot, dryRun) {
const memorySource = path.join(packageRoot, 'memory');
const memoryDest = path.join(projectPath, '.specify', 'memory');
if (!await fs.pathExists(memorySource)) {
console.log(chalk.yellow(' ⚠ 记忆源文件未找到'));
return 0;
}
if (!dryRun) {
await fs.copy(memorySource, memoryDest, { overwrite: true });
}
// 统计记忆文件
const files = await fs.readdir(memorySource);
const memoryCount = files.filter(f => f.endsWith('.md')).length;
console.log(chalk.gray(` ✓ 更新 ${memoryCount} 个记忆文件`));
return memoryCount;
}
/**
* 更新 spec 目录(包括 presets、反AI检测规范等)
*/
async function updateSpec(projectPath, packageRoot, dryRun) {
const specSource = path.join(packageRoot, 'spec');
const specDest = path.join(projectPath, 'spec');
if (!await fs.pathExists(specSource)) {
console.log(chalk.yellow(' ⚠ Spec 源文件未找到'));
return 0;
}
let count = 0;
if (!dryRun) {
// 遍历 spec 目录,只更新 presets、checklists、config.json 等
// 不覆盖 tracking 和 knowledge(用户数据)
const specItems = await fs.readdir(specSource);
for (const item of specItems) {
if (item !== 'tracking' && item !== 'knowledge') {
const sourcePath = path.join(specSource, item);
const targetPath = path.join(specDest, item);
await fs.copy(sourcePath, targetPath, { overwrite: true });
// 统计文件数
if (await fs.stat(sourcePath).then(s => s.isDirectory())) {
const files = await fs.readdir(sourcePath);
count += files.filter(f => f.endsWith('.md') || f.endsWith('.json')).length;
}
else {
count += 1;
}
}
}
}
else {
// dry run - 只统计
const specItems = await fs.readdir(specSource);
for (const item of specItems) {
if (item !== 'tracking' && item !== 'knowledge') {
const sourcePath = path.join(specSource, item);
if (await fs.stat(sourcePath).then(s => s.isDirectory())) {
const files = await fs.readdir(sourcePath);
count += files.filter(f => f.endsWith('.md') || f.endsWith('.json')).length;
}
else {
count += 1;
}
}
}
}
console.log(chalk.gray(` ✓ 更新 spec/ (presets 等 ${count} 个文件)`));
return count;
}
/**
* 更新专家模式文件
*/
async function updateExperts(projectPath, packageRoot, dryRun) {
const expertsSource = path.join(packageRoot, 'experts');
const expertsDest = path.join(projectPath, '.specify', 'experts');
// 检查项目是否安装了专家模式
if (!await fs.pathExists(expertsDest)) {
console.log(chalk.gray(' ⓘ 项目未安装专家模式,跳过'));
return 0;
}
if (!await fs.pathExists(expertsSource)) {
console.log(chalk.yellow(' ⚠ 专家源文件未找到'));
return 0;
}
if (!dryRun) {
await fs.copy(expertsSource, expertsDest, { overwrite: true });
}
// 统计专家文件
const countFiles = async (dir) => {
let count = 0;
const items = await fs.readdir(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = await fs.stat(itemPath);
if (stat.isDirectory()) {
count += await countFiles(itemPath);
}
else if (item.endsWith('.md')) {
count += 1;
}
}
return count;
};
const expertsCount = await countFiles(expertsSource);
console.log(chalk.gray(` ✓ 更新 ${expertsCount} 个专家文件`));
return expertsCount;
}
/**
* 创建选择性备份
*/
async function createBackup(projectPath, updateContent, targetAI, projectVersion) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
const backupPath = path.join(projectPath, 'backup', timestamp);
await fs.ensureDir(backupPath);
console.log(chalk.cyan('📦 创建备份...'));
// 备份命令文件
if (updateContent.commands) {
for (const ai of targetAI) {
const aiConfig = AI_CONFIGS.find(c => c.name === ai);
if (!aiConfig)
continue;
const source = path.join(projectPath, aiConfig.dir);
const dest = path.join(backupPath, aiConfig.dir);
if (await fs.pathExists(source)) {
await fs.copy(source, dest);
console.log(chalk.gray(` ✓ 备份 ${aiConfig.dir}/`));
}
}
}
// 备份脚本
if (updateContent.scripts) {
const scriptsSource = path.join(projectPath, '.specify', 'scripts');
if (await fs.pathExists(scriptsSource)) {
await fs.copy(scriptsSource, path.join(backupPath, '.specify', 'scripts'));
console.log(chalk.gray(' ✓ 备份 .specify/scripts/'));
}
}
// 备份模板
if (updateContent.templates) {
const templatesSource = path.join(projectPath, '.specify', 'templates');
if (await fs.pathExists(templatesSource)) {
await fs.copy(templatesSource, path.join(backupPath, '.specify', 'templates'));
console.log(chalk.gray(' ✓ 备份 .specify/templates/'));
}
}
// 备份记忆
if (updateContent.memory) {
const memorySource = path.join(projectPath, '.specify', 'memory');
if (await fs.pathExists(memorySource)) {
await fs.copy(memorySource, path.join(backupPath, '.specify', 'memory'));
console.log(chalk.gray(' ✓ 备份 .specify/memory/'));
}
}
// 保存备份信息
const backupInfo = {
timestamp,
fromVersion: projectVersion,
toVersion: getVersion(),
upgradedAI: targetAI,
updateContent,
backupPath
};
await fs.writeJson(path.join(backupPath, 'BACKUP_INFO.json'), backupInfo, { spaces: 2 });
console.log(chalk.green(`✓ 备份完成: ${backupPath}\n`));
return backupPath;
}
/**
* 显示升级报告
*/
function displayUpgradeReport(stats, projectVersion, backupPath, updateContent) {
console.log(chalk.cyan('\n📊 升级报告\n'));
console.log(chalk.green('✅ 升级完成!\n'));
console.log(chalk.yellow('升级统计:'));
console.log(` • 版本: ${projectVersion} → ${getVersion()}`);
console.log(` • AI 平台: ${stats.platforms.join(', ')}`);
if (updateContent.commands && stats.commands > 0) {
console.log(` • 命令文件: ${stats.commands} 个`);
}
if (updateContent.scripts && stats.scripts > 0) {
console.log(` • 脚本文件: ${stats.scripts} 个`);
}
if (updateContent.spec && stats.spec > 0) {
console.log(` • 写作规范和预设: ${stats.spec} 个`);
}
if (updateContent.experts && stats.experts > 0) {
console.log(` • 专家模式文件: ${stats.experts} 个`);
}
if (updateContent.templates && stats.templates > 0) {
console.log(` • 模板文件: ${stats.templates} 个`);
}
if (updateContent.memory && stats.memory > 0) {
console.log(` • 记忆文件: ${stats.memory} 个`);
}
if (backupPath) {
console.log(chalk.gray(`\n📦 备份位置: ${backupPath}`));
console.log(chalk.gray(' 如需回滚,删除当前文件并从备份恢复'));
}
console.log(chalk.cyan('\n✨ 本次升级包含:'));
console.log(' • 反AI检测规范: 基于朱雀实测的0% AI浓度写作指南');
console.log(' • 专家模式增强: 核心专家系统(角色、剧情、风格、世界观)');
console.log(' • AI 温度控制: write 命令新增创作强化指令');
console.log(' • 多平台支持: 所有 13 个 AI 平台的命令已更新');
console.log(chalk.gray('\n📚 查看详细升级指南: docs/upgrade-guide.md'));
console.log(chalk.gray(' 或访问: https://github.com/wordflowlab/novel-writer/blob/main/docs/upgrade-guide.md'));
}
// upgrade 命令 - 升级现有项目
program
.command('upgrade')
.option('--ai <type>', '指定要升级的 AI 配置: claude | cursor | gemini | windsurf | roocode | copilot | qwen | opencode | codex | kilocode | auggie | codebuddy | q')
.option('--all', '升级所有 AI 配置')
.option('-i, --interactive', '交互式选择要更新的内容')
.option('--commands', '仅更新命令文件')
.option('--scripts', '仅更新脚本文件')
.option('--spec', '仅更新写作规范和预设')
.option('--experts', '仅更新专家模式文件')
.option('--templates', '仅更新模板文件')
.option('--memory', '仅更新记忆文件')
.option('-y, --yes', '跳过确认提示')
.option('--no-backup', '跳过备份')
.option('--dry-run', '预览升级内容,不实际修改')
.description('升级现有项目到最新版本')
.action(async (options) => {
const projectPath = process.cwd();
const packageRoot = path.resolve(__dirname, '..');
try {
// 1. 检测项目
const configPath = path.join(projectPath, '.specify', 'config.json');
if (!await fs.pathExists(configPath)) {
console.log(chalk.red('❌ 当前目录不是 novel-writer 项目'));
console.log(chalk.gray(' 请在项目根目录运行此命令,或使用 novel init 创建新项目'));
process.exit(1);
}
// 读取项目配置
const config = await fs.readJson(configPath);
const projectVersion = config.version || '未知';
console.log(chalk.cyan('\n📦 Novel Writer 项目升级\n'));
console.log(chalk.gray(`当前版本: ${projectVersion}`));
console.log(chalk.gray(`目标版本: ${getVersion()}\n`));
// 2. 检测已安装的 AI 配置
const installedAI = [];
for (const aiConfig of AI_CONFIGS) {
if (await fs.pathExists(path.join(projectPath, aiConfig.dir))) {
installedAI.push(aiConfig.name);
}
}
if (installedAI.length === 0) {
console.log(chalk.yellow('⚠️ 未检测到任何 AI 配置目录'));
process.exit(1);
}
const displayNames = installedAI.map(name => {
const config = AI_CONFIGS.find(c => c.name === name);
return config?.displayName || name;
});
console.log(chalk.green('✓') + ' 检测到 AI 配置: ' + displayNames.join(', '));
// 3. 确定要升级的 AI 配置
let targetAI = installedAI;
if (options.ai) {
if (!installedAI.includes(options.ai)) {
console.log(chalk.red(`❌ AI 配置 "${options.ai}" 未安装`));
process.exit(1);
}
targetAI = [options.ai];
}
else if (!options.all) {
// 默认升级所有已安装的 AI 配置
targetAI = installedAI;
}
const targetDisplayNames = targetAI.map(name => {
const config = AI_CONFIGS.find(c => c.name === name);
return config?.displayName || name;
});
console.log(chalk.cyan(`\n升级目标: ${targetDisplayNames.join(', ')}\n`));
// 4. 确定要更新的内容
let updateContent;
if (options.interactive) {
// 交互式选择
updateContent = await selectUpdateContentInteractive();
}
else {
// 根据选项确定更新内容
const hasSpecificOption = options.commands || options.scripts || options.spec || options.experts || options.templates || options.memory;
updateContent = {
commands: hasSpecificOption ? !!options.commands : true,
scripts: hasSpecificOption ? !!options.scripts : true,
spec: hasSpecificOption ? !!options.spec : true,
experts: hasSpecificOption ? !!options.experts : false,
templates: hasSpecificOption ? !!options.templates : false,
memory: hasSpecificOption ? !!options.memory : false
};
}
// 显示将要更新的内容
const updateList = [];
if (updateContent.commands)
updateList.push('命令文件');
if (updateContent.scripts)
updateList.push('脚本文件');
if (updateContent.spec)
updateList.push('写作规范和预设');
if (updateContent.experts)
updateList.push('专家模式');
if (updateContent.templates)
updateList.push('模板文件');
if (updateContent.memory)
updateList.push('记忆文件');
console.log(chalk.cyan(`更新内容: ${updateList.join(', ')}\n`));
if (options.dryRun) {
console.log(chalk.yellow('🔍 预览模式(不会实际修改文件)\n'));
}
// 5. 确认执行
if (!options.yes && !options.dryRun && !options.interactive) {
const inquirer = (await import('inquirer')).default;
const answers = await inquirer.prompt([
{
type: 'confirm',
name: 'proceed',
message: '确认执行升级?',
default: true
}
]);
if (!answers.proceed) {
console.log(chalk.yellow('\n升级已取消'));
process.exit(0);
}
}
// 6. 创建备份
let backupPath = '';
if (options.backup !== false && !options.dryRun) {
backupPath = await createBackup(projectPath, updateContent, targetAI, projectVersion);
}
// 7. 执行更新
const stats = {
commands: 0,
scripts: 0,
templates: 0,
memory: 0,
spec: 0,
experts: 0,
platforms: targetDisplayNames
};
const dryRun = !!options.dryRun;
if (updateContent.commands) {
console.log(chalk.cyan('📝 更新命令文件...'));
stats.commands = await updateCommands(targetAI, projectPath, packageRoot, dryRun);
}
if (updateContent.scripts) {
console.log(chalk.cyan('\n🔧 更新脚本文件...'));
stats.scripts = await updateScripts(projectPath, packageRoot, dryRun);
}
if (updateContent.spec) {