book.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import { Command } from "commander";
  2. import { access, readFile, rm } from "node:fs/promises";
  3. import { createInterface } from "node:readline";
  4. import { join, resolve } from "node:path";
  5. import { PipelineRunner, StateManager, type BookConfig } from "@actalk/inkos-core";
  6. import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
  7. export const bookCommand = new Command("book")
  8. .description("Manage books");
  9. bookCommand
  10. .command("create")
  11. .description("Create a new book with AI-generated foundation")
  12. .requiredOption("--title <title>", "Book title")
  13. .option("--genre <genre>", "Genre", "xuanhuan")
  14. .option("--platform <platform>", "Target platform", "tomato")
  15. .option("--target-chapters <n>", "Target chapter count", "200")
  16. .option("--chapter-words <n>", "Words per chapter", "3000")
  17. .option("--brief <path>", "Path to creative brief file (.md/.txt) — Architect builds from your ideas instead of generating from scratch")
  18. .option("--lang <language>", "Writing language: zh (Chinese) or en (English). Defaults from genre.")
  19. .option("--json", "Output JSON")
  20. .action(async (opts) => {
  21. try {
  22. const config = await loadConfig();
  23. const root = findProjectRoot();
  24. const bookId = opts.title
  25. .toLowerCase()
  26. .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
  27. .replace(/-+/g, "-")
  28. .slice(0, 30);
  29. const now = new Date().toISOString();
  30. const book: BookConfig = {
  31. id: bookId,
  32. title: opts.title,
  33. platform: opts.platform,
  34. genre: opts.genre,
  35. status: "outlining",
  36. targetChapters: parseInt(opts.targetChapters, 10),
  37. chapterWordCount: parseInt(opts.chapterWords, 10),
  38. language: opts.lang ?? config.language,
  39. createdAt: now,
  40. updatedAt: now,
  41. };
  42. const bookDir = join(root, "books", bookId);
  43. try {
  44. await access(bookDir);
  45. // Directory exists — check if it's a complete book or an interrupted init
  46. try {
  47. await access(join(bookDir, "story", "story_bible.md"));
  48. throw new Error(`Book "${bookId}" already exists at books/${bookId}/. Use a different title or delete the existing book first.`);
  49. } catch (inner) {
  50. if (inner instanceof Error && inner.message.includes("already exists")) throw inner;
  51. // story_bible.md missing → incomplete init, allow re-run
  52. if (!opts.json) log(`Resuming incomplete book creation for "${bookId}"...`);
  53. }
  54. } catch (e) {
  55. if (e instanceof Error && e.message.includes("already exists")) throw e;
  56. // Directory doesn't exist, good
  57. }
  58. if (!opts.json) log(`Creating book "${book.title}" (${book.genre} / ${book.platform})...`);
  59. const brief = opts.brief
  60. ? await readFile(resolve(opts.brief), "utf-8")
  61. : undefined;
  62. const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: brief }));
  63. await pipeline.initBook(book);
  64. if (opts.json) {
  65. log(JSON.stringify({
  66. bookId,
  67. title: book.title,
  68. genre: book.genre,
  69. platform: book.platform,
  70. location: `books/${bookId}/`,
  71. nextStep: `inkos write next ${bookId}`,
  72. }, null, 2));
  73. } else {
  74. log(`Book created: ${bookId}`);
  75. log(` Location: books/${bookId}/`);
  76. log(` Story bible, outline, book rules generated.`);
  77. log("");
  78. log(`Next: inkos write next ${bookId}`);
  79. }
  80. } catch (e) {
  81. if (opts.json) {
  82. log(JSON.stringify({ error: String(e) }));
  83. } else {
  84. logError(`Failed to create book: ${e}`);
  85. }
  86. process.exit(1);
  87. }
  88. });
  89. bookCommand
  90. .command("update")
  91. .description("Update book settings")
  92. .argument("[book-id]", "Book ID (auto-detected if only one book)")
  93. .option("--chapter-words <n>", "Words per chapter")
  94. .option("--target-chapters <n>", "Target chapter count")
  95. .option("--status <status>", "Book status (outlining/active/paused/completed)")
  96. .option("--lang <language>", "Writing language: zh or en")
  97. .option("--json", "Output JSON")
  98. .action(async (bookIdArg: string | undefined, opts) => {
  99. try {
  100. const root = findProjectRoot();
  101. const bookId = await resolveBookId(bookIdArg, root);
  102. const state = new StateManager(root);
  103. const book = await state.loadBookConfig(bookId);
  104. const updates: Record<string, unknown> = {};
  105. if (opts.chapterWords) updates.chapterWordCount = parseInt(opts.chapterWords, 10);
  106. if (opts.targetChapters) updates.targetChapters = parseInt(opts.targetChapters, 10);
  107. if (opts.status) updates.status = opts.status;
  108. if (opts.lang) updates.language = opts.lang;
  109. if (Object.keys(updates).length === 0) {
  110. if (opts.json) {
  111. log(JSON.stringify(book, null, 2));
  112. } else {
  113. log(`Book: ${book.title} (${bookId})`);
  114. log(` Words/chapter: ${book.chapterWordCount}`);
  115. log(` Target chapters: ${book.targetChapters}`);
  116. log(` Status: ${book.status}`);
  117. log(` Genre: ${book.genre} | Platform: ${book.platform}`);
  118. }
  119. return;
  120. }
  121. const updated: BookConfig = {
  122. ...book,
  123. ...updates,
  124. updatedAt: new Date().toISOString(),
  125. };
  126. await state.saveBookConfig(bookId, updated);
  127. if (opts.json) {
  128. log(JSON.stringify(updated, null, 2));
  129. } else {
  130. for (const [key, value] of Object.entries(updates)) {
  131. log(` ${key}: ${(book as Record<string, unknown>)[key]} → ${value}`);
  132. }
  133. }
  134. } catch (e) {
  135. if (opts.json) {
  136. log(JSON.stringify({ error: String(e) }));
  137. } else {
  138. logError(`Failed to update book: ${e}`);
  139. }
  140. process.exit(1);
  141. }
  142. });
  143. bookCommand
  144. .command("list")
  145. .description("List all books")
  146. .option("--json", "Output JSON")
  147. .action(async (opts) => {
  148. try {
  149. const root = findProjectRoot();
  150. const state = new StateManager(root);
  151. const bookIds = await state.listBooks();
  152. if (bookIds.length === 0) {
  153. if (opts.json) {
  154. log(JSON.stringify({ books: [] }));
  155. } else {
  156. log("No books found. Create one with: inkos book create --title '...'");
  157. }
  158. return;
  159. }
  160. const books = [];
  161. for (const id of bookIds) {
  162. const book = await state.loadBookConfig(id);
  163. const nextChapter = await state.getNextChapterNumber(id);
  164. const info = {
  165. id,
  166. title: book.title,
  167. genre: book.genre,
  168. platform: book.platform,
  169. status: book.status,
  170. chapters: nextChapter - 1,
  171. };
  172. books.push(info);
  173. if (!opts.json) {
  174. log(` ${id} | ${book.title} | ${book.genre}/${book.platform} | ${book.status} | chapters: ${nextChapter - 1}`);
  175. }
  176. }
  177. if (opts.json) {
  178. log(JSON.stringify({ books }, null, 2));
  179. }
  180. } catch (e) {
  181. if (opts.json) {
  182. log(JSON.stringify({ error: String(e) }));
  183. } else {
  184. logError(`Failed to list books: ${e}`);
  185. }
  186. process.exit(1);
  187. }
  188. });
  189. bookCommand
  190. .command("delete")
  191. .description("Delete a book and all its chapters, truth files, and snapshots")
  192. .argument("<book-id>", "Book ID to delete")
  193. .option("--force", "Skip confirmation prompt")
  194. .option("--json", "Output JSON")
  195. .action(async (bookId: string, opts) => {
  196. try {
  197. const root = findProjectRoot();
  198. const state = new StateManager(root);
  199. const allBooks = await state.listBooks();
  200. if (!allBooks.includes(bookId)) {
  201. throw new Error(`Book "${bookId}" not found. Available: ${allBooks.join(", ") || "(none)"}`);
  202. }
  203. const book = await state.loadBookConfig(bookId);
  204. const index = await state.loadChapterIndex(bookId);
  205. if (!opts.force) {
  206. const rl = createInterface({ input: process.stdin, output: process.stdout });
  207. const answer = await new Promise<string>((resolve) => {
  208. rl.question(
  209. `Delete "${book.title}" (${bookId})? This will remove ${index.length} chapter(s) and all data. (y/N) `,
  210. resolve,
  211. );
  212. });
  213. rl.close();
  214. if (answer.toLowerCase() !== "y") {
  215. log("Cancelled.");
  216. return;
  217. }
  218. }
  219. const bookDir = join(root, "books", bookId);
  220. await rm(bookDir, { recursive: true, force: true });
  221. if (opts.json) {
  222. log(JSON.stringify({ deleted: bookId, chapters: index.length }));
  223. } else {
  224. log(`Deleted "${book.title}" (${bookId}): ${index.length} chapter(s) removed.`);
  225. }
  226. } catch (e) {
  227. if (opts.json) {
  228. log(JSON.stringify({ error: String(e) }));
  229. } else {
  230. logError(`Failed to delete book: ${e}`);
  231. }
  232. process.exit(1);
  233. }
  234. });