novel-writer-cli 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,7 +46,15 @@ node dist/cli.js --help
46
46
 
47
47
  ## 最小工作流:跑通一章
48
48
 
49
- 在**小说项目根目录**(含 `.checkpoint.json`)运行:
49
+ 如果你是从零开始,在空目录先执行初始化(会创建 `.checkpoint.json` + `staging/**`,并写入若干可选模板文件):
50
+
51
+ ```bash
52
+ mkdir my-novel && cd my-novel
53
+ novel init # --platform qidian|tomato 可选
54
+ novel status
55
+ ```
56
+
57
+ 之后在**小说项目根目录**(含 `.checkpoint.json`)运行:
50
58
 
51
59
  ```bash
52
60
  # 1) 计算下一步
@@ -0,0 +1,240 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { readCheckpoint } from "../checkpoint.js";
7
+ import { NovelCliError } from "../errors.js";
8
+ import { initProject, normalizePlatformId, resolveInitRootDir } from "../init.js";
9
+ import { computeNextStep } from "../next-step.js";
10
+ import { parsePlatformProfile } from "../platform-profile.js";
11
+ // ── Helpers ──────────────────────────────────────────────────────────────
12
+ async function assertDir(absPath) {
13
+ const s = await stat(absPath);
14
+ assert.ok(s.isDirectory(), `Expected directory: ${absPath}`);
15
+ }
16
+ async function assertFile(absPath) {
17
+ const s = await stat(absPath);
18
+ assert.ok(s.isFile(), `Expected file: ${absPath}`);
19
+ }
20
+ async function statExists(absPath) {
21
+ try {
22
+ await stat(absPath);
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ async function readJson(absPath) {
30
+ return JSON.parse(await readFile(absPath, "utf8"));
31
+ }
32
+ // ── resolveInitRootDir ──────────────────────────────────────────────────
33
+ test("resolveInitRootDir returns cwd when no projectOverride", () => {
34
+ const result = resolveInitRootDir({ cwd: "/tmp/foo" });
35
+ assert.equal(result, "/tmp/foo");
36
+ });
37
+ test("resolveInitRootDir resolves relative projectOverride against cwd", () => {
38
+ const result = resolveInitRootDir({ cwd: "/tmp", projectOverride: "my-novel" });
39
+ assert.equal(result, "/tmp/my-novel");
40
+ });
41
+ test("resolveInitRootDir rejects path traversal", () => {
42
+ assert.throws(() => resolveInitRootDir({ cwd: "/tmp", projectOverride: "../../etc" }), (err) => err instanceof NovelCliError && /path traversal/i.test(err.message));
43
+ });
44
+ // ── normalizePlatformId ─────────────────────────────────────────────────
45
+ test("normalizePlatformId accepts valid values", () => {
46
+ assert.equal(normalizePlatformId("qidian"), "qidian");
47
+ assert.equal(normalizePlatformId("tomato"), "tomato");
48
+ });
49
+ test("normalizePlatformId rejects invalid values", () => {
50
+ assert.throws(() => normalizePlatformId("jjwxc"), (err) => err instanceof NovelCliError && /Invalid --platform.*jjwxc/i.test(err.message));
51
+ assert.throws(() => normalizePlatformId(42), (err) => err instanceof NovelCliError && /Invalid --platform/i.test(err.message));
52
+ });
53
+ // ── initProject: basic skeleton ─────────────────────────────────────────
54
+ test("initProject creates a runnable skeleton with all checkpoint fields", async () => {
55
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-basic-"));
56
+ try {
57
+ const result = await initProject({ rootDir });
58
+ assert.equal(result.rootDir, rootDir);
59
+ // Exact created set (non-minimal = checkpoint + 4 templates)
60
+ assert.deepEqual(result.created.sort(), [".checkpoint.json", "ai-blacklist.json", "brief.md", "style-profile.json", "web-novel-cliche-lint.json"].sort());
61
+ // All 7 staging dirs ensured
62
+ assert.equal(result.ensuredDirs.length, 7);
63
+ assert.ok(result.ensuredDirs.includes("staging/chapters"));
64
+ assert.ok(result.ensuredDirs.includes("staging/manifests"));
65
+ // Verify ALL checkpoint fields
66
+ const checkpoint = await readCheckpoint(rootDir);
67
+ assert.equal(checkpoint.last_completed_chapter, 0);
68
+ assert.equal(checkpoint.current_volume, 1);
69
+ assert.equal(checkpoint.pipeline_stage, "committed");
70
+ assert.equal(checkpoint.inflight_chapter, null);
71
+ assert.equal(checkpoint.revision_count, 0);
72
+ assert.equal(checkpoint.hook_fix_count, 0);
73
+ assert.equal(checkpoint.title_fix_count, 0);
74
+ assert.ok(typeof checkpoint.last_checkpoint_time === "string" && checkpoint.last_checkpoint_time.length > 0);
75
+ // Integration: next step should be chapter:001:draft
76
+ const next = await computeNextStep(rootDir, checkpoint);
77
+ assert.equal(next.step, "chapter:001:draft");
78
+ // All staging dirs exist
79
+ for (const relDir of [
80
+ "staging/chapters",
81
+ "staging/summaries",
82
+ "staging/state",
83
+ "staging/evaluations",
84
+ "staging/logs",
85
+ "staging/storylines",
86
+ "staging/manifests"
87
+ ]) {
88
+ await assertDir(join(rootDir, relDir));
89
+ }
90
+ // All template files exist
91
+ for (const relFile of ["brief.md", "style-profile.json", "ai-blacklist.json", "web-novel-cliche-lint.json"]) {
92
+ await assertFile(join(rootDir, relFile));
93
+ }
94
+ }
95
+ finally {
96
+ await rm(rootDir, { recursive: true, force: true });
97
+ }
98
+ });
99
+ // ── Skip / Force: .checkpoint.json ──────────────────────────────────────
100
+ test("initProject does not overwrite .checkpoint.json without --force", async () => {
101
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-no-force-"));
102
+ try {
103
+ await writeFile(join(rootDir, ".checkpoint.json"), `${JSON.stringify({ last_completed_chapter: 5, current_volume: 1, pipeline_stage: "committed", inflight_chapter: null }, null, 2)}\n`, "utf8");
104
+ const result = await initProject({ rootDir, minimal: true });
105
+ assert.ok(result.skipped.includes(".checkpoint.json"));
106
+ assert.equal(result.overwritten.length, 0);
107
+ const checkpoint = await readCheckpoint(rootDir);
108
+ assert.equal(checkpoint.last_completed_chapter, 5);
109
+ }
110
+ finally {
111
+ await rm(rootDir, { recursive: true, force: true });
112
+ }
113
+ });
114
+ test("initProject overwrites .checkpoint.json with --force", async () => {
115
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-force-"));
116
+ try {
117
+ await writeFile(join(rootDir, ".checkpoint.json"), `${JSON.stringify({ last_completed_chapter: 5, current_volume: 1, pipeline_stage: "committed", inflight_chapter: null }, null, 2)}\n`, "utf8");
118
+ const result = await initProject({ rootDir, minimal: true, force: true });
119
+ assert.ok(result.overwritten.includes(".checkpoint.json"));
120
+ const checkpoint = await readCheckpoint(rootDir);
121
+ assert.equal(checkpoint.last_completed_chapter, 0);
122
+ }
123
+ finally {
124
+ await rm(rootDir, { recursive: true, force: true });
125
+ }
126
+ });
127
+ // ── Skip / Force: template files ────────────────────────────────────────
128
+ test("initProject skips existing template files without --force", async () => {
129
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-skip-tpl-"));
130
+ try {
131
+ // Pre-create a template file
132
+ await writeFile(join(rootDir, "brief.md"), "custom brief", "utf8");
133
+ await writeFile(join(rootDir, "ai-blacklist.json"), "{}", "utf8");
134
+ const result = await initProject({ rootDir });
135
+ assert.ok(result.skipped.includes("brief.md"));
136
+ assert.ok(result.skipped.includes("ai-blacklist.json"));
137
+ assert.ok(result.created.includes("style-profile.json"));
138
+ assert.ok(result.created.includes("web-novel-cliche-lint.json"));
139
+ // Verify content was NOT overwritten
140
+ const content = await readFile(join(rootDir, "brief.md"), "utf8");
141
+ assert.equal(content, "custom brief");
142
+ }
143
+ finally {
144
+ await rm(rootDir, { recursive: true, force: true });
145
+ }
146
+ });
147
+ test("initProject overwrites template files with --force", async () => {
148
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-force-tpl-"));
149
+ try {
150
+ await writeFile(join(rootDir, "brief.md"), "custom brief", "utf8");
151
+ const result = await initProject({ rootDir, force: true });
152
+ assert.ok(result.overwritten.includes("brief.md"));
153
+ // Verify content was overwritten with template content
154
+ const content = await readFile(join(rootDir, "brief.md"), "utf8");
155
+ assert.notEqual(content, "custom brief");
156
+ assert.ok(content.length > 0);
157
+ }
158
+ finally {
159
+ await rm(rootDir, { recursive: true, force: true });
160
+ }
161
+ });
162
+ // ── Platform: tomato ────────────────────────────────────────────────────
163
+ test("initProject writes platform-profile.json + genre-weight-profiles.json for --platform tomato", async () => {
164
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-platform-tomato-"));
165
+ try {
166
+ const result = await initProject({ rootDir, minimal: true, platform: "tomato" });
167
+ assert.ok(result.created.includes("platform-profile.json"));
168
+ assert.ok(result.created.includes("genre-weight-profiles.json"));
169
+ const raw = await readJson(join(rootDir, "platform-profile.json"));
170
+ const profile = parsePlatformProfile(raw, "platform-profile.json");
171
+ assert.equal(profile.platform, "tomato");
172
+ assert.ok(typeof profile.created_at === "string" && profile.created_at.length > 0);
173
+ assert.ok(typeof profile.schema_version === "number");
174
+ // genre-weight-profiles.json should be a valid JSON object
175
+ const genreRaw = await readJson(join(rootDir, "genre-weight-profiles.json"));
176
+ assert.ok(typeof genreRaw === "object" && genreRaw !== null && !Array.isArray(genreRaw));
177
+ }
178
+ finally {
179
+ await rm(rootDir, { recursive: true, force: true });
180
+ }
181
+ });
182
+ // ── Platform: qidian ────────────────────────────────────────────────────
183
+ test("initProject writes platform-profile.json for --platform qidian", async () => {
184
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-platform-qidian-"));
185
+ try {
186
+ const result = await initProject({ rootDir, minimal: true, platform: "qidian" });
187
+ assert.ok(result.created.includes("platform-profile.json"));
188
+ assert.ok(result.created.includes("genre-weight-profiles.json"));
189
+ const raw = await readJson(join(rootDir, "platform-profile.json"));
190
+ const profile = parsePlatformProfile(raw, "platform-profile.json");
191
+ assert.equal(profile.platform, "qidian");
192
+ assert.ok(typeof profile.created_at === "string" && profile.created_at.length > 0);
193
+ }
194
+ finally {
195
+ await rm(rootDir, { recursive: true, force: true });
196
+ }
197
+ });
198
+ // ── Minimal mode ────────────────────────────────────────────────────────
199
+ test("initProject minimal mode skips templates", async () => {
200
+ const rootDir = await mkdtemp(join(tmpdir(), "novel-init-minimal-"));
201
+ try {
202
+ const result = await initProject({ rootDir, minimal: true });
203
+ assert.ok(result.created.includes(".checkpoint.json"));
204
+ assert.equal(result.created.length, 1);
205
+ await assertFile(join(rootDir, ".checkpoint.json"));
206
+ await assertDir(join(rootDir, "staging/chapters"));
207
+ assert.equal(await statExists(join(rootDir, "brief.md")), false);
208
+ assert.equal(await statExists(join(rootDir, "style-profile.json")), false);
209
+ assert.equal(await statExists(join(rootDir, "ai-blacklist.json")), false);
210
+ assert.equal(await statExists(join(rootDir, "web-novel-cliche-lint.json")), false);
211
+ }
212
+ finally {
213
+ await rm(rootDir, { recursive: true, force: true });
214
+ }
215
+ });
216
+ // ── Non-existent --project directory ────────────────────────────────────
217
+ test("initProject can initialize a non-existent --project directory", async () => {
218
+ const parentDir = await mkdtemp(join(tmpdir(), "novel-init-project-"));
219
+ const rootDir = join(parentDir, "child-project");
220
+ try {
221
+ const result = await initProject({ rootDir, minimal: true });
222
+ assert.equal(result.rootDir, rootDir);
223
+ await assertFile(join(rootDir, ".checkpoint.json"));
224
+ }
225
+ finally {
226
+ await rm(parentDir, { recursive: true, force: true });
227
+ }
228
+ });
229
+ // ── Negative: rootDir is a file ─────────────────────────────────────────
230
+ test("initProject throws when rootDir is a file", async () => {
231
+ const parentDir = await mkdtemp(join(tmpdir(), "novel-init-file-"));
232
+ const filePath = join(parentDir, "not-a-dir");
233
+ await writeFile(filePath, "hello", "utf8");
234
+ try {
235
+ await assert.rejects(() => initProject({ rootDir: filePath, minimal: true }), (err) => err instanceof NovelCliError && /not a directory/i.test(err.message));
236
+ }
237
+ finally {
238
+ await rm(parentDir, { recursive: true, force: true });
239
+ }
240
+ });
@@ -3,6 +3,18 @@ import { NovelCliError } from "./errors.js";
3
3
  import { readJsonFile, writeJsonFile } from "./fs-utils.js";
4
4
  import { isPlainObject } from "./type-guards.js";
5
5
  export const PIPELINE_STAGES = ["drafting", "drafted", "refined", "judged", "revising", "committed"];
6
+ export function createDefaultCheckpoint(nowIso) {
7
+ return {
8
+ last_completed_chapter: 0,
9
+ current_volume: 1,
10
+ pipeline_stage: "committed",
11
+ inflight_chapter: null,
12
+ revision_count: 0,
13
+ hook_fix_count: 0,
14
+ title_fix_count: 0,
15
+ last_checkpoint_time: nowIso ?? new Date().toISOString()
16
+ };
17
+ }
6
18
  function asInt(value) {
7
19
  if (typeof value !== "number")
8
20
  return null;
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { errJson, okJson, printJson } from "./output.js";
9
9
  import { pathExists } from "./fs-utils.js";
10
10
  import { resolveProjectRoot } from "./project.js";
11
11
  import { readCheckpoint } from "./checkpoint.js";
12
+ import { initProject, normalizePlatformId, resolveInitRootDir } from "./init.js";
12
13
  import { advanceCheckpointForStep } from "./advance.js";
13
14
  import { commitChapter } from "./commit.js";
14
15
  import { buildInstructionPacket } from "./instructions.js";
@@ -46,6 +47,38 @@ function buildProgram(argv) {
46
47
  program.showHelpAfterError(false);
47
48
  program.showSuggestionAfterError(false);
48
49
  program.exitOverride();
50
+ program
51
+ .command("init")
52
+ .description("Initialize a new novel project directory (.checkpoint.json + staging/** + optional templates).")
53
+ .option("--force", "Overwrite existing files when present.")
54
+ .option("--minimal", "Only create .checkpoint.json + staging/** (skip templates).")
55
+ .option("--platform <id>", "Also write platform-profile.json (+ genre-weight-profiles.json). Supported: qidian|tomato.")
56
+ .action(async (localOpts) => {
57
+ const opts = program.opts();
58
+ const json = Boolean(opts.json);
59
+ const rootDir = resolveInitRootDir({ cwd: process.cwd(), projectOverride: opts.project });
60
+ const platform = localOpts.platform ? normalizePlatformId(localOpts.platform) : undefined;
61
+ const result = await initProject({
62
+ rootDir,
63
+ force: Boolean(localOpts.force),
64
+ minimal: Boolean(localOpts.minimal),
65
+ platform
66
+ });
67
+ if (json) {
68
+ printJson(okJson("init", result));
69
+ return;
70
+ }
71
+ process.stdout.write(`Project: ${rootDir}\n`);
72
+ for (const d of result.ensuredDirs)
73
+ process.stdout.write(`MKDIR ${d}\n`);
74
+ for (const p of result.created)
75
+ process.stdout.write(`CREATE ${p}\n`);
76
+ for (const p of result.overwritten)
77
+ process.stdout.write(`OVERWRITE ${p}\n`);
78
+ for (const p of result.skipped)
79
+ process.stdout.write(`SKIP ${p}\n`);
80
+ process.stdout.write(`Next: novel next\n`);
81
+ });
49
82
  program
50
83
  .command("status")
51
84
  .description("Show project status (checkpoint, locks, next action).")
package/dist/init.js ADDED
@@ -0,0 +1,163 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createDefaultCheckpoint } from "./checkpoint.js";
5
+ import { NovelCliError } from "./errors.js";
6
+ import { ensureDir, pathExists, readJsonFile, readTextFile, writeJsonFile, writeTextFile } from "./fs-utils.js";
7
+ import { rejectPathTraversalInput } from "./safe-path.js";
8
+ import { isPlainObject } from "./type-guards.js";
9
+ export function resolveInitRootDir(args) {
10
+ const cwdAbs = resolve(args.cwd);
11
+ if (!args.projectOverride)
12
+ return cwdAbs;
13
+ rejectPathTraversalInput(args.projectOverride, "--project");
14
+ return resolve(cwdAbs, args.projectOverride);
15
+ }
16
+ export function normalizePlatformId(value) {
17
+ if (value === "qidian" || value === "tomato")
18
+ return value;
19
+ throw new NovelCliError(`Invalid --platform: ${String(value)} (expected qidian|tomato).`, 2);
20
+ }
21
+ function moduleRootDir() {
22
+ // src/init.ts → <repo_root>; dist/init.js → <package_root>
23
+ // NOTE: Not compatible with single-file bundlers (esbuild/rollup).
24
+ return resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
25
+ }
26
+ const TEMPLATE_DIR = join(moduleRootDir(), "templates");
27
+ async function ensureRootIsDirectory(absPath) {
28
+ try {
29
+ const s = await stat(absPath);
30
+ if (!s.isDirectory()) {
31
+ throw new NovelCliError(`Project root is not a directory: ${absPath}`, 2);
32
+ }
33
+ }
34
+ catch (err) {
35
+ if (err instanceof NovelCliError)
36
+ throw err;
37
+ // Path does not exist or is inaccessible — attempt to create it.
38
+ await ensureDir(absPath);
39
+ }
40
+ }
41
+ async function writeIfMissingOrForce(args) {
42
+ const abs = join(args.rootDir, args.relPath);
43
+ const exists = await pathExists(abs);
44
+ if (exists && !args.force) {
45
+ args.result.skipped.push(args.relPath);
46
+ return;
47
+ }
48
+ if (args.contents.kind === "text")
49
+ await writeTextFile(abs, args.contents.text);
50
+ else
51
+ await writeJsonFile(abs, args.contents.json);
52
+ if (exists)
53
+ args.result.overwritten.push(args.relPath);
54
+ else
55
+ args.result.created.push(args.relPath);
56
+ }
57
+ async function loadTemplateText(name) {
58
+ try {
59
+ return await readTextFile(join(TEMPLATE_DIR, name));
60
+ }
61
+ catch (err) {
62
+ if (err instanceof NovelCliError) {
63
+ throw new NovelCliError(`Built-in template missing or unreadable: templates/${name}. ${err.message}`, 2);
64
+ }
65
+ throw err;
66
+ }
67
+ }
68
+ async function loadTemplateJson(name) {
69
+ let raw;
70
+ try {
71
+ raw = await readJsonFile(join(TEMPLATE_DIR, name));
72
+ }
73
+ catch (err) {
74
+ if (err instanceof NovelCliError) {
75
+ throw new NovelCliError(`Built-in template missing or unreadable: templates/${name}. ${err.message}`, 2);
76
+ }
77
+ throw err;
78
+ }
79
+ if (!isPlainObject(raw)) {
80
+ throw new NovelCliError(`Built-in template templates/${name}: expected a JSON object, got ${typeof raw}.`, 2);
81
+ }
82
+ return raw;
83
+ }
84
+ async function loadPlatformProfileTemplate(platform) {
85
+ const raw = await loadTemplateJson("platform-profile.json");
86
+ const defaults = raw.defaults;
87
+ if (!isPlainObject(defaults)) {
88
+ throw new NovelCliError("Invalid templates/platform-profile.json: missing 'defaults' object.", 2);
89
+ }
90
+ const selected = defaults[platform];
91
+ if (!isPlainObject(selected)) {
92
+ throw new NovelCliError(`Invalid templates/platform-profile.json: missing defaults.${platform} object.`, 2);
93
+ }
94
+ return selected;
95
+ }
96
+ const DEFAULT_TEMPLATES = [
97
+ { relPath: "brief.md", templateName: "brief-template.md", kind: "text" },
98
+ { relPath: "style-profile.json", templateName: "style-profile-template.json", kind: "json" },
99
+ { relPath: "ai-blacklist.json", templateName: "ai-blacklist.json", kind: "json" },
100
+ { relPath: "web-novel-cliche-lint.json", templateName: "web-novel-cliche-lint.json", kind: "json" }
101
+ ];
102
+ const STAGING_SUBDIRS = [
103
+ "staging/chapters",
104
+ "staging/summaries",
105
+ "staging/state",
106
+ "staging/evaluations",
107
+ "staging/logs",
108
+ "staging/storylines",
109
+ "staging/manifests"
110
+ ];
111
+ export async function initProject(args) {
112
+ const force = Boolean(args.force);
113
+ const minimal = Boolean(args.minimal);
114
+ const platform = args.platform ?? null;
115
+ const result = {
116
+ rootDir: args.rootDir,
117
+ ensuredDirs: [],
118
+ created: [],
119
+ overwritten: [],
120
+ skipped: []
121
+ };
122
+ await ensureRootIsDirectory(args.rootDir);
123
+ for (const relDir of STAGING_SUBDIRS) {
124
+ await ensureDir(join(args.rootDir, relDir));
125
+ result.ensuredDirs.push(relDir);
126
+ }
127
+ // Intentionally capture time once for transactional consistency.
128
+ const nowIso = new Date().toISOString();
129
+ await writeIfMissingOrForce({
130
+ rootDir: args.rootDir,
131
+ relPath: ".checkpoint.json",
132
+ contents: { kind: "json", json: createDefaultCheckpoint(nowIso) },
133
+ force,
134
+ result
135
+ });
136
+ if (!minimal) {
137
+ for (const tmpl of DEFAULT_TEMPLATES) {
138
+ const contents = tmpl.kind === "text"
139
+ ? { kind: "text", text: await loadTemplateText(tmpl.templateName) }
140
+ : { kind: "json", json: await loadTemplateJson(tmpl.templateName) };
141
+ await writeIfMissingOrForce({ rootDir: args.rootDir, relPath: tmpl.relPath, contents, force, result });
142
+ }
143
+ }
144
+ if (platform) {
145
+ const templateProfile = await loadPlatformProfileTemplate(platform);
146
+ await writeIfMissingOrForce({
147
+ rootDir: args.rootDir,
148
+ relPath: "platform-profile.json",
149
+ contents: { kind: "json", json: { ...templateProfile, created_at: nowIso } },
150
+ force,
151
+ result
152
+ });
153
+ // genre-weight-profiles.json is required when platform-profile.json.scoring is present.
154
+ await writeIfMissingOrForce({
155
+ rootDir: args.rootDir,
156
+ relPath: "genre-weight-profiles.json",
157
+ contents: { kind: "json", json: await loadTemplateJson("genre-weight-profiles.json") },
158
+ force,
159
+ result
160
+ });
161
+ }
162
+ return result;
163
+ }
@@ -16,6 +16,7 @@
16
16
 
17
17
  | 命令 | 用途 |
18
18
  |------|------|
19
+ | `novel init` | 初始化项目骨架(写入 `.checkpoint.json`、创建 `staging/**`,并可选写入模板配置) |
19
20
  | `novel status` | 查看 checkpoint / lock / next(只读) |
20
21
  | `novel next` | 计算确定性的下一步 step id |
21
22
  | `novel instructions <step>` | 输出 instruction packet(JSON) |
@@ -51,6 +52,33 @@ npm run build
51
52
  node dist/cli.js --help
52
53
  ```
53
54
 
55
+ ## 初始化项目(init)
56
+
57
+ `novel` 的大多数命令都要求项目根目录存在 `.checkpoint.json`(用于确定项目边界与当前进度)。从零开始时,先在空目录执行一次:
58
+
59
+ ```bash
60
+ mkdir my-novel && cd my-novel
61
+ novel init --platform tomato
62
+ ```
63
+
64
+ 默认会:
65
+
66
+ - 写入 `.checkpoint.json`
67
+ - 创建 `staging/**` 必要目录
68
+ - 写入若干模板文件(如 `brief.md`、`style-profile.json`、`ai-blacklist.json`、`web-novel-cliche-lint.json`)
69
+
70
+ 常用选项:
71
+
72
+ - `--minimal`:只创建 `.checkpoint.json` + `staging/**`(跳过模板文件)
73
+ - `--force`:覆盖已有文件(谨慎使用)
74
+ - `--platform qidian|tomato`:写入 `platform-profile.json`,并同时写入 `genre-weight-profiles.json`(用于 QualityJudge 动态权重)
75
+
76
+ 也可指定目标目录(会在目录不存在时创建):
77
+
78
+ ```bash
79
+ novel init --project ./my-novel --platform tomato
80
+ ```
81
+
54
82
  ## 最短路径:跑通“一章的确定性编排”
55
83
 
56
84
  以下示例假设你已在**小说项目根目录**(含 `.checkpoint.json`),或使用 `--project <dir>` 指定根目录。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novel-writer-cli",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Executor-agnostic novel orchestration CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",