@tophtab/homer 0.1.0
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/.homer/adapters/agents/skills/homer/SKILL.md +36 -0
- package/.homer/adapters/agents/skills/homer-setup/SKILL.md +46 -0
- package/.homer/adapters/agents/skills/homer-sync/SKILL.md +62 -0
- package/.homer/adapters/agents/skills/homer-write/SKILL.md +57 -0
- package/.homer/adapters/codex/config.toml +2 -0
- package/.homer/adapters/codex/hooks/inject-homer-state.py +45 -0
- package/.homer/adapters/codex/hooks.json +15 -0
- package/.homer/adapters/codex/skills/homer-start/SKILL.md +26 -0
- package/.homer/knowledge/author-lore/index.json +5 -0
- package/.homer/knowledge/public-lore/index.json +5 -0
- package/.homer/knowledge/tracking/character-state.json +5 -0
- package/.homer/knowledge/tracking/context.json +5 -0
- package/.homer/knowledge/tracking/foreshadowing.json +5 -0
- package/.homer/knowledge/tracking/patches.json +5 -0
- package/.homer/knowledge/tracking/timeline.json +5 -0
- package/.homer/scripts/homer.py +424 -0
- package/.homer/spec/hard-rules.md +14 -0
- package/.homer/spec/setup.md +34 -0
- package/.homer/spec/sync.md +48 -0
- package/.homer/spec/write.md +51 -0
- package/.homer/state/chapters.json +4 -0
- package/.homer/workflow.md +55 -0
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/bin/homer +17 -0
- package/bin/homer.js +35 -0
- package/package.json +21 -0
- package/scripts/homer-install.py +185 -0
package/README.md
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# Homer
|
|
2
|
+
|
|
3
|
+
Homer 是一个面向单本长篇网文项目的 Codex skill 集。
|
|
4
|
+
|
|
5
|
+
它的核心不是“帮你写一点文字”,而是维护一套适合连载小说的知识机制:作者可以拥有完整设定,但 AI 写作时默认只参考读者已经知道的内容,以及当前连载需要延续的状态。也就是用“公开设定 + 追踪”来支持部分揭露,避免 AI 把还没揭开的真相、未来伏笔、幕后规则提前写漏。
|
|
6
|
+
|
|
7
|
+
## 核心机制:部分揭露
|
|
8
|
+
|
|
9
|
+
长篇连载里,作者脑中通常有完整真相,但正文只能逐步揭露。Homer 把知识分成三层:
|
|
10
|
+
|
|
11
|
+
- `author-lore`:作者侧完整设定,来源于 `设定/`。这里可以包含隐藏真相、未来反转、世界真实规则、角色秘密和私有计划。
|
|
12
|
+
- `public-lore`:读者已经知道的公开设定,来源于所有 `accepted` 正文章节。默认写作只使用这层,避免提前泄露。
|
|
13
|
+
- `tracking`:连载追踪状态,来源于所有 `accepted` 正文章节。记录时间线、角色当前位置、关系状态、未回收伏笔、后续需要修补的问题。
|
|
14
|
+
|
|
15
|
+
默认写作上下文是:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
当前用户指令
|
|
19
|
+
+ 相关大纲
|
|
20
|
+
+ public-lore
|
|
21
|
+
+ tracking
|
|
22
|
+
+ 必要时的前文/相关 accepted 正文
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
默认不读完整 `设定/`,也不读完整 `author-lore`。只有当任务明确需要隐藏设定时,才读取相关切片。例如某章要揭露角色真实身份,就只读取这个角色和相关组织的作者设定,而不是把整本书的隐藏真相都塞进上下文。
|
|
26
|
+
|
|
27
|
+
这套机制的目标是让 AI 像一个知道连载边界的助手:它知道读者现在知道什么,也知道当前故事状态推进到哪里,但不会默认把作者手里的底牌摊出来。
|
|
28
|
+
|
|
29
|
+
## 三条业务流
|
|
30
|
+
|
|
31
|
+
Homer MVP 只保留三条线:
|
|
32
|
+
|
|
33
|
+
- `homer-setup`:初始化或修复单本书项目,扫描已有章节,建立 `.homer/state/chapters.json`。
|
|
34
|
+
- `homer-write`:写作、续写、扩写、打磨合一,直接修改 `正文/` 下的 draft 文件。
|
|
35
|
+
- `homer-sync`:采纳章节进入 canon,并从所有 accepted 章节全量重建 `public-lore` 和 `tracking`。
|
|
36
|
+
|
|
37
|
+
另有 Codex 启动辅助:
|
|
38
|
+
|
|
39
|
+
- `homer-start`:在重启会话、hooks 不可用或需要重新加载状态时使用。
|
|
40
|
+
|
|
41
|
+
## 项目模型
|
|
42
|
+
|
|
43
|
+
一个工作目录就是一本书。
|
|
44
|
+
|
|
45
|
+
作者目录完全自由:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
设定/ # 作者侧完整设定、隐藏真相、未来揭露
|
|
49
|
+
大纲/ # 卷纲、章纲、临时规划
|
|
50
|
+
正文/ # 章节正文
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Homer 结构化目录:
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
.homer/
|
|
57
|
+
workflow.md
|
|
58
|
+
spec/
|
|
59
|
+
state/
|
|
60
|
+
chapters.json
|
|
61
|
+
knowledge/
|
|
62
|
+
author-lore/
|
|
63
|
+
public-lore/
|
|
64
|
+
tracking/
|
|
65
|
+
cache/
|
|
66
|
+
scripts/
|
|
67
|
+
adapters/
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Codex / agent 适配层:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
.agents/skills/ # 共享 skill
|
|
74
|
+
.codex/ # Codex 专用 skill、hook、配置
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`.homer/` 是权威源。`.agents/` 和 `.codex/` 是从 `.homer/adapters/` 生成出来的适配层。
|
|
78
|
+
|
|
79
|
+
## 章节状态
|
|
80
|
+
|
|
81
|
+
章节状态保存在 `.homer/state/chapters.json`。
|
|
82
|
+
|
|
83
|
+
只保留三种状态:
|
|
84
|
+
|
|
85
|
+
- `planned`:已规划,但还不是可编辑正文。
|
|
86
|
+
- `draft`:可编辑工作稿。
|
|
87
|
+
- `accepted`:已采纳为 canon,默认不再修改。
|
|
88
|
+
|
|
89
|
+
知识状态:
|
|
90
|
+
|
|
91
|
+
- `none`:不进入长期知识,通常用于 `planned` 或 `draft`。
|
|
92
|
+
- `current`:accepted 章节的 hash 与已生成知识一致。
|
|
93
|
+
- `stale`:accepted 章节被改过,需要重新 sync。
|
|
94
|
+
|
|
95
|
+
核心规则:
|
|
96
|
+
|
|
97
|
+
- `draft` 可以直接改。
|
|
98
|
+
- `accepted` 是 canon,默认不改。
|
|
99
|
+
- 修改 accepted 属于 canon revision,必须让生成知识变 stale,直到下一次 sync。
|
|
100
|
+
- draft 不进入 `public-lore` 和 `tracking`。
|
|
101
|
+
|
|
102
|
+
## public-lore:公开设定
|
|
103
|
+
|
|
104
|
+
`public-lore` 只从 accepted 章节重建,表示读者已经知道或可以合理感知到的内容。
|
|
105
|
+
|
|
106
|
+
它需要区分:
|
|
107
|
+
|
|
108
|
+
- `shown_fact`:正文明确展示或确认的事实。
|
|
109
|
+
- `reader_inference`:读者可以推断,但正文尚未确认。
|
|
110
|
+
- `character_claim`:角色说过或相信的内容。
|
|
111
|
+
- `rumor`:流言、传闻、公众说法。
|
|
112
|
+
- `misdirection`:有意误导的信息。
|
|
113
|
+
|
|
114
|
+
每条内容都应保留证据和来源章节。这样写新章时,AI 使用的是“读者视角可见世界”,不是作者完整真相。
|
|
115
|
+
|
|
116
|
+
## tracking:连载追踪
|
|
117
|
+
|
|
118
|
+
`tracking` 同样只从 accepted 章节重建,但它不是 public-lore 的摘要,而是直接从正文抽取当前连续性状态。
|
|
119
|
+
|
|
120
|
+
推荐文件:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
.homer/knowledge/tracking/context.json
|
|
124
|
+
.homer/knowledge/tracking/timeline.json
|
|
125
|
+
.homer/knowledge/tracking/character-state.json
|
|
126
|
+
.homer/knowledge/tracking/foreshadowing.json
|
|
127
|
+
.homer/knowledge/tracking/patches.json
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
它用于回答这些问题:
|
|
131
|
+
|
|
132
|
+
- 当前剧情推进到哪里?
|
|
133
|
+
- 各角色在哪里、知道什么、状态如何?
|
|
134
|
+
- 哪些伏笔已经埋下但还没回收?
|
|
135
|
+
- 哪些 accepted 正文里的问题不能回头改,只能后文修补?
|
|
136
|
+
|
|
137
|
+
## author-lore:作者侧设定索引
|
|
138
|
+
|
|
139
|
+
`author-lore` 来源于 `设定/`,用于索引作者自由写下的完整设定。
|
|
140
|
+
|
|
141
|
+
规则:
|
|
142
|
+
|
|
143
|
+
- `设定/` 是作者自由编辑的源文件。
|
|
144
|
+
- Homer 默认不修改 `设定/`。
|
|
145
|
+
- `author-lore` 只作为 AI 可读 JSON 索引。
|
|
146
|
+
- 显式事实、推断、候选想法、冲突项、过期设定必须分开。
|
|
147
|
+
|
|
148
|
+
## 安装与开书
|
|
149
|
+
|
|
150
|
+
Homer 的分发模型分两层:
|
|
151
|
+
|
|
152
|
+
- 全局命令层:从 Homer 源码检出安装 `homer` 命令,或直接运行 `bin/homer`。
|
|
153
|
+
- 项目本地层:每本书目录内生成 `.homer/`、`.agents/` 和 `.codex/`。
|
|
154
|
+
|
|
155
|
+
像 Trellis 一样安装全局命令:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
npm install -g @tophtab/homer
|
|
159
|
+
homer init /path/to/my-book
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
本地源码开发时可以先用当前 checkout 模拟全局安装:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
cd /path/to/homer
|
|
166
|
+
npm install -g .
|
|
167
|
+
homer init /path/to/my-book
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
不安装全局命令,也可以直接从源码目录创建一本新书:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
/path/to/homer/bin/homer.js init /path/to/my-book
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
在当前目录初始化:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
homer init
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
已有章节放在 `正文/` 下时,默认扫描为 `draft`。如果是已发布/已定稿章节,可以显式指定:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
homer init /path/to/my-book --status accepted
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
默认不会覆盖目标目录中已有且内容不同的 Homer 模板文件。需要升级模板时使用:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
homer init /path/to/my-book --force
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
这个命令会完成三件事:
|
|
195
|
+
|
|
196
|
+
1. 复制 `.homer/` 模板。
|
|
197
|
+
2. 运行 `python3 .homer/scripts/homer.py init --scan`。
|
|
198
|
+
3. 运行 `python3 .homer/scripts/homer.py generate-adapters` 生成 `.agents/` 和 `.codex/`。
|
|
199
|
+
|
|
200
|
+
## 发布 npm 包
|
|
201
|
+
|
|
202
|
+
仓库使用 GitHub Actions 发布 `@tophtab/homer`,流程参考 Trellis:
|
|
203
|
+
|
|
204
|
+
- 触发方式:发布 GitHub Release,或推送 `v*` tag。
|
|
205
|
+
- 发布前检查:运行 `npm test`,再运行 `npm pack --dry-run` 检查包内容。
|
|
206
|
+
- 认证方式:npm Trusted Publishing,通过 GitHub OIDC 发布,不保存 npm token。
|
|
207
|
+
- npm dist-tag:版本包含 `-beta` 发布到 `beta`,包含 `-alpha` 发布到 `alpha`,包含 `-rc` 发布到 `rc`,其他版本发布到 `latest`。
|
|
208
|
+
|
|
209
|
+
首次配置需要在 npm 里添加 Trusted Publisher:
|
|
210
|
+
|
|
211
|
+
```text
|
|
212
|
+
Package: @tophtab/homer
|
|
213
|
+
Publisher: GitHub Actions
|
|
214
|
+
Repository: tophtab/homer
|
|
215
|
+
Workflow file: publish.yml
|
|
216
|
+
Environment: leave empty unless the workflow also sets one
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
不需要在 GitHub 里配置 `NPM_TOKEN`。如果 npm 界面要求包已存在才能添加 Trusted Publisher,先用本机或一次性 token 发布第一个版本,随后立刻启用 Trusted Publishing 并删除 token。
|
|
220
|
+
|
|
221
|
+
常规发布步骤:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
npm version patch
|
|
225
|
+
git push origin main --tags
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
或在 GitHub 上创建 `v0.1.0` 这样的 Release。发布完成后用户即可安装:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
npm install -g @tophtab/homer
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## 基本命令
|
|
235
|
+
|
|
236
|
+
初始化或修复项目:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
python3 .homer/scripts/homer.py init --scan
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
查看状态:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
python3 .homer/scripts/homer.py status
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
从 `.homer/adapters/` 重新生成 `.agents/` 和 `.codex/`:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
python3 .homer/scripts/homer.py generate-adapters
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
修改 draft 章节后更新 hash:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
python3 .homer/scripts/homer.py check
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
机械采纳章节:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
python3 .homer/scripts/homer.py accept 1
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`homer-sync` 完成 public-lore / tracking 重建后标记知识为最新:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
python3 .homer/scripts/homer.py mark-current
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Codex 使用方式
|
|
273
|
+
|
|
274
|
+
常用 skill:
|
|
275
|
+
|
|
276
|
+
- `$homer-start`:开始会话或上下文丢失后加载状态。
|
|
277
|
+
- `$homer-setup`:初始化、扫描、修复项目。
|
|
278
|
+
- `$homer-write`:写作、续写、扩写、打磨 draft。
|
|
279
|
+
- `$homer-sync`:采纳章节并重建知识。
|
|
280
|
+
|
|
281
|
+
`.codex/hooks/inject-homer-state.py` 会在 hooks 启用时注入简短状态,但 hooks 不是硬依赖;skill 和脚本可以独立工作。
|
|
282
|
+
|
|
283
|
+
## 不做什么
|
|
284
|
+
|
|
285
|
+
MVP 不做:
|
|
286
|
+
|
|
287
|
+
- 榜单扫描。
|
|
288
|
+
- 市场题材分析。
|
|
289
|
+
- 拆书分析产品。
|
|
290
|
+
- 封面生成。
|
|
291
|
+
- 短篇/长篇双工作流。
|
|
292
|
+
- 多书切换。
|
|
293
|
+
- 发布平台集成。
|
|
294
|
+
- 单独的 review 工作流。
|
|
295
|
+
- 每次写作任务创建独立任务目录。
|
|
296
|
+
|
|
297
|
+
## 仓库内容
|
|
298
|
+
|
|
299
|
+
- `HOMER_MVP_SPEC.md`:MVP 规格文档。
|
|
300
|
+
- `.homer/`:Homer 权威源,包括 workflow、spec、state、knowledge、script、adapter 模板。
|
|
301
|
+
- `.agents/skills/`:生成的共享 skill。
|
|
302
|
+
- `.codex/`:生成的 Codex 专用 skill、hook、配置。
|
|
303
|
+
|
|
304
|
+
## 致谢
|
|
305
|
+
|
|
306
|
+
Homer 的设计参考了两个项目:
|
|
307
|
+
|
|
308
|
+
- [Trellis](https://github.com/tophtab/Trellis):参考其“权威源 + 多平台适配生成层”的组织方式。
|
|
309
|
+
- [oh-story-claudecode](https://github.com/tophtab/oh-story-claudecode):参考其小说写作工作流、长篇写作辅助和 Claude/Codex skill 组织经验。
|
|
310
|
+
|
|
311
|
+
Homer 会有意收敛范围:它不是全链路网文工具箱,而是单本书项目里的知识、写作和同步助手。
|
package/bin/homer
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run the Homer installer from a source checkout."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import runpy
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "homer-install.py"
|
|
12
|
+
|
|
13
|
+
if not SCRIPT.is_file():
|
|
14
|
+
raise SystemExit(f"Homer installer not found: {SCRIPT}")
|
|
15
|
+
|
|
16
|
+
sys.argv[0] = str(SCRIPT)
|
|
17
|
+
runpy.run_path(str(SCRIPT), run_name="__main__")
|
package/bin/homer.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require("node:child_process");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
|
|
6
|
+
const script = path.resolve(__dirname, "..", "scripts", "homer-install.py");
|
|
7
|
+
const candidates = process.platform === "win32"
|
|
8
|
+
? ["python", "python3", "py"]
|
|
9
|
+
: ["python3", "python"];
|
|
10
|
+
|
|
11
|
+
let lastError = null;
|
|
12
|
+
|
|
13
|
+
for (const command of candidates) {
|
|
14
|
+
const args = command === "py"
|
|
15
|
+
? ["-3", script, ...process.argv.slice(2)]
|
|
16
|
+
: [script, ...process.argv.slice(2)];
|
|
17
|
+
const result = spawnSync(command, args, { stdio: "inherit" });
|
|
18
|
+
|
|
19
|
+
if (result.error) {
|
|
20
|
+
if (result.error.code === "ENOENT") {
|
|
21
|
+
lastError = result.error;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
console.error(result.error.message);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
process.exit(result.status ?? 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.error("Homer requires Python 3 on PATH.");
|
|
32
|
+
if (lastError) {
|
|
33
|
+
console.error(lastError.message);
|
|
34
|
+
}
|
|
35
|
+
process.exit(1);
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tophtab/homer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Codex skill set and project bootstrapper for single-book web-novel projects",
|
|
5
|
+
"bin": {
|
|
6
|
+
"homer": "bin/homer.js"
|
|
7
|
+
},
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"scripts",
|
|
14
|
+
".homer",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --check bin/homer.js && python3 -m py_compile scripts/homer-install.py bin/homer .homer/scripts/homer.py && rm -rf scripts/__pycache__ bin/__pycache__ .homer/scripts/__pycache__"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT"
|
|
21
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install Homer project infrastructure into a book directory."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
+
SOURCE_HOMER = SOURCE_ROOT / ".homer"
|
|
17
|
+
SKIPPED_PARTS = {"__pycache__", "cache"}
|
|
18
|
+
SKIPPED_SUFFIXES = {".pyc", ".pyo"}
|
|
19
|
+
VALID_STATUSES = ("planned", "draft", "accepted")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class CopyPlan:
|
|
24
|
+
to_copy: list[tuple[Path, Path]]
|
|
25
|
+
unchanged: list[Path]
|
|
26
|
+
skipped: list[Path]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def posix(path: Path) -> str:
|
|
30
|
+
return path.as_posix()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def iter_template_files(root: Path) -> Iterable[Path]:
|
|
34
|
+
for path in sorted(root.rglob("*")):
|
|
35
|
+
if not path.is_file():
|
|
36
|
+
continue
|
|
37
|
+
rel_parts = path.relative_to(root).parts
|
|
38
|
+
if any(part in SKIPPED_PARTS for part in rel_parts):
|
|
39
|
+
continue
|
|
40
|
+
if path.suffix in SKIPPED_SUFFIXES:
|
|
41
|
+
continue
|
|
42
|
+
yield path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def same_bytes(left: Path, right: Path) -> bool:
|
|
46
|
+
if left.stat().st_size != right.stat().st_size:
|
|
47
|
+
return False
|
|
48
|
+
return left.read_bytes() == right.read_bytes()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def plan_copy_tree(
|
|
52
|
+
src_root: Path,
|
|
53
|
+
dst_root: Path,
|
|
54
|
+
*,
|
|
55
|
+
force: bool,
|
|
56
|
+
skip_existing: bool,
|
|
57
|
+
) -> CopyPlan:
|
|
58
|
+
conflicts: list[Path] = []
|
|
59
|
+
to_copy: list[tuple[Path, Path]] = []
|
|
60
|
+
unchanged: list[Path] = []
|
|
61
|
+
skipped: list[Path] = []
|
|
62
|
+
|
|
63
|
+
for src in iter_template_files(src_root):
|
|
64
|
+
rel = src.relative_to(src_root)
|
|
65
|
+
dst = dst_root / rel
|
|
66
|
+
|
|
67
|
+
if dst.exists():
|
|
68
|
+
if not dst.is_file():
|
|
69
|
+
conflicts.append(dst)
|
|
70
|
+
continue
|
|
71
|
+
if same_bytes(src, dst):
|
|
72
|
+
unchanged.append(dst)
|
|
73
|
+
continue
|
|
74
|
+
if skip_existing:
|
|
75
|
+
skipped.append(dst)
|
|
76
|
+
continue
|
|
77
|
+
if not force:
|
|
78
|
+
conflicts.append(dst)
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
to_copy.append((src, dst))
|
|
82
|
+
|
|
83
|
+
if conflicts:
|
|
84
|
+
rel_conflicts = "\n".join(f" - {posix(path)}" for path in conflicts[:20])
|
|
85
|
+
suffix = "\n ..." if len(conflicts) > 20 else ""
|
|
86
|
+
raise SystemExit(
|
|
87
|
+
"Refusing to overwrite existing Homer files. "
|
|
88
|
+
"Re-run with --force to overwrite or --skip-existing to preserve them.\n"
|
|
89
|
+
f"{rel_conflicts}{suffix}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return CopyPlan(to_copy=to_copy, unchanged=unchanged, skipped=skipped)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def apply_copy_plan(plan: CopyPlan) -> None:
|
|
96
|
+
for src, dst in plan.to_copy:
|
|
97
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
shutil.copy2(src, dst)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_homer(target: Path, *args: str) -> None:
|
|
102
|
+
script = target / ".homer" / "scripts" / "homer.py"
|
|
103
|
+
if not script.is_file():
|
|
104
|
+
raise SystemExit(f"Homer helper not found after install: {script}")
|
|
105
|
+
subprocess.run([sys.executable, str(script), *args], cwd=target, check=True)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
109
|
+
if not SOURCE_HOMER.is_dir():
|
|
110
|
+
raise SystemExit(f"Source Homer template not found: {SOURCE_HOMER}")
|
|
111
|
+
|
|
112
|
+
target = Path(args.project_dir).expanduser().resolve()
|
|
113
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
plan = plan_copy_tree(
|
|
116
|
+
SOURCE_HOMER,
|
|
117
|
+
target / ".homer",
|
|
118
|
+
force=args.force,
|
|
119
|
+
skip_existing=args.skip_existing,
|
|
120
|
+
)
|
|
121
|
+
apply_copy_plan(plan)
|
|
122
|
+
|
|
123
|
+
run_homer(target, "init", "--scan", "--status", args.status)
|
|
124
|
+
run_homer(target, "generate-adapters")
|
|
125
|
+
|
|
126
|
+
print()
|
|
127
|
+
print(f"Homer installed in {target}")
|
|
128
|
+
print(
|
|
129
|
+
"Template files: "
|
|
130
|
+
f"copied={len(plan.to_copy)} "
|
|
131
|
+
f"unchanged={len(plan.unchanged)} "
|
|
132
|
+
f"skipped={len(plan.skipped)}"
|
|
133
|
+
)
|
|
134
|
+
print("Next:")
|
|
135
|
+
print(f" cd {target}")
|
|
136
|
+
print(" python3 .homer/scripts/homer.py status")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
141
|
+
parser = argparse.ArgumentParser(
|
|
142
|
+
prog="homer",
|
|
143
|
+
description="Install Homer into a single-book novel project.",
|
|
144
|
+
)
|
|
145
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
146
|
+
|
|
147
|
+
init = sub.add_parser("init", help="Create or update a Homer book project.")
|
|
148
|
+
init.add_argument(
|
|
149
|
+
"project_dir",
|
|
150
|
+
nargs="?",
|
|
151
|
+
default=".",
|
|
152
|
+
help="Book project directory. Defaults to the current directory.",
|
|
153
|
+
)
|
|
154
|
+
init.add_argument(
|
|
155
|
+
"--status",
|
|
156
|
+
choices=VALID_STATUSES,
|
|
157
|
+
default="draft",
|
|
158
|
+
help="Default status for newly scanned chapter files.",
|
|
159
|
+
)
|
|
160
|
+
init.add_argument(
|
|
161
|
+
"-f",
|
|
162
|
+
"--force",
|
|
163
|
+
action="store_true",
|
|
164
|
+
help="Overwrite existing Homer template files.",
|
|
165
|
+
)
|
|
166
|
+
init.add_argument(
|
|
167
|
+
"--skip-existing",
|
|
168
|
+
action="store_true",
|
|
169
|
+
help="Preserve existing Homer template files that differ from the source.",
|
|
170
|
+
)
|
|
171
|
+
init.set_defaults(func=cmd_init)
|
|
172
|
+
|
|
173
|
+
return parser
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main(argv: list[str] | None = None) -> int:
|
|
177
|
+
parser = build_parser()
|
|
178
|
+
args = parser.parse_args(argv)
|
|
179
|
+
if args.force and args.skip_existing:
|
|
180
|
+
parser.error("--force and --skip-existing cannot be used together")
|
|
181
|
+
return args.func(args)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
raise SystemExit(main())
|