feat(cli): 章節與寫作命令(fox chapters/fox generate,issue #43)

- fox chapters list/get/create/update/delete:對接 #40 章節 CRUD 端點;
  公開命令(list/get)在持有 token 時自動帶上(作者本人可見 draft/hidden)。
- fox generate draft/continue/rewrite/usages:對接 #42 生成 API;
  需 generate scope 的 access token。
- 補齊 M3:fox novels list/create/update(Bearer 授權)+
  tokens create --scopes。
- --content/--prompt/--previous-context 支援 '-' 從 stdin 讀多行內容。
- 輸出維持單一 JSON(stdout)、exit code 0/1/2 規範不變。
- cli/README.md:命令表、授權說明與 M3 里程碑更新。

本機後端(Postgres+NestJS,echo 供應商)端到端實測 44 項全過。
This commit is contained in:
2026-09-06 23:32:48 +08:00
parent 7e05fd87c3
commit 77f30c1b6f
3 changed files with 421 additions and 17 deletions
+18 -7
View File
@@ -1,6 +1,6 @@
# Fox CLI — 與 Fox 後端互動的命令列工具 # Fox CLI — 與 Fox 後端互動的命令列工具
> 專案狀態:M0–M2 已實作(M1:HTTP client、設定讀取、`tokens verify`/`auth status`;M2:`tokens create/list/revoke`、公開的 `novels get`)|日期:2026-09-04 > 專案狀態:M0–M3 已實作(M1:HTTP client、設定讀取、`tokens verify`/`auth status`;M2:`tokens create/list/revoke`、公開的 `novels get`;M3:`novels list/create/update`、`chapters …`、`generate …`)|日期:2026-09-06
`fox` 是一個命令列工具,讓人與 **AI agent** 不經過前端,直接與 Fox 後端 API 互動:查詢/建立/編輯作品、管理 access token 等。目標是「一句命令拿到機器可解析的結果」,方便腳本、CI 與 agent 排程使用。 `fox` 是一個命令列工具,讓人與 **AI agent** 不經過前端,直接與 Fox 後端 API 互動:查詢/建立/編輯作品、管理 access token 等。目標是「一句命令拿到機器可解析的結果」,方便腳本、CI 與 agent 排程使用。
@@ -31,15 +31,26 @@
|------|----------|------| |------|----------|------|
| `fox auth status` | `GET /auth/status` | 查詢登入/SSO 設定(公開) | | `fox auth status` | `GET /auth/status` | 查詢登入/SSO 設定(公開) |
| `fox tokens verify` | `GET /access-tokens/verify` | 驗證手中 token 是否有效 | | `fox tokens verify` | `GET /access-tokens/verify` | 驗證手中 token 是否有效 |
| `fox tokens create --name <名稱> [--expires-at <ISO8601>]` | `POST /access-tokens` | 建立新 token(回傳只出現一次的原始值) | | `fox tokens create --name <名稱> [--expires-at <ISO8601>] [--scopes read,write,generate]` | `POST /access-tokens` | 建立新 token(回傳只出現一次的原始值) |
| `fox tokens list` | `GET /access-tokens` | 列出 token(不含 hash/原始值) | | `fox tokens list` | `GET /access-tokens` | 列出 token(不含 hash/原始值) |
| `fox tokens revoke <id>` | `DELETE /access-tokens/:id` | 撤銷 token(冪等) | | `fox tokens revoke <id>` | `DELETE /access-tokens/:id` | 撤銷 token(冪等) |
| `fox novels get <slug>` | `GET /novels/:slug` | 查單一作品(公開) | | `fox novels get <slug>` | `GET /novels/:slug` | 查單一作品(公開) |
| `fox novels list` | `GET /novels` | 列出自己的作品(需授權,見下) | | `fox novels list` | `GET /novels` | 列出自己的作品(需授權) |
| `fox novels create --title <書名> [--slug <slug>] [--synopsis <簡介>]` | `POST /novels` | 建立作品(需授權,見下) | | `fox novels create --title <書名> [--slug <slug>] [--synopsis <簡介>] [--tags <標籤1,標籤2>]` | `POST /novels` | 建立作品(需授權;標題非拉丁字元時建議附 `--slug`) |
| `fox novels update <slug> [--title ...] [--slug ...] [--synopsis ...]` | `PATCH /novels/:slug` | 更新作品(需授權,見下) | | `fox novels update <slug> [--title …] [--slug …] [--synopsis …] [--tags …] [--status <狀態>]` | `PATCH /novels/:slug` | 更新作品(需授權;只傳要改的欄位) |
| `fox chapters list <novelSlug>` | `GET /novels/:novelSlug/chapters` | 列章節(公開僅 published;帶作者 token 可見全部) |
| `fox chapters get <novelSlug> <chapterSlug>` | `GET /novels/:novelSlug/chapters/:chapterSlug` | 查單一章節(draft 僅作者/admin) |
| `fox chapters create <novelSlug> --title <標題> [--slug <slug>] [--content <內容|->] [--sort-order <N>] [--status <狀態>]` | `POST /novels/:novelSlug/chapters` | 建立章節(需授權;狀態預設 draft) |
| `fox chapters update <novelSlug> <chapterSlug> [--title …] [--slug …] [--content …] [--sort-order …] [--status …]` | `PATCH /novels/:novelSlug/chapters/:chapterSlug` | 更新章節(需授權;只傳要改的欄位) |
| `fox chapters delete <novelSlug> <chapterSlug>` | `DELETE /novels/:novelSlug/chapters/:chapterSlug` | 刪除章節(需授權;冪等) |
| `fox generate draft <novelSlug> --prompt <指示|-> [--previous-context <前文摘要|->]` | `POST /novels/:novelSlug/generate` | 草稿生成(需授權;token 需 generate scope) |
| `fox generate continue <novelSlug> --previous-context <前文摘要|-> [--prompt <指示|->]` | `POST /novels/:novelSlug/generate/continue` | 續寫(前文摘要必填) |
| `fox generate rewrite <novelSlug> --prompt <原文|-> [--previous-context …]` | `POST /novels/:novelSlug/generate/rewrite` | 改寫(原文放在 `--prompt`) |
| `fox generate usages <novelSlug>` | `GET /novels/:novelSlug/generate/usages` | 查詢生成用量記帳(需授權) |
> **授權(issue #16 已補齊)**:受保護端點(`novels list/create/update`、`auth me`)的 `AuthGuard` 現在同時接受 `Authorization: Bearer <access token>` 與瀏覽器 session(cookie);Bearer 優先。access token 需綁定帳號(`POST /access-tokens` 帶 `accountId`,或由管理員建立時指定),未綁帳號的系統 token 只能用於 `tokens verify`。CLI 待 M3 補齊 `novels list/create/update` 命令。 > **授權**:受保護端點的 `AuthGuard` 同時接受 `Authorization: Bearer <access token>` 與瀏覽器 session(Bearer 優先)。access token 需綁定帳號(`POST /access-tokens` 帶 `accountId`,或由管理員建立時指定),未綁帳號的系統 token 只能用於 `tokens verify`。**生成命令需具備 `generate` scope**(建立 token 時 `--scopes read,write,generate`;缺 scope 回 403)。
> **多行內容**:`--content`/`--prompt`/`--previous-context` 的值為 `-` 時改從 stdin 讀入全部內容,適合管線傳入多行文字:`cat 章節.md | fox chapters create <novelSlug> --title <標題> --content -`。
## 四、AI agent 使用指引 ## 四、AI agent 使用指引
@@ -57,7 +68,7 @@
- **M0**:子項目目錄+README(完成)。 - **M0**:子項目目錄+README(完成)。
- **M1**:HTTP client、設定讀取、`tokens verify`/`auth status`(完成,見 `src/http.ts`、`src/config.ts`)。 - **M1**:HTTP client、設定讀取、`tokens verify`/`auth status`(完成,見 `src/http.ts`、`src/config.ts`)。
- **M2**:`tokens create/list/revoke`、公開的 `novels get`(完成)。 - **M2**:`tokens create/list/revoke`、公開的 `novels get`(完成)。
- **M3**:`novels list/create/update`(視後端授權進度)、`--pretty`、錯誤處理打磨。 - **M3**:`novels list/create/update`、`chapters list/get/create/update/delete`、`generate draft/continue/rewrite/usages`(完成,2026-09-06,issue #43)。
## 開發 ## 開發
+36 -5
View File
@@ -12,7 +12,13 @@
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { ApiError, UsageError } from "./errors.js"; import { ApiError, UsageError } from "./errors.js";
import { parseArgs, Registry } from "./command.js"; import { parseArgs, Registry } from "./command.js";
import { registerAuthCommands, registerNovelCommands, registerTokenCommands } from "./commands.js"; import {
registerAuthCommands,
registerChapterCommands,
registerGenerateCommands,
registerNovelCommands,
registerTokenCommands,
} from "./commands.js";
const PROGRAM = "fox"; const PROGRAM = "fox";
@@ -26,10 +32,30 @@ exit code:0 成功;1 執行失敗(後端 4xx/5xx、網路錯誤);2 用
fox auth status 查詢登入/SSO 設定(公開) fox auth status 查詢登入/SSO 設定(公開)
fox tokens verify 驗證手中 token 是否有效 fox tokens verify 驗證手中 token 是否有效
fox tokens create --name <名稱> 建立新 token(原始值只顯示一次) fox tokens create --name <名稱> 建立新 token(原始值只顯示一次)
[--expires-at <ISO8601>] [--expires-at <ISO8601>] [--scopes read,write,generate]
fox tokens list 列出 token(不含 hash/原始值) fox tokens list 列出 token(不含 hash/原始值)
fox tokens revoke <id> 撤銷 token(冪等) fox tokens revoke <id> 撤銷 token(冪等)
fox novels get <slug> 查單一作品(公開) fox novels get <slug> 查單一作品(公開)
fox novels list 列出自己的作品(需授權)
fox novels create --title <書名> 建立作品(需授權)
[--slug <slug>] [--synopsis <簡介>] [--tags <標籤1,標籤2>]
fox novels update <slug> […] 更新作品(需授權;見 fox novels update --help)
fox chapters list <novelSlug> 列章節(公開僅 published;作者 token 見全部)
fox chapters get <novelSlug> <chapterSlug>
查單一章節(draft 僅作者/admin)
fox chapters create <novelSlug> --title <標題>
建立章節(需授權;狀態預設 draft)
fox chapters update <novelSlug> <chapterSlug> […]
更新章節(需授權)
fox chapters delete <novelSlug> <chapterSlug>
刪除章節(需授權;冪等)
fox generate draft <novelSlug> --prompt <指示>
草稿生成(需 generate scope)
fox generate continue <novelSlug> --previous-context <前文摘要>
續寫(前文摘要必填)
fox generate rewrite <novelSlug> --prompt <原文>
改寫(原文放在 --prompt)
fox generate usages <novelSlug> 查詢生成用量記帳(需授權)
全域選項(放在子命令後): 全域選項(放在子命令後):
--api-url <URL> 後端 API 位址(預設 FOX_API_URL 或 http://localhost:3000) --api-url <URL> 後端 API 位址(預設 FOX_API_URL 或 http://localhost:3000)
@@ -39,16 +65,21 @@ exit code:0 成功;1 執行失敗(後端 4xx/5xx、網路錯誤);2 用
設定優先順序:--token/--api-url > FOX_API_TOKEN/FOX_API_URL > 設定優先順序:--token/--api-url > FOX_API_TOKEN/FOX_API_URL >
~/.config/fox/cli.json({"apiUrl": "...", "token": "..."},權限建議 600)。 ~/.config/fox/cli.json({"apiUrl": "...", "token": "..."},權限建議 600)。
尚未支援(見 cli/README.md 授權缺口):novels list/create/update — 內容類選項(--content/--prompt/--previous-context)值為 "-" 時改從 stdin
後端 novels 授權端點目前只吃瀏覽器 session,待後端接受 Bearer access token 後補齊。 讀入(適合多行文字:cat 章節.md | fox chapters create <slug> --title X --content -)。
每個命令可用 --help 查看詳細說明,例如:fox tokens create --help`; fox generate 命令需要具備 generate scope 的 access token
(建立時 --scopes read,write,generate;缺 scope 會回 403)。
每個命令可用 --help 查看詳細說明,例如:fox chapters create --help`;
function buildRegistry(): Registry { function buildRegistry(): Registry {
const registry = new Registry(); const registry = new Registry();
registerAuthCommands(registry); registerAuthCommands(registry);
registerTokenCommands(registry); registerTokenCommands(registry);
registerNovelCommands(registry); registerNovelCommands(registry);
registerChapterCommands(registry);
registerGenerateCommands(registry);
return registry; return registry;
} }
+367 -5
View File
@@ -20,6 +20,55 @@ async function call(
return data; return data;
} }
/**
* 讀取內容類選項(--content/--prompt/--previous-context)的值。
* 值為 "-" 時改從 stdin 讀入全部內容(適合多行文字經管線傳入);
* stdin 是終端機時直接報用法錯誤,避免掛起等待輸入。
*/
async function readValue(
flagName: string,
value: string | undefined,
): Promise<string | undefined> {
if (value !== "-") return value;
if (process.stdin.isTTY) {
throw new UsageError(
`--${flagName} - 需經管線傳入內容(stdin 是終端機,會無限等待);用法:cat 檔案 | fox … --${flagName} -`,
);
}
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks).toString("utf8");
}
/** 讀取位置參數並要求非空。 */
function requirePositional(
positionals: string[],
index: number,
name: string,
usage: string,
): string {
const value = positionals[index];
if (value === undefined || !value.trim()) {
throw new UsageError(`缺少 <${name}>;用法:${usage}`);
}
return value.trim();
}
/** 讀取必填的字串選項。 */
async function requireFlag(
flags: Record<string, string | boolean>,
flagName: string,
usage: string,
): Promise<string> {
const value = await readValue(flagName, flagString(flags, flagName));
if (value === undefined || !value.trim()) {
throw new UsageError(`缺少 --${flagName};用法:${usage}`);
}
return value.trim();
}
export function registerAuthCommands(registry: Registry): void { export function registerAuthCommands(registry: Registry): void {
registry.add({ registry.add({
path: ["auth", "status"], path: ["auth", "status"],
@@ -44,18 +93,37 @@ export function registerTokenCommands(registry: Registry): void {
registry.add({ registry.add({
path: ["tokens", "create"], path: ["tokens", "create"],
summary: "建立新 token(原始值只在這次輸出顯示)", summary: "建立新 token(原始值只在這次輸出顯示)",
usage: "fox tokens create --name <名稱> [--expires-at <ISO8601>]", usage:
"fox tokens create --name <名稱> [--expires-at <ISO8601>] [--scopes read,write,generate]",
run: async (ctx) => { run: async (ctx) => {
const name = flagString(ctx.args.flags, "name"); const name = flagString(ctx.args.flags, "name");
if (!name || !name.trim()) { if (!name || !name.trim()) {
throw new UsageError("缺少 --name <名稱>;用法:fox tokens create --name <名稱> [--expires-at <ISO8601>]"); throw new UsageError(
"缺少 --name <名稱>;用法:fox tokens create --name <名稱> [--expires-at <ISO8601>] [--scopes read,write,generate]",
);
} }
const expiresAt = flagString(ctx.args.flags, "expires-at"); const expiresAt = flagString(ctx.args.flags, "expires-at");
if (expiresAt !== undefined && Number.isNaN(Date.parse(expiresAt))) { if (expiresAt !== undefined && Number.isNaN(Date.parse(expiresAt))) {
throw new UsageError(`--expires-at 不是有效的 ISO 8601 日期:${expiresAt}`); throw new UsageError(`--expires-at 不是有效的 ISO 8601 日期:${expiresAt}`);
} }
const body: Record<string, string> = { name: name.trim() }; const body: Record<string, unknown> = { name: name.trim() };
if (expiresAt !== undefined) body.expiresAt = new Date(expiresAt).toISOString(); if (expiresAt !== undefined) body.expiresAt = new Date(expiresAt).toISOString();
const scopes = flagString(ctx.args.flags, "scopes");
if (scopes !== undefined) {
const list = scopes
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const valid = ["read", "write", "generate"];
const bad = list.filter((s) => !valid.includes(s));
if (bad.length > 0) {
throw new UsageError(
`--scopes 含無效值:${bad.join(",")}(可用:read、write、generate)`,
);
}
body.scopes = list;
}
await call(ctx, "access-tokens", { method: "POST", body }); await call(ctx, "access-tokens", { method: "POST", body });
}, },
}); });
@@ -85,17 +153,311 @@ export function registerTokenCommands(registry: Registry): void {
}); });
} }
const CHAPTER_USAGE =
"fox chapters <list|get|create|update|delete> <novelSlug> …(見 fox chapters --help)";
const CHAPTER_STATUSES = ["draft", "published", "hidden"] as const;
const NOVEL_STATUSES = ["draft", "serializing", "completed", "hidden"] as const;
/** 讀取 --status 並驗證值域(draft/published/hidden)。 */
function readChapterStatusFlag(flags: Record<string, string | boolean>): string | undefined {
const value = flagString(flags, "status");
if (value === undefined) return undefined;
if (!CHAPTER_STATUSES.includes(value as (typeof CHAPTER_STATUSES)[number])) {
throw new UsageError(`--status 不正確(${CHAPTER_STATUSES.join("|")}):${value}`);
}
return value;
}
/** 讀取 --status 並驗證值域(draft/serializing/completed/hidden)。 */
function readNovelStatusFlag(flags: Record<string, string | boolean>): string | undefined {
const value = flagString(flags, "status");
if (value === undefined) return undefined;
if (!NOVEL_STATUSES.includes(value as (typeof NOVEL_STATUSES)[number])) {
throw new UsageError(`--status 不正確(${NOVEL_STATUSES.join("|")}):${value}`);
}
return value;
}
/** 讀取 --sort-order 並驗證為 0–1000000 的整數。 */
function readSortOrderFlag(flags: Record<string, string | boolean>): number | undefined {
const raw = flagString(flags, "sort-order");
if (raw === undefined) return undefined;
const n = Number(raw);
if (!/^\d+$/.test(raw.trim()) || !Number.isInteger(n) || n < 0 || n > 1_000_000) {
throw new UsageError(`--sort-order 須為 0–1000000 的整數:${raw}`);
}
return n;
}
export function registerNovelCommands(registry: Registry): void { export function registerNovelCommands(registry: Registry): void {
registry.add({ registry.add({
path: ["novels", "get"], path: ["novels", "get"],
summary: "查單一作品(公開端點)", summary: "查單一作品(公開端點)",
usage: "fox novels get <slug>", usage: "fox novels get <slug>",
run: async (ctx) => { run: async (ctx) => {
const slug = ctx.args.positionals[0]; const slug = requirePositional(ctx.args.positionals, 0, "slug", "fox novels get <slug>");
if (!slug) throw new UsageError("缺少 <slug>;用法:fox novels get <slug>");
await call(ctx, `novels/${encodeURIComponent(slug)}`); await call(ctx, `novels/${encodeURIComponent(slug)}`);
}, },
}); });
registry.add({
path: ["novels", "list"],
summary: "列出自己的作品(需授權)",
usage: "fox novels list",
run: async (ctx) => {
await call(ctx, "novels", { auth: true });
},
});
registry.add({
path: ["novels", "create"],
summary: "建立作品(需授權;slug 由書名自動生成,衝突時加流水號)",
usage:
"fox novels create --title <書名> [--slug <slug>] [--synopsis <簡介>] [--tags <標籤1,標籤2>]",
run: async (ctx) => {
const usage =
"fox novels create --title <書名> [--slug <slug>] [--synopsis <簡介>] [--tags <標籤1,標籤2>]";
const title = await requireFlag(ctx.args.flags, "title", usage);
const body: Record<string, unknown> = { title };
const slug = flagString(ctx.args.flags, "slug");
if (slug !== undefined) body.slug = slug.trim();
const synopsis = flagString(ctx.args.flags, "synopsis");
if (synopsis !== undefined) body.synopsis = synopsis;
const tags = flagString(ctx.args.flags, "tags");
if (tags !== undefined) body.tags = tags;
await call(ctx, "novels", { method: "POST", body, auth: true });
},
});
registry.add({
path: ["novels", "update"],
summary: "更新作品(需授權;只傳要改的欄位)",
usage:
"fox novels update <slug> [--title <書名>] [--slug <新slug>] [--synopsis <簡介>] [--tags <標籤>] [--status <狀態>]",
run: async (ctx) => {
const usage =
"fox novels update <slug> [--title <書名>] [--slug <新slug>] [--synopsis <簡介>] [--tags <標籤>] [--status <狀態>]";
const slug = requirePositional(ctx.args.positionals, 0, "slug", usage);
const body: Record<string, unknown> = {};
const title = flagString(ctx.args.flags, "title");
if (title !== undefined) body.title = title.trim();
const newSlug = flagString(ctx.args.flags, "slug");
if (newSlug !== undefined) body.slug = newSlug.trim();
const synopsis = flagString(ctx.args.flags, "synopsis");
if (synopsis !== undefined) body.synopsis = synopsis;
const tags = flagString(ctx.args.flags, "tags");
if (tags !== undefined) body.tags = tags;
const status = readNovelStatusFlag(ctx.args.flags);
if (status !== undefined) body.status = status;
if (Object.keys(body).length === 0) {
throw new UsageError(`沒有指定任何要更新的欄位;用法:${usage}`);
}
await call(ctx, `novels/${encodeURIComponent(slug)}`, {
method: "PATCH",
body,
auth: true,
});
},
});
}
export function registerChapterCommands(registry: Registry): void {
registry.add({
path: ["chapters", "list"],
summary: "列章節(公開僅 published;帶作者 token 可見全部)",
usage: "fox chapters list <novelSlug>",
run: async (ctx) => {
const slug = requirePositional(ctx.args.positionals, 0, "novelSlug", "fox chapters list <novelSlug>");
// 公開端點:有 token 就帶上(作者本人可見 draft/hidden),沒有也照常呼叫。
const config = await loadConfig({
apiUrl: flagString(ctx.args.flags, "api-url"),
token: flagString(ctx.args.flags, "token"),
});
const data = await request(
config,
`novels/${encodeURIComponent(slug)}/chapters`,
config.token ? { auth: true } : {},
);
ctx.print(data);
},
});
registry.add({
path: ["chapters", "get"],
summary: "查單一章節(draft 僅作者/admin)",
usage: "fox chapters get <novelSlug> <chapterSlug>",
run: async (ctx) => {
const usage = "fox chapters get <novelSlug> <chapterSlug>";
const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage);
const chapterSlug = requirePositional(ctx.args.positionals, 1, "chapterSlug", usage);
const config = await loadConfig({
apiUrl: flagString(ctx.args.flags, "api-url"),
token: flagString(ctx.args.flags, "token"),
});
const data = await request(
config,
`novels/${encodeURIComponent(novelSlug)}/chapters/${encodeURIComponent(chapterSlug)}`,
config.token ? { auth: true } : {},
);
ctx.print(data);
},
});
registry.add({
path: ["chapters", "create"],
summary: "建立章節(需授權;slug 由標題生成,狀態預設 draft)",
usage:
"fox chapters create <novelSlug> --title <標題> [--slug <slug>] [--content <內容|->] [--sort-order <N>] [--status <狀態>]",
run: async (ctx) => {
const usage =
"fox chapters create <novelSlug> --title <標題> [--slug <slug>] [--content <內容|->] [--sort-order <N>] [--status <狀態>]";
const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage);
const title = await requireFlag(ctx.args.flags, "title", usage);
const body: Record<string, unknown> = { title };
const slug = flagString(ctx.args.flags, "slug");
if (slug !== undefined) body.slug = slug.trim();
const content = await readValue("content", flagString(ctx.args.flags, "content"));
if (content !== undefined) body.content = content;
const sortOrder = readSortOrderFlag(ctx.args.flags);
if (sortOrder !== undefined) body.sortOrder = sortOrder;
const status = readChapterStatusFlag(ctx.args.flags);
if (status !== undefined) body.status = status;
await call(ctx, `novels/${encodeURIComponent(novelSlug)}/chapters`, {
method: "POST",
body,
auth: true,
});
},
});
registry.add({
path: ["chapters", "update"],
summary: "更新章節(需授權;只傳要改的欄位)",
usage:
"fox chapters update <novelSlug> <chapterSlug> [--title <標題>] [--slug <slug>] [--content <內容|->] [--sort-order <N>] [--status <狀態>]",
run: async (ctx) => {
const usage =
"fox chapters update <novelSlug> <chapterSlug> [--title <標題>] [--slug <slug>] [--content <內容|->] [--sort-order <N>] [--status <狀態>]";
const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage);
const chapterSlug = requirePositional(ctx.args.positionals, 1, "chapterSlug", usage);
const body: Record<string, unknown> = {};
const title = flagString(ctx.args.flags, "title");
if (title !== undefined) body.title = title.trim();
const slug = flagString(ctx.args.flags, "slug");
if (slug !== undefined) body.slug = slug.trim();
const content = await readValue("content", flagString(ctx.args.flags, "content"));
if (content !== undefined) body.content = content;
const sortOrder = readSortOrderFlag(ctx.args.flags);
if (sortOrder !== undefined) body.sortOrder = sortOrder;
const status = readChapterStatusFlag(ctx.args.flags);
if (status !== undefined) body.status = status;
if (Object.keys(body).length === 0) {
throw new UsageError(`沒有指定任何要更新的欄位;用法:${usage}`);
}
await call(
ctx,
`novels/${encodeURIComponent(novelSlug)}/chapters/${encodeURIComponent(chapterSlug)}`,
{ method: "PATCH", body, auth: true },
);
},
});
registry.add({
path: ["chapters", "delete"],
summary: "刪除章節(需授權;冪等)",
usage: "fox chapters delete <novelSlug> <chapterSlug>",
run: async (ctx) => {
const usage = "fox chapters delete <novelSlug> <chapterSlug>";
const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage);
const chapterSlug = requirePositional(ctx.args.positionals, 1, "chapterSlug", usage);
await call(
ctx,
`novels/${encodeURIComponent(novelSlug)}/chapters/${encodeURIComponent(chapterSlug)}`,
{ method: "DELETE", auth: true },
);
},
});
}
export function registerGenerateCommands(registry: Registry): void {
/** 共用:組生成請求 body 並 POST。 */
async function postGenerate(
ctx: CommandContext,
novelSlug: string,
action: "draft" | "continue" | "rewrite",
): Promise<void> {
const usage = `fox generate ${action} <novelSlug> [--prompt <指示|->] [--previous-context <前文摘要|->]`;
const body: Record<string, unknown> = {};
const prompt = await readValue("prompt", flagString(ctx.args.flags, "prompt"));
if (prompt !== undefined && prompt.trim()) body.prompt = prompt.trim();
const previousContext = await readValue(
"previous-context",
flagString(ctx.args.flags, "previous-context"),
);
if (previousContext !== undefined && previousContext.trim()) {
body.previousContext = previousContext.trim();
}
if (action === "draft" && body.prompt === undefined) {
throw new UsageError(`缺少 --prompt <指示>;用法:${usage}`);
}
if (action === "continue" && body.previousContext === undefined) {
throw new UsageError(`續寫需提供前文摘要;用法:${usage}`);
}
await call(ctx, `novels/${encodeURIComponent(novelSlug)}/generate${action === "draft" ? "" : `/${action}`}`, {
method: "POST",
body,
auth: true,
});
}
registry.add({
path: ["generate", "draft"],
summary: "草稿生成(需授權;token 需 generate scope)",
usage: "fox generate draft <novelSlug> --prompt <指示|-> [--previous-context <前文摘要|->]",
run: async (ctx) => {
const usage =
"fox generate draft <novelSlug> --prompt <指示|-> [--previous-context <前文摘要|->]";
const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage);
await postGenerate(ctx, novelSlug, "draft");
},
});
registry.add({
path: ["generate", "continue"],
summary: "續寫(需授權;--previous-context 必填)",
usage: "fox generate continue <novelSlug> --previous-context <前文摘要|-> [--prompt <指示|->]",
run: async (ctx) => {
const usage =
"fox generate continue <novelSlug> --previous-context <前文摘要|-> [--prompt <指示|->]";
const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage);
await postGenerate(ctx, novelSlug, "continue");
},
});
registry.add({
path: ["generate", "rewrite"],
summary: "改寫(需授權;原文放在 --prompt)",
usage: "fox generate rewrite <novelSlug> --prompt <原文|-> [--previous-context <前文摘要|->]",
run: async (ctx) => {
const usage =
"fox generate rewrite <novelSlug> --prompt <原文|-> [--previous-context <前文摘要|->]";
const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage);
await postGenerate(ctx, novelSlug, "rewrite");
},
});
registry.add({
path: ["generate", "usages"],
summary: "查詢生成用量記帳(需授權;作者/admin)",
usage: "fox generate usages <novelSlug>",
run: async (ctx) => {
const usage = "fox generate usages <novelSlug>";
const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage);
await call(ctx, `novels/${encodeURIComponent(novelSlug)}/generate/usages`, {
auth: true,
});
},
});
} }
/** 共用型別:命令實作可引用的介面(保持單一來源)。 */ /** 共用型別:命令實作可引用的介面(保持單一來源)。 */