ソースを参照

feat: CLI UX improvements — auto-detect book-id, --json everywhere, update command

- book-id auto-detection: all commands that take [book-id] now auto-detect
  when only one book exists. Error messages list available books.
- --json output added to: book create, book list, status, review list,
  review approve/reject/approve-all, export, radar scan
- write rewrite now requires confirmation (--force to skip)
- inkos update command for self-updating via npm
- Early book-id validation with actionable error messages
- Version bump to 0.3.3
majx_mac 8 ヶ月 前
親
コミット
930892add1

+ 14 - 13
README.en.md

@@ -257,28 +257,29 @@ inkos up                          # Daemon mode
 
 | Command | Description |
 |---------|-------------|
-| `inkos init` | Initialize a new project |
+| `inkos init [name]` | Initialize project (omit name to init current directory) |
 | `inkos book create` | Create a new book (generates worldbuilding + outline + book rules) |
 | `inkos book list` | List all books |
 | `inkos genre list/show/copy/create` | View, copy, or create genres |
-| `inkos write next <id>` | Full pipeline: write next chapter |
-| `inkos write rewrite <id> <n>` | Rewrite chapter N (restores state snapshot) |
-| `inkos draft <id>` | Write draft only (no audit or revision) |
-| `inkos audit <id> [n]` | Audit a specific chapter |
-| `inkos revise <id> [n]` | Revise a specific chapter |
+| `inkos write next [id]` | Full pipeline: write next chapter |
+| `inkos write rewrite [id] <n>` | Rewrite chapter N (restores state snapshot, requires confirmation) |
+| `inkos draft [id]` | Write draft only (no audit or revision) |
+| `inkos audit [id] [n]` | Audit a specific chapter |
+| `inkos revise [id] [n]` | Revise a specific chapter |
 | `inkos agent <instruction>` | Natural language agent mode |
-| `inkos review list/approve/reject` | Review drafts |
-| `inkos review approve-all <id>` | Batch approve |
-| `inkos status` | Project status |
-| `inkos export <id>` | Export book to txt/md |
+| `inkos review list [id]` | Review drafts |
+| `inkos review approve-all [id]` | Batch approve |
+| `inkos status [id]` | Project status |
+| `inkos export [id]` | Export book to txt/md |
 | `inkos radar scan` | Scan platform trends |
 | `inkos config set-global` | Set global LLM config (~/.inkos/.env) |
 | `inkos config show-global` | Show global config |
 | `inkos config set/show` | View/update project config |
 | `inkos doctor` | Diagnose setup issues (includes API connectivity test) |
+| `inkos update` | Update to latest version |
 | `inkos up / down` | Start/stop daemon |
 
-All commands support `--json` for structured output. `draft`/`write next`/`book create` support `--context` for writing guidance.
+`[id]` is auto-detected when the project has only one book. All commands support `--json` for structured output. `draft`/`write next`/`book create` support `--context` for writing guidance.
 
 ## Key Features
 
@@ -322,7 +323,7 @@ inkos/
 │   │   ├── llm/           # OpenAI + Anthropic dual SDK (streaming)
 │   │   ├── notify/        # Telegram, Feishu, WeCom
 │   │   └── models/        # Zod schema validation
-│   └── cli/               # Commander.js CLI (15 commands)
+│   └── cli/               # Commander.js CLI (16 commands)
 │       └── commands/      # init, book, write, draft, audit, revise, agent, review, status, export...
 └── (planned) studio/      # Web UI for review and editing
 ```
@@ -334,7 +335,7 @@ TypeScript monorepo managed with pnpm workspaces.
 - [x] Full pipeline (radar → architect → writer → auditor → reviser)
 - [x] Canonical truth files + continuity audit
 - [x] Built-in writing rule system
-- [x] Full CLI (15 commands)
+- [x] Full CLI (16 commands)
 - [x] State snapshots + chapter rewrite
 - [x] Daemon mode
 - [x] Notifications (Telegram / Feishu / WeCom)

+ 13 - 12
README.md

@@ -257,28 +257,29 @@ inkos up                       # 守护进程模式
 
 | 命令 | 说明 |
 |------|------|
-| `inkos init` | 初始化项目 |
+| `inkos init [name]` | 初始化项目(省略 name 在当前目录初始化) |
 | `inkos book create` | 创建新书(生成世界观 + 卷纲 + 本书规则) |
 | `inkos book list` | 列出所有书籍 |
 | `inkos genre list/show/copy/create` | 查看、复制、创建题材 |
-| `inkos write next <id>` | 完整管线写下一章 |
-| `inkos write rewrite <id> <n>` | 重写第 N 章(恢复状态快照) |
-| `inkos draft <id>` | 只写草稿(不审不改) |
-| `inkos audit <id> [n]` | 审计指定章节 |
-| `inkos revise <id> [n]` | 修订指定章节 |
+| `inkos write next [id]` | 完整管线写下一章 |
+| `inkos write rewrite [id] <n>` | 重写第 N 章(恢复状态快照,需确认) |
+| `inkos draft [id]` | 只写草稿(不审不改) |
+| `inkos audit [id] [n]` | 审计指定章节 |
+| `inkos revise [id] [n]` | 修订指定章节 |
 | `inkos agent <instruction>` | 自然语言 Agent 模式 |
-| `inkos review list/approve/reject` | 审阅草稿 |
-| `inkos review approve-all <id>` | 批量通过 |
-| `inkos status` | 项目状态 |
-| `inkos export <id>` | 导出书籍为 txt/md |
+| `inkos review list [id]` | 审阅草稿 |
+| `inkos review approve-all [id]` | 批量通过 |
+| `inkos status [id]` | 项目状态 |
+| `inkos export [id]` | 导出书籍为 txt/md |
 | `inkos radar scan` | 扫描平台趋势 |
 | `inkos config set-global` | 设置全局 LLM 配置(~/.inkos/.env) |
 | `inkos config show-global` | 查看全局配置 |
 | `inkos config set/show` | 查看/更新项目配置 |
 | `inkos doctor` | 诊断配置问题(含 API 连通性测试) |
+| `inkos update` | 更新到最新版本 |
 | `inkos up / down` | 启动/停止守护进程 |
 
-所有命令支持 `--json` 输出结构化数据,`draft`/`write next`/`book create` 支持 `--context` 传入创作指导。
+`[id]` 参数在项目只有一本书时可省略,自动检测。所有命令支持 `--json` 输出结构化数据,`draft`/`write next`/`book create` 支持 `--context` 传入创作指导。
 
 ## 实测数据
 
@@ -340,7 +341,7 @@ inkos/
 │   │   ├── llm/           # OpenAI + Anthropic 双 SDK 接口 (流式)
 │   │   ├── notify/        # Telegram, 飞书, 企业微信
 │   │   └── models/        # Zod schema 校验
-│   └── cli/               # Commander.js 命令行 (15 条命令)
+│   └── cli/               # Commander.js 命令行 (16 条命令)
 │       └── commands/      # init, book, write, draft, audit, revise, agent, review, status, export...
 └── (规划中) studio/        # 网页审阅编辑界面
 ```

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "inkos",
-  "version": "0.3.2",
+  "version": "0.3.3",
   "private": true,
   "description": "Multi-agent novel production system",
   "license": "MIT",

+ 1 - 1
packages/cli/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@actalk/inkos",
-  "version": "0.3.2",
+  "version": "0.3.3",
   "description": "InkOS CLI — multi-agent novel production system",
   "type": "module",
   "bin": {

+ 14 - 4
packages/cli/src/commands/audit.ts

@@ -1,25 +1,35 @@
 import { Command } from "commander";
 import { PipelineRunner } from "@actalk/inkos-core";
-import { loadConfig, createClient, findProjectRoot, log, logError } from "../utils.js";
+import { loadConfig, createClient, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const auditCommand = new Command("audit")
   .description("Audit a chapter for continuity issues")
-  .argument("<book-id>", "Book ID")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .argument("[chapter]", "Chapter number (defaults to latest)")
   .option("--json", "Output JSON")
-  .action(async (bookId: string, chapterStr: string | undefined, opts) => {
+  .action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
     try {
       const config = await loadConfig();
       const client = createClient(config);
       const root = findProjectRoot();
 
+      // If first arg looks like a number, treat it as chapter (auto-detect book)
+      let bookId: string;
+      let chapterNumber: number | undefined;
+      if (bookIdArg && /^\d+$/.test(bookIdArg)) {
+        bookId = await resolveBookId(undefined, root);
+        chapterNumber = parseInt(bookIdArg, 10);
+      } else {
+        bookId = await resolveBookId(bookIdArg, root);
+        chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
+      }
+
       const pipeline = new PipelineRunner({
         client,
         model: config.llm.model,
         projectRoot: root,
       });
 
-      const chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
       if (!opts.json) log(`Auditing "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"}...`);
 
       const result = await pipeline.auditDraft(bookId, chapterNumber);

+ 52 - 11
packages/cli/src/commands/book.ts

@@ -15,6 +15,7 @@ bookCommand
   .option("--chapter-words <n>", "Words per chapter", "3000")
   .option("--context <text>", "External context / instructions (natural language)")
   .option("--context-file <path>", "Read external context from file")
+  .option("--json", "Output JSON")
   .action(async (opts) => {
     try {
       const config = await loadConfig();
@@ -40,7 +41,7 @@ bookCommand
         updatedAt: now,
       };
 
-      log(`Creating book "${book.title}" (${book.genre} / ${book.platform})...`);
+      if (!opts.json) log(`Creating book "${book.title}" (${book.genre} / ${book.platform})...`);
 
       const context = await resolveContext(opts);
 
@@ -53,13 +54,28 @@ bookCommand
 
       await pipeline.initBook(book);
 
-      log(`Book created: ${bookId}`);
-      log(`  Location: books/${bookId}/`);
-      log(`  Story bible, outline, style guide generated.`);
-      log("");
-      log(`Next: inkos write next ${bookId}`);
+      if (opts.json) {
+        log(JSON.stringify({
+          bookId,
+          title: book.title,
+          genre: book.genre,
+          platform: book.platform,
+          location: `books/${bookId}/`,
+          nextStep: `inkos write next ${bookId}`,
+        }, null, 2));
+      } else {
+        log(`Book created: ${bookId}`);
+        log(`  Location: books/${bookId}/`);
+        log(`  Story bible, outline, book rules generated.`);
+        log("");
+        log(`Next: inkos write next ${bookId}`);
+      }
     } catch (e) {
-      logError(`Failed to create book: ${e}`);
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to create book: ${e}`);
+      }
       process.exit(1);
     }
   });
@@ -67,24 +83,49 @@ bookCommand
 bookCommand
   .command("list")
   .description("List all books")
-  .action(async () => {
+  .option("--json", "Output JSON")
+  .action(async (opts) => {
     try {
       const root = findProjectRoot();
       const state = new StateManager(root);
       const bookIds = await state.listBooks();
 
       if (bookIds.length === 0) {
-        log("No books found. Create one with: inkos book create --title '...'");
+        if (opts.json) {
+          log(JSON.stringify({ books: [] }));
+        } else {
+          log("No books found. Create one with: inkos book create --title '...'");
+        }
         return;
       }
 
+      const books = [];
       for (const id of bookIds) {
         const book = await state.loadBookConfig(id);
         const nextChapter = await state.getNextChapterNumber(id);
-        log(`  ${id} | ${book.title} | ${book.genre}/${book.platform} | ${book.status} | chapters: ${nextChapter - 1}`);
+        const info = {
+          id,
+          title: book.title,
+          genre: book.genre,
+          platform: book.platform,
+          status: book.status,
+          chapters: nextChapter - 1,
+        };
+        books.push(info);
+        if (!opts.json) {
+          log(`  ${id} | ${book.title} | ${book.genre}/${book.platform} | ${book.status} | chapters: ${nextChapter - 1}`);
+        }
+      }
+
+      if (opts.json) {
+        log(JSON.stringify({ books }, null, 2));
       }
     } catch (e) {
-      logError(`Failed to list books: ${e}`);
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to list books: ${e}`);
+      }
       process.exit(1);
     }
   });

+ 4 - 3
packages/cli/src/commands/draft.ts

@@ -1,18 +1,19 @@
 import { Command } from "commander";
 import { PipelineRunner } from "@actalk/inkos-core";
-import { loadConfig, createClient, findProjectRoot, resolveContext, log, logError } from "../utils.js";
+import { loadConfig, createClient, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js";
 
 export const draftCommand = new Command("draft")
   .description("Write a draft chapter (no audit/revise)")
-  .argument("<book-id>", "Book ID")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .option("--context <text>", "Creative guidance (natural language)")
   .option("--context-file <path>", "Read guidance from file")
   .option("--json", "Output JSON")
-  .action(async (bookId: string, opts) => {
+  .action(async (bookIdArg: string | undefined, opts) => {
     try {
       const config = await loadConfig();
       const client = createClient(config);
       const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
       const context = await resolveContext(opts);
 
       const pipeline = new PipelineRunner({

+ 28 - 7
packages/cli/src/commands/export.ts

@@ -2,17 +2,19 @@ import { Command } from "commander";
 import { StateManager } from "@actalk/inkos-core";
 import { readFile, readdir, writeFile } from "node:fs/promises";
 import { join } from "node:path";
-import { findProjectRoot, log, logError } from "../utils.js";
+import { findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const exportCommand = new Command("export")
   .description("Export book chapters to a single file")
-  .argument("<book-id>", "Book ID")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .option("--format <format>", "Output format (txt, md)", "txt")
   .option("--output <path>", "Output file path")
   .option("--approved-only", "Only export approved chapters")
-  .action(async (bookId: string, opts) => {
+  .option("--json", "Output JSON metadata")
+  .action(async (bookIdArg: string | undefined, opts) => {
     try {
       const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
       const state = new StateManager(root);
 
       const book = await state.loadBookConfig(bookId);
@@ -25,7 +27,12 @@ export const exportCommand = new Command("export")
         : index;
 
       if (chapters.length === 0) {
-        logError("No chapters to export.");
+        const msg = "No chapters to export.";
+        if (opts.json) {
+          log(JSON.stringify({ error: msg }));
+        } else {
+          logError(msg);
+        }
         process.exit(1);
       }
 
@@ -55,10 +62,24 @@ export const exportCommand = new Command("export")
         opts.output ?? join(root, `${bookId}_export.${opts.format}`);
       await writeFile(outputPath, parts.join("\n"), "utf-8");
 
-      log(`Exported ${chapters.length} chapters (${totalWords} words)`);
-      log(`Output: ${outputPath}`);
+      if (opts.json) {
+        log(JSON.stringify({
+          bookId,
+          chaptersExported: chapters.length,
+          totalWords,
+          format: opts.format,
+          outputPath,
+        }, null, 2));
+      } else {
+        log(`Exported ${chapters.length} chapters (${totalWords} words)`);
+        log(`Output: ${outputPath}`);
+      }
     } catch (e) {
-      logError(`Failed to export: ${e}`);
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to export: ${e}`);
+      }
       process.exit(1);
     }
   });

+ 26 - 16
packages/cli/src/commands/radar.ts

@@ -10,7 +10,8 @@ export const radarCommand = new Command("radar")
 radarCommand
   .command("scan")
   .description("Scan market for opportunities")
-  .action(async () => {
+  .option("--json", "Output JSON")
+  .action(async (opts) => {
     try {
       const config = await loadConfig();
       const client = createClient(config);
@@ -22,34 +23,43 @@ radarCommand
         projectRoot: root,
       });
 
-      log("Scanning market...");
+      if (!opts.json) log("Scanning market...");
 
       const result = await pipeline.runRadar();
 
-      log(`\nMarket Summary:\n${result.marketSummary}\n`);
-      log("Recommendations:");
-
-      for (const rec of result.recommendations) {
-        log(`  [${(rec.confidence * 100).toFixed(0)}%] ${rec.platform}/${rec.genre}`);
-        log(`    Concept: ${rec.concept}`);
-        log(`    Reasoning: ${rec.reasoning}`);
-        log(`    Benchmarks: ${rec.benchmarkTitles.join(", ")}`);
-        log("");
-      }
-
       // Save radar result
       const radarDir = join(root, "radar");
       await mkdir(radarDir, { recursive: true });
       const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+      const filePath = join(radarDir, `scan-${timestamp}.json`);
       await writeFile(
-        join(radarDir, `scan-${timestamp}.json`),
+        filePath,
         JSON.stringify(result, null, 2),
         "utf-8",
       );
 
-      log(`Radar result saved to radar/scan-${timestamp}.json`);
+      if (opts.json) {
+        log(JSON.stringify({ ...result, savedTo: filePath }, null, 2));
+      } else {
+        log(`\nMarket Summary:\n${result.marketSummary}\n`);
+        log("Recommendations:");
+
+        for (const rec of result.recommendations) {
+          log(`  [${(rec.confidence * 100).toFixed(0)}%] ${rec.platform}/${rec.genre}`);
+          log(`    Concept: ${rec.concept}`);
+          log(`    Reasoning: ${rec.reasoning}`);
+          log(`    Benchmarks: ${rec.benchmarkTitles.join(", ")}`);
+          log("");
+        }
+
+        log(`Radar result saved to radar/scan-${timestamp}.json`);
+      }
     } catch (e) {
-      logError(`Radar scan failed: ${e}`);
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Radar scan failed: ${e}`);
+      }
       process.exit(1);
     }
   });

+ 121 - 28
packages/cli/src/commands/review.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
 import { StateManager } from "@actalk/inkos-core";
-import { findProjectRoot, log, logError } from "../utils.js";
+import { findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const reviewCommand = new Command("review")
   .description("Review and approve chapters");
@@ -9,12 +9,22 @@ reviewCommand
   .command("list")
   .description("List chapters pending review")
   .argument("[book-id]", "Book ID (optional, lists all books if omitted)")
-  .action(async (bookId?: string) => {
+  .option("--json", "Output JSON")
+  .action(async (bookId: string | undefined, opts) => {
     try {
       const root = findProjectRoot();
       const state = new StateManager(root);
 
       const bookIds = bookId ? [bookId] : await state.listBooks();
+      const allPending: Array<{
+        readonly bookId: string;
+        readonly title: string;
+        readonly chapter: number;
+        readonly chapterTitle: string;
+        readonly wordCount: number;
+        readonly status: string;
+        readonly issues: ReadonlyArray<string>;
+      }> = [];
 
       for (const id of bookIds) {
         const index = await state.loadChapterIndex(id);
@@ -26,20 +36,44 @@ reviewCommand
         if (pending.length === 0) continue;
 
         const book = await state.loadBookConfig(id);
-        log(`\n${book.title} (${id}):`);
+
+        if (!opts.json) {
+          log(`\n${book.title} (${id}):`);
+        }
         for (const ch of pending) {
-          log(
-            `  Ch.${ch.number} "${ch.title}" | ${ch.wordCount}字 | ${ch.status}`,
-          );
-          if (ch.auditIssues.length > 0) {
-            for (const issue of ch.auditIssues) {
-              log(`    - ${issue}`);
+          allPending.push({
+            bookId: id,
+            title: book.title,
+            chapter: ch.number,
+            chapterTitle: ch.title,
+            wordCount: ch.wordCount,
+            status: ch.status,
+            issues: ch.auditIssues,
+          });
+          if (!opts.json) {
+            log(
+              `  Ch.${ch.number} "${ch.title}" | ${ch.wordCount}字 | ${ch.status}`,
+            );
+            if (ch.auditIssues.length > 0) {
+              for (const issue of ch.auditIssues) {
+                log(`    - ${issue}`);
+              }
             }
           }
         }
       }
+
+      if (opts.json) {
+        log(JSON.stringify({ pending: allPending }, null, 2));
+      } else if (allPending.length === 0) {
+        log("No chapters pending review.");
+      }
     } catch (e) {
-      logError(`Failed to list reviews: ${e}`);
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to list reviews: ${e}`);
+      }
       process.exit(1);
     }
   });
@@ -47,18 +81,33 @@ reviewCommand
 reviewCommand
   .command("approve")
   .description("Approve a chapter")
-  .argument("<book-id>", "Book ID")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .argument("<chapter>", "Chapter number")
-  .action(async (bookId: string, chapterStr: string) => {
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string, chapterStr: string, opts) => {
     try {
       const root = findProjectRoot();
-      const state = new StateManager(root);
-      const chapterNum = parseInt(chapterStr, 10);
 
+      let bookId: string;
+      let chapterNum: number;
+      if (!chapterStr || isNaN(parseInt(chapterStr, 10))) {
+        bookId = await resolveBookId(undefined, root);
+        chapterNum = parseInt(bookIdArg, 10);
+      } else {
+        bookId = await resolveBookId(bookIdArg, root);
+        chapterNum = parseInt(chapterStr, 10);
+      }
+
+      const state = new StateManager(root);
       const index = [...(await state.loadChapterIndex(bookId))];
       const idx = index.findIndex((ch) => ch.number === chapterNum);
       if (idx === -1) {
-        logError(`Chapter ${chapterNum} not found`);
+        const msg = `Chapter ${chapterNum} not found in "${bookId}"`;
+        if (opts.json) {
+          log(JSON.stringify({ error: msg }));
+        } else {
+          logError(msg);
+        }
         process.exit(1);
       }
 
@@ -68,9 +117,18 @@ reviewCommand
         updatedAt: new Date().toISOString(),
       };
       await state.saveChapterIndex(bookId, index);
-      log(`Chapter ${chapterNum} approved.`);
+
+      if (opts.json) {
+        log(JSON.stringify({ bookId, chapter: chapterNum, status: "approved" }));
+      } else {
+        log(`Chapter ${chapterNum} approved.`);
+      }
     } catch (e) {
-      logError(`Failed to approve: ${e}`);
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to approve: ${e}`);
+      }
       process.exit(1);
     }
   });
@@ -78,10 +136,12 @@ reviewCommand
 reviewCommand
   .command("approve-all")
   .description("Approve all pending chapters for a book")
-  .argument("<book-id>", "Book ID")
-  .action(async (bookId: string) => {
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string | undefined, opts) => {
     try {
       const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
       const state = new StateManager(root);
 
       const index = [...(await state.loadChapterIndex(bookId))];
@@ -97,9 +157,18 @@ reviewCommand
       });
 
       await state.saveChapterIndex(bookId, updated);
-      log(`${count} chapter(s) approved.`);
+
+      if (opts.json) {
+        log(JSON.stringify({ bookId, approvedCount: count }));
+      } else {
+        log(`${count} chapter(s) approved.`);
+      }
     } catch (e) {
-      logError(`Failed to approve: ${e}`);
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to approve: ${e}`);
+      }
       process.exit(1);
     }
   });
@@ -107,19 +176,34 @@ reviewCommand
 reviewCommand
   .command("reject")
   .description("Reject a chapter")
-  .argument("<book-id>", "Book ID")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .argument("<chapter>", "Chapter number")
   .option("--reason <reason>", "Rejection reason")
-  .action(async (bookId: string, chapterStr: string, opts) => {
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string, chapterStr: string, opts) => {
     try {
       const root = findProjectRoot();
-      const state = new StateManager(root);
-      const chapterNum = parseInt(chapterStr, 10);
 
+      let bookId: string;
+      let chapterNum: number;
+      if (!chapterStr || isNaN(parseInt(chapterStr, 10))) {
+        bookId = await resolveBookId(undefined, root);
+        chapterNum = parseInt(bookIdArg, 10);
+      } else {
+        bookId = await resolveBookId(bookIdArg, root);
+        chapterNum = parseInt(chapterStr, 10);
+      }
+
+      const state = new StateManager(root);
       const index = [...(await state.loadChapterIndex(bookId))];
       const idx = index.findIndex((ch) => ch.number === chapterNum);
       if (idx === -1) {
-        logError(`Chapter ${chapterNum} not found`);
+        const msg = `Chapter ${chapterNum} not found in "${bookId}"`;
+        if (opts.json) {
+          log(JSON.stringify({ error: msg }));
+        } else {
+          logError(msg);
+        }
         process.exit(1);
       }
 
@@ -130,9 +214,18 @@ reviewCommand
         updatedAt: new Date().toISOString(),
       };
       await state.saveChapterIndex(bookId, index);
-      log(`Chapter ${chapterNum} rejected.`);
+
+      if (opts.json) {
+        log(JSON.stringify({ bookId, chapter: chapterNum, status: "rejected" }));
+      } else {
+        log(`Chapter ${chapterNum} rejected.`);
+      }
     } catch (e) {
-      logError(`Failed to reject: ${e}`);
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to reject: ${e}`);
+      }
       process.exit(1);
     }
   });

+ 13 - 4
packages/cli/src/commands/revise.ts

@@ -1,26 +1,35 @@
 import { Command } from "commander";
 import { PipelineRunner, type ReviseMode } from "@actalk/inkos-core";
-import { loadConfig, createClient, findProjectRoot, log, logError } from "../utils.js";
+import { loadConfig, createClient, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const reviseCommand = new Command("revise")
   .description("Revise a chapter based on audit issues")
-  .argument("<book-id>", "Book ID")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .argument("[chapter]", "Chapter number (defaults to latest)")
   .option("--mode <mode>", "Revise mode: polish, rewrite, rework", "rewrite")
   .option("--json", "Output JSON")
-  .action(async (bookId: string, chapterStr: string | undefined, opts) => {
+  .action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
     try {
       const config = await loadConfig();
       const client = createClient(config);
       const root = findProjectRoot();
 
+      let bookId: string;
+      let chapterNumber: number | undefined;
+      if (bookIdArg && /^\d+$/.test(bookIdArg)) {
+        bookId = await resolveBookId(undefined, root);
+        chapterNumber = parseInt(bookIdArg, 10);
+      } else {
+        bookId = await resolveBookId(bookIdArg, root);
+        chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
+      }
+
       const pipeline = new PipelineRunner({
         client,
         model: config.llm.model,
         projectRoot: root,
       });
 
-      const chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
       const mode = opts.mode as ReviseMode;
       if (!opts.json) log(`Revising "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"} [mode: ${mode}]...`);
 

+ 55 - 14
packages/cli/src/commands/status.ts

@@ -4,16 +4,33 @@ import { findProjectRoot, log, logError } from "../utils.js";
 
 export const statusCommand = new Command("status")
   .description("Show project status")
-  .action(async () => {
+  .argument("[book-id]", "Book ID (optional, shows all if omitted)")
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string | undefined, opts) => {
     try {
       const root = findProjectRoot();
       const state = new StateManager(root);
 
-      const bookIds = await state.listBooks();
+      const allBookIds = await state.listBooks();
+      const bookIds = bookIdArg ? [bookIdArg] : allBookIds;
 
-      log(`InkOS Project: ${root}`);
-      log(`Books: ${bookIds.length}`);
-      log("");
+      if (bookIdArg && !allBookIds.includes(bookIdArg)) {
+        const msg = `Book "${bookIdArg}" not found. Available: ${allBookIds.join(", ") || "(none)"}`;
+        if (opts.json) {
+          log(JSON.stringify({ error: msg }));
+        } else {
+          logError(msg);
+        }
+        process.exit(1);
+      }
+
+      const booksData = [];
+
+      if (!opts.json) {
+        log(`InkOS Project: ${root}`);
+        log(`Books: ${allBookIds.length}`);
+        log("");
+      }
 
       for (const id of bookIds) {
         const book = await state.loadBookConfig(id);
@@ -27,20 +44,44 @@ export const statusCommand = new Command("status")
         const failed = index.filter(
           (ch) => ch.status === "audit-failed",
         ).length;
-
-        log(`  ${book.title} (${id})`);
-        log(`    Status: ${book.status}`);
-        log(`    Platform: ${book.platform} | Genre: ${book.genre}`);
         const totalWords = index.reduce((sum, ch) => sum + ch.wordCount, 0);
         const avgWords = index.length > 0 ? Math.round(totalWords / index.length) : 0;
 
-        log(`    Chapters: ${nextChapter - 1} / ${book.targetChapters}`);
-        log(`    Words: ${totalWords.toLocaleString()} (avg ${avgWords}/ch)`);
-        log(`    Approved: ${approved} | Pending: ${pending} | Failed: ${failed}`);
-        log("");
+        booksData.push({
+          id,
+          title: book.title,
+          status: book.status,
+          genre: book.genre,
+          platform: book.platform,
+          chapters: nextChapter - 1,
+          targetChapters: book.targetChapters,
+          totalWords,
+          avgWordsPerChapter: avgWords,
+          approved,
+          pending,
+          failed,
+        });
+
+        if (!opts.json) {
+          log(`  ${book.title} (${id})`);
+          log(`    Status: ${book.status}`);
+          log(`    Platform: ${book.platform} | Genre: ${book.genre}`);
+          log(`    Chapters: ${nextChapter - 1} / ${book.targetChapters}`);
+          log(`    Words: ${totalWords.toLocaleString()} (avg ${avgWords}/ch)`);
+          log(`    Approved: ${approved} | Pending: ${pending} | Failed: ${failed}`);
+          log("");
+        }
+      }
+
+      if (opts.json) {
+        log(JSON.stringify({ project: root, books: booksData }, null, 2));
       }
     } catch (e) {
-      logError(`Failed to get status: ${e}`);
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to get status: ${e}`);
+      }
       process.exit(1);
     }
   });

+ 34 - 0
packages/cli/src/commands/update.ts

@@ -0,0 +1,34 @@
+import { Command } from "commander";
+import { execSync } from "node:child_process";
+import { log, logError } from "../utils.js";
+
+export const updateCommand = new Command("update")
+  .description("Update InkOS to the latest version")
+  .action(async () => {
+    try {
+      log("Checking for updates...");
+
+      const current = execSync("npm list -g @actalk/inkos --depth=0 --json 2>/dev/null", {
+        encoding: "utf-8",
+      });
+      const currentVersion = JSON.parse(current)?.dependencies?.["@actalk/inkos"]?.version ?? "unknown";
+
+      log(`Current version: ${currentVersion}`);
+      log("Installing latest version...");
+
+      execSync("npm install -g @actalk/inkos@latest", {
+        stdio: "inherit",
+      });
+
+      const updated = execSync("inkos --version", { encoding: "utf-8" }).trim();
+      if (updated === currentVersion) {
+        log(`Already up to date (${updated}).`);
+      } else {
+        log(`Updated: ${currentVersion} → ${updated}`);
+      }
+    } catch (e) {
+      logError(`Update failed: ${e}`);
+      log("You can also update manually: npm install -g @actalk/inkos@latest");
+      process.exit(1);
+    }
+  });

+ 61 - 22
packages/cli/src/commands/write.ts

@@ -2,7 +2,8 @@ import { Command } from "commander";
 import { PipelineRunner, StateManager } from "@actalk/inkos-core";
 import { readdir, unlink } from "node:fs/promises";
 import { join } from "node:path";
-import { loadConfig, createClient, findProjectRoot, resolveContext, log, logError } from "../utils.js";
+import { createInterface } from "node:readline";
+import { loadConfig, createClient, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js";
 
 export const writeCommand = new Command("write")
   .description("Write chapters");
@@ -10,16 +11,17 @@ export const writeCommand = new Command("write")
 writeCommand
   .command("next")
   .description("Write the next chapter for a book")
-  .argument("<book-id>", "Book ID")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .option("--count <n>", "Number of chapters to write", "1")
   .option("--context <text>", "Creative guidance (natural language)")
   .option("--context-file <path>", "Read guidance from file")
   .option("--json", "Output JSON")
-  .action(async (bookId: string, opts) => {
+  .action(async (bookIdArg: string | undefined, opts) => {
     try {
       const config = await loadConfig();
       const client = createClient(config);
       const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
       const context = await resolveContext(opts);
 
       const pipeline = new PipelineRunner({
@@ -34,7 +36,7 @@ writeCommand
 
       const results = [];
       for (let i = 0; i < count; i++) {
-        if (!opts.json) log(`Writing chapter for "${bookId}"...`);
+        if (!opts.json) log(`[${i + 1}/${count}] Writing chapter for "${bookId}"...`);
 
         const result = await pipeline.writeNextChapter(bookId);
         results.push(result);
@@ -65,7 +67,11 @@ writeCommand
         log("Done.");
       }
     } catch (e) {
-      logError(`Failed to write chapter: ${e}`);
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to write chapter: ${e}`);
+      }
       process.exit(1);
     }
   });
@@ -73,54 +79,79 @@ writeCommand
 writeCommand
   .command("rewrite")
   .description("Re-generate a specific chapter (removes it and writes fresh)")
-  .argument("<book-id>", "Book ID")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .argument("<chapter>", "Chapter number to rewrite")
-  .action(async (bookId: string, chapterStr: string) => {
+  .option("--force", "Skip confirmation prompt")
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string, chapterStr: string, opts) => {
     try {
       const config = await loadConfig();
       const client = createClient(config);
       const root = findProjectRoot();
       const chapterNum = parseInt(chapterStr, 10);
 
+      // If only one argument, it's the chapter number (book-id auto-detected)
+      let bookId: string;
+      if (!chapterStr || isNaN(chapterNum)) {
+        // bookIdArg is actually the chapter number
+        bookId = await resolveBookId(undefined, root);
+        chapterStr = bookIdArg;
+      } else {
+        bookId = await resolveBookId(bookIdArg, root);
+      }
+      const chapter = parseInt(chapterStr, 10);
+
+      if (!opts.force) {
+        const rl = createInterface({ input: process.stdin, output: process.stdout });
+        const answer = await new Promise<string>((resolve) => {
+          rl.question(`Rewrite chapter ${chapter} of "${bookId}"? This will delete chapter ${chapter} and all later chapters. (y/N) `, resolve);
+        });
+        rl.close();
+        if (answer.toLowerCase() !== "y") {
+          log("Cancelled.");
+          return;
+        }
+      }
+
       const state = new StateManager(root);
       const bookDir = state.bookDir(bookId);
       const chaptersDir = join(bookDir, "chapters");
 
       // Remove existing chapter file
       const files = await readdir(chaptersDir);
-      const paddedNum = String(chapterNum).padStart(4, "0");
+      const paddedNum = String(chapter).padStart(4, "0");
       const existing = files.filter((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
       for (const f of existing) {
         await unlink(join(chaptersDir, f));
-        log(`Removed: ${f}`);
+        if (!opts.json) log(`Removed: ${f}`);
       }
 
       // Remove from index (and all chapters after it)
       const index = await state.loadChapterIndex(bookId);
-      const trimmed = index.filter((ch) => ch.number < chapterNum);
+      const trimmed = index.filter((ch) => ch.number < chapter);
       await state.saveChapterIndex(bookId, trimmed);
 
       // Also remove later chapter files since state will be rolled back
       const laterFiles = files.filter((f) => {
         const num = parseInt(f.slice(0, 4), 10);
-        return num > chapterNum && f.endsWith(".md");
+        return num > chapter && f.endsWith(".md");
       });
       for (const f of laterFiles) {
         await unlink(join(chaptersDir, f));
-        log(`Removed later chapter: ${f}`);
+        if (!opts.json) log(`Removed later chapter: ${f}`);
       }
 
       // Restore state to previous chapter's end-state
-      if (chapterNum > 1) {
-        const restored = await state.restoreState(bookId, chapterNum - 1);
+      if (chapter > 1) {
+        const restored = await state.restoreState(bookId, chapter - 1);
         if (restored) {
-          log(`State restored from chapter ${chapterNum - 1} snapshot.`);
+          if (!opts.json) log(`State restored from chapter ${chapter - 1} snapshot.`);
         } else {
-          log(`Warning: no snapshot for chapter ${chapterNum - 1}. Using current state.`);
+          if (!opts.json) log(`Warning: no snapshot for chapter ${chapter - 1}. Using current state.`);
         }
       }
 
-      log(`Regenerating chapter ${chapterNum}...`);
+      if (!opts.json) log(`Regenerating chapter ${chapter}...`);
 
       const pipeline = new PipelineRunner({
         client,
@@ -131,12 +162,20 @@ writeCommand
 
       const result = await pipeline.writeNextChapter(bookId);
 
-      log(`  Chapter ${result.chapterNumber}: ${result.title}`);
-      log(`  Words: ${result.wordCount}`);
-      log(`  Audit: ${result.auditResult.passed ? "PASSED" : "NEEDS REVIEW"}`);
-      log(`  Status: ${result.status}`);
+      if (opts.json) {
+        log(JSON.stringify(result, null, 2));
+      } else {
+        log(`  Chapter ${result.chapterNumber}: ${result.title}`);
+        log(`  Words: ${result.wordCount}`);
+        log(`  Audit: ${result.auditResult.passed ? "PASSED" : "NEEDS REVIEW"}`);
+        log(`  Status: ${result.status}`);
+      }
     } catch (e) {
-      logError(`Failed to rewrite chapter: ${e}`);
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to rewrite chapter: ${e}`);
+      }
       process.exit(1);
     }
   });

+ 3 - 1
packages/cli/src/index.ts

@@ -16,13 +16,14 @@ import { auditCommand } from "./commands/audit.js";
 import { reviseCommand } from "./commands/revise.js";
 import { agentCommand } from "./commands/agent.js";
 import { genreCommand } from "./commands/genre.js";
+import { updateCommand } from "./commands/update.js";
 
 const program = new Command();
 
 program
   .name("inkos")
   .description("InkOS — Multi-agent novel production system")
-  .version("0.3.2");
+  .version("0.3.3");
 
 program.addCommand(initCommand);
 program.addCommand(configCommand);
@@ -40,5 +41,6 @@ program.addCommand(auditCommand);
 program.addCommand(reviseCommand);
 program.addCommand(agentCommand);
 program.addCommand(genreCommand);
+program.addCommand(updateCommand);
 
 program.parse();

+ 35 - 1
packages/cli/src/utils.ts

@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
 import { homedir } from "node:os";
 import { join, resolve } from "node:path";
 import { config as loadEnv } from "dotenv";
-import { createLLMClient, type ProjectConfig, ProjectConfigSchema } from "@actalk/inkos-core";
+import { createLLMClient, StateManager, type ProjectConfig, ProjectConfigSchema } from "@actalk/inkos-core";
 
 export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
 export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
@@ -81,3 +81,37 @@ export function log(message: string): void {
 export function logError(message: string): void {
   process.stderr.write(`[ERROR] ${message}\n`);
 }
+
+/**
+ * Resolve book-id: if provided use it, otherwise auto-detect when exactly one book exists.
+ * Validates that the book actually exists.
+ */
+export async function resolveBookId(
+  bookIdArg: string | undefined,
+  root: string,
+): Promise<string> {
+  const state = new StateManager(root);
+  const books = await state.listBooks();
+
+  if (bookIdArg) {
+    if (!books.includes(bookIdArg)) {
+      const available = books.length > 0 ? books.join(", ") : "(none)";
+      throw new Error(
+        `Book "${bookIdArg}" not found. Available books: ${available}`,
+      );
+    }
+    return bookIdArg;
+  }
+
+  if (books.length === 0) {
+    throw new Error(
+      "No books found. Create one first:\n  inkos book create --title '...' --genre xuanhuan",
+    );
+  }
+  if (books.length === 1) {
+    return books[0]!;
+  }
+  throw new Error(
+    `Multiple books found: ${books.join(", ")}\nPlease specify a book-id.`,
+  );
+}

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@actalk/inkos-core",
-  "version": "0.3.2",
+  "version": "0.3.3",
   "description": "InkOS core: models, agents, pipeline, LLM integration",
   "type": "module",
   "main": "dist/index.js",