diff --git a/README.md b/README.md index 0ea7fc7..6cff63c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 排程使用。 @@ -31,15 +31,26 @@ |------|----------|------| | `fox auth status` | `GET /auth/status` | 查詢登入/SSO 設定(公開) | | `fox tokens verify` | `GET /access-tokens/verify` | 驗證手中 token 是否有效 | -| `fox tokens create --name <名稱> [--expires-at ]` | `POST /access-tokens` | 建立新 token(回傳只出現一次的原始值) | +| `fox tokens create --name <名稱> [--expires-at ] [--scopes read,write,generate]` | `POST /access-tokens` | 建立新 token(回傳只出現一次的原始值) | | `fox tokens list` | `GET /access-tokens` | 列出 token(不含 hash/原始值) | | `fox tokens revoke ` | `DELETE /access-tokens/:id` | 撤銷 token(冪等) | | `fox novels get ` | `GET /novels/:slug` | 查單一作品(公開) | -| `fox novels list` | `GET /novels` | 列出自己的作品(需授權,見下) | -| `fox novels create --title <書名> [--slug ] [--synopsis <簡介>]` | `POST /novels` | 建立作品(需授權,見下) | -| `fox novels update [--title ...] [--slug ...] [--synopsis ...]` | `PATCH /novels/:slug` | 更新作品(需授權,見下) | +| `fox novels list` | `GET /novels` | 列出自己的作品(需授權) | +| `fox novels create --title <書名> [--slug ] [--synopsis <簡介>] [--tags <標籤1,標籤2>]` | `POST /novels` | 建立作品(需授權;標題非拉丁字元時建議附 `--slug`) | +| `fox novels update [--title …] [--slug …] [--synopsis …] [--tags …] [--status <狀態>]` | `PATCH /novels/:slug` | 更新作品(需授權;只傳要改的欄位) | +| `fox chapters list ` | `GET /novels/:novelSlug/chapters` | 列章節(公開僅 published;帶作者 token 可見全部) | +| `fox chapters get ` | `GET /novels/:novelSlug/chapters/:chapterSlug` | 查單一章節(draft 僅作者/admin) | +| `fox chapters create --title <標題> [--slug ] [--content <內容|->] [--sort-order ] [--status <狀態>]` | `POST /novels/:novelSlug/chapters` | 建立章節(需授權;狀態預設 draft) | +| `fox chapters update [--title …] [--slug …] [--content …] [--sort-order …] [--status …]` | `PATCH /novels/:novelSlug/chapters/:chapterSlug` | 更新章節(需授權;只傳要改的欄位) | +| `fox chapters delete ` | `DELETE /novels/:novelSlug/chapters/:chapterSlug` | 刪除章節(需授權;冪等) | +| `fox generate draft --prompt <指示|-> [--previous-context <前文摘要|->]` | `POST /novels/:novelSlug/generate` | 草稿生成(需授權;token 需 generate scope) | +| `fox generate continue --previous-context <前文摘要|-> [--prompt <指示|->]` | `POST /novels/:novelSlug/generate/continue` | 續寫(前文摘要必填) | +| `fox generate rewrite --prompt <原文|-> [--previous-context …]` | `POST /novels/:novelSlug/generate/rewrite` | 改寫(原文放在 `--prompt`) | +| `fox generate usages ` | `GET /novels/:novelSlug/generate/usages` | 查詢生成用量記帳(需授權) | -> **授權(issue #16 已補齊)**:受保護端點(`novels list/create/update`、`auth me`)的 `AuthGuard` 現在同時接受 `Authorization: Bearer ` 與瀏覽器 session(cookie);Bearer 優先。access token 需綁定帳號(`POST /access-tokens` 帶 `accountId`,或由管理員建立時指定),未綁帳號的系統 token 只能用於 `tokens verify`。CLI 待 M3 補齊 `novels list/create/update` 命令。 +> **授權**:受保護端點的 `AuthGuard` 同時接受 `Authorization: Bearer ` 與瀏覽器 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 --title <標題> --content -`。 ## 四、AI agent 使用指引 @@ -57,7 +68,7 @@ - **M0**:子項目目錄+README(完成)。 - **M1**:HTTP client、設定讀取、`tokens verify`/`auth status`(完成,見 `src/http.ts`、`src/config.ts`)。 - **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)。 ## 開發 diff --git a/src/cli.ts b/src/cli.ts index ef56422..b5ad9cc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,13 @@ import { loadConfig } from "./config.js"; import { ApiError, UsageError } from "./errors.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"; @@ -26,10 +32,30 @@ exit code:0 成功;1 執行失敗(後端 4xx/5xx、網路錯誤);2 用 fox auth status 查詢登入/SSO 設定(公開) fox tokens verify 驗證手中 token 是否有效 fox tokens create --name <名稱> 建立新 token(原始值只顯示一次) - [--expires-at ] + [--expires-at ] [--scopes read,write,generate] fox tokens list 列出 token(不含 hash/原始值) fox tokens revoke 撤銷 token(冪等) fox novels get 查單一作品(公開) + fox novels list 列出自己的作品(需授權) + fox novels create --title <書名> 建立作品(需授權) + [--slug ] [--synopsis <簡介>] [--tags <標籤1,標籤2>] + fox novels update […] 更新作品(需授權;見 fox novels update --help) + fox chapters list 列章節(公開僅 published;作者 token 見全部) + fox chapters get + 查單一章節(draft 僅作者/admin) + fox chapters create --title <標題> + 建立章節(需授權;狀態預設 draft) + fox chapters update […] + 更新章節(需授權) + fox chapters delete + 刪除章節(需授權;冪等) + fox generate draft --prompt <指示> + 草稿生成(需 generate scope) + fox generate continue --previous-context <前文摘要> + 續寫(前文摘要必填) + fox generate rewrite --prompt <原文> + 改寫(原文放在 --prompt) + fox generate usages 查詢生成用量記帳(需授權) 全域選項(放在子命令後): --api-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 > ~/.config/fox/cli.json({"apiUrl": "...", "token": "..."},權限建議 600)。 -尚未支援(見 cli/README.md 授權缺口):novels list/create/update — -後端 novels 授權端點目前只吃瀏覽器 session,待後端接受 Bearer access token 後補齊。 +內容類選項(--content/--prompt/--previous-context)值為 "-" 時改從 stdin +讀入(適合多行文字:cat 章節.md | fox chapters create --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 { const registry = new Registry(); registerAuthCommands(registry); registerTokenCommands(registry); registerNovelCommands(registry); + registerChapterCommands(registry); + registerGenerateCommands(registry); return registry; } diff --git a/src/commands.ts b/src/commands.ts index bcf3e30..febeb4c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -20,6 +20,55 @@ async function call( return data; } +/** + * 讀取內容類選項(--content/--prompt/--previous-context)的值。 + * 值為 "-" 時改從 stdin 讀入全部內容(適合多行文字經管線傳入); + * stdin 是終端機時直接報用法錯誤,避免掛起等待輸入。 + */ +async function readValue( + flagName: string, + value: string | undefined, +): Promise { + 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, + flagName: string, + usage: string, +): Promise { + 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 { registry.add({ path: ["auth", "status"], @@ -44,18 +93,37 @@ export function registerTokenCommands(registry: Registry): void { registry.add({ path: ["tokens", "create"], summary: "建立新 token(原始值只在這次輸出顯示)", - usage: "fox tokens create --name <名稱> [--expires-at ]", + usage: + "fox tokens create --name <名稱> [--expires-at ] [--scopes read,write,generate]", run: async (ctx) => { const name = flagString(ctx.args.flags, "name"); if (!name || !name.trim()) { - throw new UsageError("缺少 --name <名稱>;用法:fox tokens create --name <名稱> [--expires-at ]"); + throw new UsageError( + "缺少 --name <名稱>;用法:fox tokens create --name <名稱> [--expires-at ] [--scopes read,write,generate]", + ); } const expiresAt = flagString(ctx.args.flags, "expires-at"); if (expiresAt !== undefined && Number.isNaN(Date.parse(expiresAt))) { throw new UsageError(`--expires-at 不是有效的 ISO 8601 日期:${expiresAt}`); } - const body: Record = { name: name.trim() }; + const body: Record = { name: name.trim() }; 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 }); }, }); @@ -85,17 +153,311 @@ export function registerTokenCommands(registry: Registry): void { }); } +const CHAPTER_USAGE = + "fox chapters …(見 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 | 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 | 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): 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 { registry.add({ path: ["novels", "get"], summary: "查單一作品(公開端點)", usage: "fox novels get ", run: async (ctx) => { - const slug = ctx.args.positionals[0]; - if (!slug) throw new UsageError("缺少 ;用法:fox novels get "); + const slug = requirePositional(ctx.args.positionals, 0, "slug", "fox novels get "); 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 ] [--synopsis <簡介>] [--tags <標籤1,標籤2>]", + run: async (ctx) => { + const usage = + "fox novels create --title <書名> [--slug ] [--synopsis <簡介>] [--tags <標籤1,標籤2>]"; + const title = await requireFlag(ctx.args.flags, "title", usage); + const body: Record = { 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 [--title <書名>] [--slug <新slug>] [--synopsis <簡介>] [--tags <標籤>] [--status <狀態>]", + run: async (ctx) => { + const usage = + "fox novels update [--title <書名>] [--slug <新slug>] [--synopsis <簡介>] [--tags <標籤>] [--status <狀態>]"; + const slug = requirePositional(ctx.args.positionals, 0, "slug", usage); + const body: Record = {}; + 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 ", + run: async (ctx) => { + const slug = requirePositional(ctx.args.positionals, 0, "novelSlug", "fox chapters list "); + // 公開端點:有 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 ", + run: async (ctx) => { + const usage = "fox chapters get "; + 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 --title <標題> [--slug ] [--content <內容|->] [--sort-order ] [--status <狀態>]", + run: async (ctx) => { + const usage = + "fox chapters create --title <標題> [--slug ] [--content <內容|->] [--sort-order ] [--status <狀態>]"; + const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage); + const title = await requireFlag(ctx.args.flags, "title", usage); + const body: Record = { 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 [--title <標題>] [--slug ] [--content <內容|->] [--sort-order ] [--status <狀態>]", + run: async (ctx) => { + const usage = + "fox chapters update [--title <標題>] [--slug ] [--content <內容|->] [--sort-order ] [--status <狀態>]"; + const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage); + const chapterSlug = requirePositional(ctx.args.positionals, 1, "chapterSlug", usage); + const body: Record = {}; + 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 ", + run: async (ctx) => { + const usage = "fox chapters delete "; + 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 { + const usage = `fox generate ${action} [--prompt <指示|->] [--previous-context <前文摘要|->]`; + const body: Record = {}; + 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 --prompt <指示|-> [--previous-context <前文摘要|->]", + run: async (ctx) => { + const usage = + "fox generate draft --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 --previous-context <前文摘要|-> [--prompt <指示|->]", + run: async (ctx) => { + const usage = + "fox generate continue --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 --prompt <原文|-> [--previous-context <前文摘要|->]", + run: async (ctx) => { + const usage = + "fox generate rewrite --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 ", + run: async (ctx) => { + const usage = "fox generate usages "; + const novelSlug = requirePositional(ctx.args.positionals, 0, "novelSlug", usage); + await call(ctx, `novels/${encodeURIComponent(novelSlug)}/generate/usages`, { + auth: true, + }); + }, + }); } /** 共用型別:命令實作可引用的介面(保持單一來源)。 */