book.ts 6.7 KB

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