genre.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import { Command } from "commander";
  2. import { writeFile, mkdir, readFile } from "node:fs/promises";
  3. import { join } from "node:path";
  4. import { listAvailableGenres, readGenreProfile, getBuiltinGenresDir } from "@actalk/inkos-core";
  5. import { findProjectRoot, log, logError } from "../utils.js";
  6. export const genreCommand = new Command("genre")
  7. .description("Manage genre profiles");
  8. genreCommand
  9. .command("list")
  10. .description("List all available genre profiles (built-in + project)")
  11. .action(async () => {
  12. try {
  13. const root = findProjectRoot();
  14. const genres = await listAvailableGenres(root);
  15. if (genres.length === 0) {
  16. log("No genre profiles found.");
  17. return;
  18. }
  19. log("Available genres:\n");
  20. for (const g of genres) {
  21. const tag = g.source === "project" ? "[project]" : "[builtin]";
  22. log(` ${g.id.padEnd(12)} ${g.name.padEnd(8)} ${tag}`);
  23. }
  24. log(`\nTotal: ${genres.length} genre(s)`);
  25. } catch (e) {
  26. logError(`Failed to list genres: ${e}`);
  27. process.exit(1);
  28. }
  29. });
  30. genreCommand
  31. .command("show")
  32. .description("Display a genre profile")
  33. .argument("<id>", "Genre ID (e.g. xuanhuan, urban, horror)")
  34. .action(async (id: string) => {
  35. try {
  36. const root = findProjectRoot();
  37. const genres = await listAvailableGenres(root);
  38. const exactMatch = genres.some(g => g.id === id);
  39. if (!exactMatch) {
  40. logError(`Genre "${id}" not found. Available: ${genres.map(g => g.id).join(", ")}`);
  41. process.exit(1);
  42. }
  43. const { profile, body } = await readGenreProfile(root, id);
  44. log(`Genre: ${profile.name} (${profile.id})\n`);
  45. log(` Chapter types: ${profile.chapterTypes.join(", ")}`);
  46. log(` Fatigue words: ${profile.fatigueWords.join(", ")}`);
  47. log(` Numerical system: ${profile.numericalSystem}`);
  48. log(` Power scaling: ${profile.powerScaling}`);
  49. log(` Era research: ${profile.eraResearch}`);
  50. log(` Pacing rule: ${profile.pacingRule}`);
  51. log(` Satisfaction types: ${profile.satisfactionTypes.join(", ")}`);
  52. log(` Audit dimensions: ${profile.auditDimensions.join(", ")}`);
  53. if (body) {
  54. log(`\n--- Body ---\n${body}`);
  55. }
  56. } catch (e) {
  57. logError(`Failed to show genre: ${e}`);
  58. process.exit(1);
  59. }
  60. });
  61. genreCommand
  62. .command("create")
  63. .description("Scaffold a new genre profile in the project genres/ directory")
  64. .argument("<id>", "Genre ID (e.g. scifi, wuxia, romance)")
  65. .option("--name <name>", "Genre display name", "")
  66. .option("--numerical", "Enable numerical system", false)
  67. .option("--power", "Enable power scaling", false)
  68. .option("--era", "Enable era research", false)
  69. .action(async (id: string, opts) => {
  70. try {
  71. const root = findProjectRoot();
  72. const genresDir = join(root, "genres");
  73. const filePath = join(genresDir, `${id}.md`);
  74. // Check if already exists
  75. try {
  76. await readFile(filePath, "utf-8");
  77. logError(`Genre profile already exists: ${filePath}`);
  78. process.exit(1);
  79. } catch { /* file doesn't exist, good */ }
  80. await mkdir(genresDir, { recursive: true });
  81. const name = opts.name || id;
  82. const template = `---
  83. name: ${name}
  84. id: ${id}
  85. chapterTypes: ["推进章", "布局章", "过渡章", "回收章"]
  86. fatigueWords: ["震惊", "不可思议", "难以置信"]
  87. numericalSystem: ${opts.numerical}
  88. powerScaling: ${opts.power}
  89. eraResearch: ${opts.era}
  90. pacingRule: "每2-3章有一个明确的进展或反馈"
  91. satisfactionTypes: ["目标达成", "困难克服", "真相揭示"]
  92. auditDimensions: [1,2,3,6,7,8,9,10,13,14,15,16,17,18,19]
  93. ---
  94. ## 题材禁忌
  95. - (根据题材添加禁忌)
  96. ## 叙事指导
  97. (根据题材描述叙事重心和风格要求)
  98. `;
  99. await writeFile(filePath, template, "utf-8");
  100. log(`Created genre profile: ${filePath}`);
  101. log(`Edit the file to customize chapter types, fatigue words, rules, etc.`);
  102. } catch (e) {
  103. logError(`Failed to create genre: ${e}`);
  104. process.exit(1);
  105. }
  106. });
  107. genreCommand
  108. .command("copy")
  109. .description("Copy a built-in genre profile to project for customization")
  110. .argument("<id>", "Genre ID to copy (e.g. xuanhuan)")
  111. .action(async (id: string) => {
  112. try {
  113. const root = findProjectRoot();
  114. const builtinDir = getBuiltinGenresDir();
  115. const srcPath = join(builtinDir, `${id}.md`);
  116. const genresDir = join(root, "genres");
  117. const destPath = join(genresDir, `${id}.md`);
  118. // Check if project override already exists
  119. try {
  120. await readFile(destPath, "utf-8");
  121. logError(`Project genre profile already exists: ${destPath}`);
  122. process.exit(1);
  123. } catch { /* doesn't exist, good */ }
  124. let content: string;
  125. try {
  126. content = await readFile(srcPath, "utf-8");
  127. } catch {
  128. logError(`Built-in genre "${id}" not found. Use 'inkos genre list' to see available genres.`);
  129. process.exit(1);
  130. return;
  131. }
  132. await mkdir(genresDir, { recursive: true });
  133. await writeFile(destPath, content, "utf-8");
  134. log(`Copied to: ${destPath}`);
  135. log(`This project-level copy will override the built-in profile.`);
  136. } catch (e) {
  137. logError(`Failed to copy genre: ${e}`);
  138. process.exit(1);
  139. }
  140. });