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:
@@ -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
@@ -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
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 共用型別:命令實作可引用的介面(保持單一來源)。 */
|
/** 共用型別:命令實作可引用的介面(保持單一來源)。 */
|
||||||
|
|||||||
Reference in New Issue
Block a user