bookwright-cli 0.2.0__py3-none-any.whl

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.
Files changed (149) hide show
  1. bookwright/__init__.py +3 -0
  2. bookwright/__main__.py +6 -0
  3. bookwright/cli.py +19 -0
  4. bookwright/commands/__init__.py +0 -0
  5. bookwright/commands/_envelope.py +36 -0
  6. bookwright/commands/check.py +75 -0
  7. bookwright/commands/graph/__init__.py +23 -0
  8. bookwright/commands/graph/build.py +157 -0
  9. bookwright/commands/graph/envelope.py +26 -0
  10. bookwright/commands/graph/query.py +98 -0
  11. bookwright/commands/init/__init__.py +5 -0
  12. bookwright/commands/init/conflict.py +107 -0
  13. bookwright/commands/init/envelope.py +322 -0
  14. bookwright/commands/init/git.py +96 -0
  15. bookwright/commands/init/main.py +263 -0
  16. bookwright/commands/init/resolve.py +193 -0
  17. bookwright/commands/init/scaffold.py +242 -0
  18. bookwright/commands/init/validate.py +172 -0
  19. bookwright/commands/integration/__init__.py +22 -0
  20. bookwright/commands/integration/use.py +120 -0
  21. bookwright/commands/validate.py +160 -0
  22. bookwright/commands/version.py +35 -0
  23. bookwright/core/__init__.py +35 -0
  24. bookwright/core/_blocks.py +239 -0
  25. bookwright/core/_build.py +154 -0
  26. bookwright/core/_research_block.py +56 -0
  27. bookwright/core/_translate.py +90 -0
  28. bookwright/core/errors.py +127 -0
  29. bookwright/core/iso639_1.py +200 -0
  30. bookwright/core/manifest.py +343 -0
  31. bookwright/errors.py +47 -0
  32. bookwright/golem/__init__.py +71 -0
  33. bookwright/golem/base.py +200 -0
  34. bookwright/golem/errors.py +29 -0
  35. bookwright/golem/modules/__init__.py +1 -0
  36. bookwright/golem/modules/character.py +109 -0
  37. bookwright/golem/modules/event.py +91 -0
  38. bookwright/golem/modules/feature.py +161 -0
  39. bookwright/golem/modules/inference.py +41 -0
  40. bookwright/golem/modules/narrative.py +55 -0
  41. bookwright/golem/modules/provenance.py +197 -0
  42. bookwright/golem/modules/relationship.py +38 -0
  43. bookwright/golem/modules/setting.py +30 -0
  44. bookwright/golem/namespaces.py +332 -0
  45. bookwright/golem/serialize.py +25 -0
  46. bookwright/golem/slug.py +22 -0
  47. bookwright/indexers/__init__.py +47 -0
  48. bookwright/indexers/base.py +55 -0
  49. bookwright/indexers/errors.py +80 -0
  50. bookwright/indexers/rdflib_indexer.py +89 -0
  51. bookwright/integrations/__init__.py +155 -0
  52. bookwright/integrations/base.py +117 -0
  53. bookwright/integrations/claude/__init__.py +29 -0
  54. bookwright/integrations/constants.py +38 -0
  55. bookwright/integrations/descriptions.py +48 -0
  56. bookwright/integrations/errors.py +170 -0
  57. bookwright/integrations/generic/__init__.py +56 -0
  58. bookwright/integrations/lint.py +160 -0
  59. bookwright/integrations/materialize.py +202 -0
  60. bookwright/integrations/options.py +203 -0
  61. bookwright/io/__init__.py +1 -0
  62. bookwright/io/bible.py +500 -0
  63. bookwright/io/errors.py +98 -0
  64. bookwright/io/frontmatter.py +61 -0
  65. bookwright/io/fs.py +226 -0
  66. bookwright/io/manuscript.py +15 -0
  67. bookwright/io/project.py +21 -0
  68. bookwright/io/report.py +107 -0
  69. bookwright/io/research.py +427 -0
  70. bookwright/resources/__init__.py +1 -0
  71. bookwright/resources/commands/bookwright-analyze.md +66 -0
  72. bookwright/resources/commands/bookwright-bible.md +96 -0
  73. bookwright/resources/commands/bookwright-checklist.md +67 -0
  74. bookwright/resources/commands/bookwright-clarify.md +65 -0
  75. bookwright/resources/commands/bookwright-constitution.md +79 -0
  76. bookwright/resources/commands/bookwright-continuity.md +70 -0
  77. bookwright/resources/commands/bookwright-draft.md +74 -0
  78. bookwright/resources/commands/bookwright-outline.md +71 -0
  79. bookwright/resources/commands/bookwright-research.md +107 -0
  80. bookwright/resources/commands/bookwright-scenes.md +66 -0
  81. bookwright/resources/commands/bookwright-synopsis.md +67 -0
  82. bookwright/resources/commands/bookwright-verify.md +136 -0
  83. bookwright/resources/commands/references/golem-character.md +65 -0
  84. bookwright/resources/commands/references/golem-events-timeline.md +56 -0
  85. bookwright/resources/commands/references/golem-relationships.md +53 -0
  86. bookwright/resources/commands/references/greimas-actants.md +57 -0
  87. bookwright/resources/commands/references/pending-protocol.md +72 -0
  88. bookwright/resources/commands/references/propp-functions.md +54 -0
  89. bookwright/resources/commands/references/research-format.md +136 -0
  90. bookwright/resources/project/.bookwright/cache/.gitkeep +0 -0
  91. bookwright/resources/project/.bookwright/schema/.gitkeep +0 -0
  92. bookwright/resources/project/.bookwright/templates/.gitkeep +0 -0
  93. bookwright/resources/project/.gitignore +23 -0
  94. bookwright/resources/project/README.md.j2 +40 -0
  95. bookwright/resources/project/__init__.py +6 -0
  96. bookwright/resources/project/bible/characters/.gitkeep +0 -0
  97. bookwright/resources/project/bible/constitution.md.j2 +74 -0
  98. bookwright/resources/project/bible/glossary.md +36 -0
  99. bookwright/resources/project/bible/locations/.gitkeep +0 -0
  100. bookwright/resources/project/bible/pov-structure.md +43 -0
  101. bookwright/resources/project/bible/relationships.md +36 -0
  102. bookwright/resources/project/bible/research/_index.md +28 -0
  103. bookwright/resources/project/bible/research/sources.md +23 -0
  104. bookwright/resources/project/bible/settings/.gitkeep +0 -0
  105. bookwright/resources/project/bible/subplots.md +35 -0
  106. bookwright/resources/project/bible/themes.md +36 -0
  107. bookwright/resources/project/bible/timeline.md +38 -0
  108. bookwright/resources/project/manuscript/.gitkeep +0 -0
  109. bookwright/resources/project/outline/arcs.md +34 -0
  110. bookwright/resources/project/outline/scenes.md +31 -0
  111. bookwright/resources/project/outline/structure.md +35 -0
  112. bookwright/resources/project/outline/synopsis.md +25 -0
  113. bookwright/resources/schemas/__init__.py +19 -0
  114. bookwright/resources/schemas/golem-1.1/VERSION +1 -0
  115. bookwright/resources/schemas/golem-1.1/golem.ttl +1947 -0
  116. bookwright/resources/schemas/golem-1.1/version.json +8 -0
  117. bookwright/resources/templates/__init__.py +1 -0
  118. bookwright/resources/templates/bible/character.md.tmpl +63 -0
  119. bookwright/resources/templates/bible/location.md.tmpl +37 -0
  120. bookwright/resources/templates/bible/research/_index.md.tmpl +25 -0
  121. bookwright/resources/templates/bible/research/sources.md.tmpl +21 -0
  122. bookwright/resources/templates/bible/research/tema.md.tmpl +37 -0
  123. bookwright/resources/templates/bible/setting.md.tmpl +38 -0
  124. bookwright/resources/templates/manifest.template.toml +79 -0
  125. bookwright/resources/templates/manuscript/chapter.md.tmpl +36 -0
  126. bookwright/resources/templates/scenes/scene.md.tmpl +37 -0
  127. bookwright/resources/vocabularies/__init__.py +6 -0
  128. bookwright/resources/vocabularies/greimas.ttl +4 -0
  129. bookwright/resources/vocabularies/propp.ttl +4 -0
  130. bookwright/resources/vocabularies/sources.ttl +82 -0
  131. bookwright/validation/__init__.py +33 -0
  132. bookwright/validation/anchor_queries.py +223 -0
  133. bookwright/validation/base.py +233 -0
  134. bookwright/validation/queries.py +197 -0
  135. bookwright/validation/registry.py +185 -0
  136. bookwright/validation/report.py +106 -0
  137. bookwright/validation/runner.py +65 -0
  138. bookwright/validation/validators/__init__.py +9 -0
  139. bookwright/validation/validators/character_presence.py +202 -0
  140. bookwright/validation/validators/factual_anchor.py +291 -0
  141. bookwright/validation/validators/focalization.py +152 -0
  142. bookwright/validation/validators/setting_continuity.py +100 -0
  143. bookwright/validation/validators/temporal.py +277 -0
  144. bookwright_cli-0.2.0.dist-info/METADATA +218 -0
  145. bookwright_cli-0.2.0.dist-info/RECORD +149 -0
  146. bookwright_cli-0.2.0.dist-info/WHEEL +4 -0
  147. bookwright_cli-0.2.0.dist-info/entry_points.txt +2 -0
  148. bookwright_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  149. bookwright_cli-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,322 @@
1
+ """Success/error JSON envelopes and the ``.bookwright/init-options.json`` record.
2
+
3
+ Single source of truth for the contract-§3 envelope shapes and the
4
+ ``InitOptionsRecord`` schema pinned in data-model §1. Pure helpers; the
5
+ only filesystem-touching primitive is ``dump_options_record`` and even
6
+ that just writes one JSON file.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import sys
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+ from typing import Any, Literal, NoReturn
17
+
18
+ import typer
19
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
20
+
21
+ from bookwright import __version__ as _BOOKWRIGHT_VERSION
22
+ from bookwright.core.iso639_1 import ISO_639_1_CODES
23
+ from bookwright.errors import BookwrightError
24
+ from bookwright.integrations import (
25
+ MalformedOptionError,
26
+ SkillLintError,
27
+ SkillMaterializationError,
28
+ )
29
+
30
+ from .git import GitInitError
31
+ from .scaffold import BackupCreationError, TargetOutsideProjectRootError
32
+
33
+ SCHEMA_VERSION = 1
34
+
35
+ _ISO_UTC_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
36
+
37
+
38
+ class ResolvedInvocation(BaseModel):
39
+ """Resolved values for one ``init`` invocation (data-model §2)."""
40
+
41
+ model_config = ConfigDict(extra="forbid", strict=False)
42
+
43
+ mode: Literal["named", "here"]
44
+ project_name: str | None
45
+ project_slug: str
46
+ project_root: str
47
+ title: str
48
+ authors: list[str]
49
+ language: str
50
+ integration_key: str
51
+ integration_skills_dir: str
52
+ integration_options: dict[str, str | bool] = Field(default_factory=dict)
53
+ no_git: bool
54
+ force: bool
55
+ json_output: bool
56
+ git_status: Literal[
57
+ "initialized",
58
+ "skipped_by_flag",
59
+ "skipped_no_binary",
60
+ "skipped_existing_repo",
61
+ ]
62
+ deprecated_flags_seen: list[str] = Field(default_factory=list)
63
+
64
+ @field_validator("language")
65
+ @classmethod
66
+ def _check_language(cls, value: str) -> str:
67
+ if value not in ISO_639_1_CODES:
68
+ raise ValueError(f"language {value!r} is not a valid ISO 639-1 code")
69
+ return value
70
+
71
+ @field_validator("authors")
72
+ @classmethod
73
+ def _check_authors(cls, value: list[str]) -> list[str]:
74
+ if not value:
75
+ raise ValueError("authors must be non-empty")
76
+ return value
77
+
78
+ @field_validator("integration_skills_dir")
79
+ @classmethod
80
+ def _check_skills_dir(cls, value: str) -> str:
81
+ if value.startswith("/"):
82
+ raise ValueError(f"integration_skills_dir must be relative (got {value!r})")
83
+ if ".." in Path(value).parts:
84
+ raise ValueError(f"integration_skills_dir must not contain '..' (got {value!r})")
85
+ return value
86
+
87
+
88
+ class InitOptionsRecord(BaseModel):
89
+ """``.bookwright/init-options.json`` envelope (data-model §1)."""
90
+
91
+ model_config = ConfigDict(extra="forbid", strict=False)
92
+
93
+ schema_version: int = SCHEMA_VERSION
94
+ created_at: str
95
+ bookwright_version: str
96
+ options: ResolvedInvocation
97
+
98
+ @field_validator("schema_version")
99
+ @classmethod
100
+ def _check_schema_version(cls, value: int) -> int:
101
+ if value != SCHEMA_VERSION:
102
+ raise ValueError(f"schema_version must be {SCHEMA_VERSION} (got {value})")
103
+ return value
104
+
105
+ @field_validator("created_at")
106
+ @classmethod
107
+ def _check_created_at(cls, value: str) -> str:
108
+ if not _ISO_UTC_RE.match(value):
109
+ raise ValueError(f"created_at must match {_ISO_UTC_RE.pattern} (got {value!r})")
110
+ return value
111
+
112
+
113
+ def _utc_now_iso_z() -> str:
114
+ """ISO 8601 UTC second-precision suffixed with ``Z``."""
115
+
116
+ return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
117
+
118
+
119
+ def success_envelope(
120
+ resolved: ResolvedInvocation,
121
+ warnings: list[str],
122
+ ) -> dict[str, Any]:
123
+ """Contract §3.1 success-envelope shape."""
124
+
125
+ return {
126
+ "status": "ok",
127
+ "project_root": resolved.project_root,
128
+ "project_slug": resolved.project_slug,
129
+ "mode": resolved.mode,
130
+ "integration": {
131
+ "key": resolved.integration_key,
132
+ "skills_dir": resolved.integration_skills_dir,
133
+ "options": dict(resolved.integration_options),
134
+ },
135
+ "git_status": resolved.git_status,
136
+ "warnings": list(warnings),
137
+ "bookwright_version": _BOOKWRIGHT_VERSION,
138
+ }
139
+
140
+
141
+ def error_envelope(
142
+ error: BookwrightError | str,
143
+ message: str | None = None,
144
+ details: dict[str, Any] | None = None,
145
+ *,
146
+ rolled_back: bool,
147
+ ) -> dict[str, Any]:
148
+ """Contract §3.2 error-envelope shape: the canonical body + init's superset.
149
+
150
+ The ``{status,code,message[,details]}`` skeleton lives in exactly one place —
151
+ ``BookwrightError.to_json`` (review R1). Pass a ``BookwrightError`` to spread
152
+ that body; pass a primitive ``code`` (as ``error``) plus ``message``/``details``
153
+ for the non-``BookwrightError`` carve-outs that ``classify_filesystem_failure``
154
+ maps by hand (``OSError``/``PermissionError``/``GitInitError`` and the two
155
+ ``io.fs`` errors).
156
+ """
157
+
158
+ body: dict[str, Any]
159
+ if isinstance(error, BookwrightError):
160
+ body = error.to_json()
161
+ else:
162
+ body = {
163
+ "status": "error",
164
+ "code": error,
165
+ "message": message,
166
+ "details": dict(details or {}),
167
+ }
168
+ return {**body, "rolled_back": rolled_back, "bookwright_version": _BOOKWRIGHT_VERSION}
169
+
170
+
171
+ def dump_success_to_stdout(payload: dict[str, Any]) -> None:
172
+ """Write the success envelope to stdout (contract §3.1 encoding)."""
173
+
174
+ sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
175
+
176
+
177
+ def dump_error_to_stdout(payload: dict[str, Any]) -> None:
178
+ """Write the error envelope to stdout (contract §3.2 encoding)."""
179
+
180
+ sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
181
+
182
+
183
+ def _emit(payload: dict[str, Any], message: str, *, exit_code: int, json_output: bool) -> NoReturn:
184
+ """Surface an error envelope, then exit: one JSON document on stdout under
185
+ ``--json`` (contract §3.2), else a single ``bookwright: error: <message>``
186
+ line on stderr (Principle IX). Always raises."""
187
+
188
+ if json_output:
189
+ dump_error_to_stdout(payload)
190
+ else:
191
+ sys.stderr.write(f"bookwright: error: {message}\n")
192
+ raise typer.Exit(exit_code)
193
+
194
+
195
+ def emit_error( # noqa: PLR0913 — structured-error envelope demands all six fields
196
+ *,
197
+ code: str,
198
+ message: str,
199
+ details: dict[str, Any],
200
+ exit_code: int,
201
+ json_output: bool,
202
+ rolled_back: bool,
203
+ ) -> NoReturn:
204
+ """Build and emit the error envelope for a primitive ``code``/``message``/
205
+ ``details`` triple, then raise ``typer.Exit(exit_code)``.
206
+
207
+ The call sites with no ``BookwrightError`` in hand (mutex, removed flags, the
208
+ conflict matrix, and the filesystem/permission carve-outs). Always raises —
209
+ callers can rely on ``NoReturn`` for control-flow analysis.
210
+ """
211
+
212
+ _emit(
213
+ error_envelope(code, message, details, rolled_back=rolled_back),
214
+ message,
215
+ exit_code=exit_code,
216
+ json_output=json_output,
217
+ )
218
+
219
+
220
+ def emit_scaffold_failure(exc: BaseException, *, json_output: bool) -> NoReturn:
221
+ """Emit the §3.2 envelope for a caught scaffold-time exception, then exit.
222
+
223
+ A ``BookwrightError`` (``MalformedOptionError``/``SkillLintError``/
224
+ ``SkillMaterializationError``) carries its own canonical body, so we spread
225
+ ``error_envelope(exc, ...)`` — keeping the skeleton single-sourced in
226
+ ``BookwrightError.to_json`` (review R1). The non-``BookwrightError`` carve-outs
227
+ (``OSError``/``PermissionError``/``GitInitError`` and the two ``io.fs`` errors)
228
+ have no envelope, so ``classify_filesystem_failure`` maps them to a primitive
229
+ ``code``/``details`` pair; only its ``exit_code`` is consulted for the base
230
+ path. The scaffold is already rolled back by the caller, hence
231
+ ``rolled_back=True``.
232
+ """
233
+
234
+ code, exit_code, details = classify_filesystem_failure(exc)
235
+ if isinstance(exc, BookwrightError):
236
+ payload = error_envelope(exc, rolled_back=True)
237
+ message = exc.message
238
+ else:
239
+ message = str(exc) or code
240
+ payload = error_envelope(code, message, details, rolled_back=True)
241
+ _emit(payload, message, exit_code=exit_code, json_output=json_output)
242
+
243
+
244
+ def classify_filesystem_failure( # noqa: PLR0911 — one branch per contract §4 exception type
245
+ exc: BaseException,
246
+ ) -> tuple[str, int, dict[str, Any]]:
247
+ """Map a scaffold-time exception to ``(code, exit_code, details)`` (contract §4)."""
248
+
249
+ if isinstance(exc, MalformedOptionError):
250
+ # `SkillsIntegration.setup()` raises this at scaffold time for
251
+ # `resolves_to_project_root` / `escapes_project_root` (and similar
252
+ # option-domain rules). Surface it with the same code/exit_code/details
253
+ # shape that parse-time `MalformedOptionError` uses in
254
+ # `resolve.resolve_integration`, so consumers see a single contract.
255
+ return (
256
+ "malformed_option",
257
+ 5,
258
+ {"value": exc.value, "rule": exc.rule},
259
+ )
260
+ if isinstance(exc, (SkillLintError, SkillMaterializationError)):
261
+ # The skills materializer (SkillsIntegration.setup()) aborts the
262
+ # integration on a lint failure or an authoring error. Surface the
263
+ # structured error verbatim (its `code` distinguishes the two) so the
264
+ # JSON envelope carries the skill/rule/detail triple (FR-016/FR-020).
265
+ return (
266
+ exc.code,
267
+ 6,
268
+ {"skill": exc.skill, "rule": exc.rule, "detail": exc.detail},
269
+ )
270
+ if isinstance(exc, BackupCreationError):
271
+ return (
272
+ "backup_creation_error",
273
+ 6,
274
+ {"target": str(exc.target), "reason": exc.reason},
275
+ )
276
+ if isinstance(exc, PermissionError):
277
+ return (
278
+ "permission_denied",
279
+ 6,
280
+ {"path": str(getattr(exc, "filename", "") or ""), "errno": exc.errno or 0},
281
+ )
282
+ if isinstance(exc, GitInitError):
283
+ return (
284
+ "git_error",
285
+ 7,
286
+ {"stderr": exc.stderr},
287
+ )
288
+ if isinstance(exc, TargetOutsideProjectRootError):
289
+ return (
290
+ "filesystem_error",
291
+ 6,
292
+ {"path": str(exc.target), "errno": 0},
293
+ )
294
+ if isinstance(exc, OSError):
295
+ return (
296
+ "filesystem_error",
297
+ 6,
298
+ {"path": str(getattr(exc, "filename", "") or ""), "errno": exc.errno or 0},
299
+ )
300
+ return (
301
+ "filesystem_error",
302
+ 6,
303
+ {"path": "", "errno": 0},
304
+ )
305
+
306
+
307
+ def build_options_record(resolved: ResolvedInvocation) -> InitOptionsRecord:
308
+ """Wrap ``resolved`` in a versioned ``InitOptionsRecord``."""
309
+
310
+ return InitOptionsRecord(
311
+ schema_version=SCHEMA_VERSION,
312
+ created_at=_utc_now_iso_z(),
313
+ bookwright_version=_BOOKWRIGHT_VERSION,
314
+ options=resolved,
315
+ )
316
+
317
+
318
+ def serialize_options_record(record: InitOptionsRecord) -> bytes:
319
+ """Encode an ``InitOptionsRecord`` for the on-disk copy (indent=2 + trailing newline)."""
320
+
321
+ payload = record.model_dump(mode="json")
322
+ return (json.dumps(payload, indent=2, sort_keys=False) + "\n").encode("utf-8")
@@ -0,0 +1,96 @@
1
+ """Thin git subprocess wrapper for ``bookwright init`` (research §R8).
2
+
3
+ Three helpers: ``git_available()`` (PATH probe), ``is_inside_existing_repo()``
4
+ (walks parents for ``.git/``), and ``init_and_commit(...)`` (runs ``git
5
+ init`` + ``git add .`` + ``git commit -m <message>`` with author env vars
6
+ filled in, and registers the partial ``.git/`` directory with the backup
7
+ ledger so a failed commit rolls back cleanly).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from .scaffold import BackupLedger
20
+
21
+ _FALLBACK_EMAIL = "author@bookwright.local"
22
+
23
+
24
+ class GitInitError(Exception):
25
+ """Raised when ``git init`` or ``git commit`` failed (FR-022)."""
26
+
27
+ code = "git_error"
28
+
29
+ def __init__(self, *, stderr: str) -> None:
30
+ self.stderr = stderr
31
+ super().__init__(stderr.strip() or "git command failed")
32
+
33
+
34
+ def git_available() -> bool:
35
+ """``True`` when a ``git`` binary is on PATH."""
36
+
37
+ return shutil.which("git") is not None
38
+
39
+
40
+ def is_inside_existing_repo(root: Path) -> bool:
41
+ """``True`` when ``root`` or any ancestor contains a ``.git`` entry."""
42
+
43
+ candidate = root.resolve()
44
+ while True:
45
+ if (candidate / ".git").exists():
46
+ return True
47
+ if candidate.parent == candidate:
48
+ return False
49
+ candidate = candidate.parent
50
+
51
+
52
+ def _augmented_env(author_name: str) -> dict[str, str]:
53
+ """Build an env that forces an identity for the initial commit."""
54
+
55
+ env = os.environ.copy()
56
+ env["GIT_AUTHOR_NAME"] = author_name
57
+ env["GIT_COMMITTER_NAME"] = author_name
58
+ env.setdefault("GIT_AUTHOR_EMAIL", _FALLBACK_EMAIL)
59
+ env.setdefault("GIT_COMMITTER_EMAIL", _FALLBACK_EMAIL)
60
+ return env
61
+
62
+
63
+ def init_and_commit(
64
+ root: Path,
65
+ message: str,
66
+ author_name: str,
67
+ ledger: BackupLedger,
68
+ ) -> None:
69
+ """Run ``git init`` + ``git add .`` + ``git commit`` inside ``root``.
70
+
71
+ Registers ``<root>/.git`` with the ledger before ``git init`` so a
72
+ commit failure unwinds the partial repository on rollback. Raises
73
+ ``GitInitError`` on subprocess failure.
74
+ """
75
+
76
+ git_dir = root / ".git"
77
+ if not git_dir.exists():
78
+ ledger.record_new_directory(git_dir)
79
+
80
+ env = _augmented_env(author_name)
81
+
82
+ for argv in (
83
+ ["git", "init"],
84
+ ["git", "add", "."],
85
+ ["git", "commit", "-m", message],
86
+ ):
87
+ completed = subprocess.run(
88
+ argv,
89
+ cwd=str(root),
90
+ capture_output=True,
91
+ text=True,
92
+ check=False,
93
+ env=env,
94
+ )
95
+ if completed.returncode != 0:
96
+ raise GitInitError(stderr=completed.stderr or completed.stdout)
@@ -0,0 +1,263 @@
1
+ """`bookwright init` — scaffold a new book project.
2
+
3
+ Owns the Typer signature, the success-path orchestration, and the
4
+ top-level ``try/except/ledger.rollback()`` wrapper. Delegates validation,
5
+ resolution, conflict checks, scaffolding, envelope serialization, and the
6
+ git step to the package's private sibling modules.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+
15
+ import typer
16
+ from pydantic import ValidationError
17
+ from rich.console import Console
18
+
19
+ from . import conflict, envelope, git, resolve, scaffold, validate
20
+ from .envelope import ResolvedInvocation
21
+
22
+ _REMOVED_FLAGS: dict[str, str] = {
23
+ "--ai-skills": ("--ai-skills is no longer accepted; Agent Skills is now the only output mode"),
24
+ "--ai-commands-dir": (
25
+ "--ai-commands-dir is no longer accepted; "
26
+ 'for generic, use --integration-options="--skills-dir <path>"'
27
+ ),
28
+ }
29
+
30
+ _REMOVED_FLAG_MODERN: dict[str, str] = {
31
+ "--ai-skills": "(drop the flag; Agent Skills is the only output mode)",
32
+ "--ai-commands-dir": '--integration-options="--skills-dir <path>"',
33
+ }
34
+
35
+ _DEPRECATED_AI_WARNING = "bookwright: warning: --ai is deprecated; use --integration instead"
36
+
37
+
38
+ def _check_removed_flags(args: list[str], json_output: bool) -> None:
39
+ """Inspect raw click args for ``--ai-skills`` / ``--ai-commands-dir`` (FR-004)."""
40
+
41
+ for raw in args:
42
+ bare = raw.partition("=")[0] if raw.startswith("--") else raw
43
+ if bare in _REMOVED_FLAGS:
44
+ envelope.emit_error(
45
+ code="removed_flag",
46
+ message=_REMOVED_FLAGS[bare],
47
+ details={"flag": bare, "modern": _REMOVED_FLAG_MODERN[bare]},
48
+ exit_code=2,
49
+ json_output=json_output,
50
+ rolled_back=False,
51
+ )
52
+
53
+
54
+ def run( # noqa: PLR0913, PLR0912, PLR0915 — single Typer entry point; surface is the CLI contract
55
+ ctx: typer.Context,
56
+ project_name: str | None = typer.Argument(
57
+ None, metavar="[PROJECT_NAME]", help="New project directory name (mutex with --here)."
58
+ ),
59
+ here: bool = typer.Option(False, "--here", help="Initialise in the current directory."),
60
+ force: bool = typer.Option(
61
+ False, "--force", help="Overwrite name collisions under the project root."
62
+ ),
63
+ no_git: bool = typer.Option(False, "--no-git", help="Skip the automatic git init + commit."),
64
+ integration: str = typer.Option(
65
+ "claude",
66
+ "--integration",
67
+ help="Agent integration key (default: claude).",
68
+ ),
69
+ integration_options: str = typer.Option(
70
+ "",
71
+ "--integration-options",
72
+ help=(
73
+ "Quoted POSIX-tokenised options forwarded to the integration "
74
+ '(e.g., "--skills-dir .cursor/skills").'
75
+ ),
76
+ ),
77
+ json_output: bool = typer.Option(
78
+ False, "--json", help="Emit a single JSON document on stdout."
79
+ ),
80
+ ai: str | None = typer.Option(
81
+ None,
82
+ "--ai",
83
+ hidden=True,
84
+ help="Deprecated alias for --integration.",
85
+ ),
86
+ ) -> None:
87
+ """Scaffold a new Bookwright project."""
88
+
89
+ raw_args = list(ctx.args) if ctx.args else []
90
+ _check_removed_flags(raw_args, json_output=json_output)
91
+ validate.check_mutex(project_name, here, json_output=json_output)
92
+
93
+ # Reconcile --ai with --integration.
94
+ deprecated_seen: list[str] = []
95
+ warnings: list[str] = []
96
+ integration_source = ctx.get_parameter_source("integration")
97
+ integration_supplied = (
98
+ integration_source is not None and integration_source.name == "COMMANDLINE"
99
+ )
100
+ if ai is not None:
101
+ deprecated_seen.append("--ai")
102
+ sys.stderr.write(_DEPRECATED_AI_WARNING + "\n")
103
+ warnings.append(_DEPRECATED_AI_WARNING)
104
+ if not integration_supplied:
105
+ integration = ai
106
+
107
+ mode: Literal["named", "here"]
108
+ if here:
109
+ project_root = Path.cwd().resolve()
110
+ basename = project_root.name
111
+ validate.parse_here_basename(basename, json_output)
112
+ title = basename
113
+ project_slug = resolve.parse_named_slug(basename, json_output)
114
+ mode = "here"
115
+ cleanup_project_root = False
116
+ else:
117
+ assert project_name is not None # narrowed by mutex check
118
+ title = validate.parse_named_name(project_name, json_output)
119
+ project_slug = resolve.parse_named_slug(title, json_output)
120
+ project_root = (Path.cwd() / project_slug).resolve()
121
+ mode = "named"
122
+ cleanup_project_root = not project_root.exists()
123
+
124
+ # Conflict matrix BEFORE author/integration resolution (which would touch git).
125
+ if here:
126
+ conflict.apply_here_conflict_matrix(project_root, force, json_output=json_output)
127
+ else:
128
+ conflict.apply_named_conflict_matrix(project_root, force, json_output=json_output)
129
+
130
+ # Resolve every value that can fail BEFORE touching the filesystem (FR-030 / SC-005:
131
+ # a failure here must leave the parent dir byte-identical, so we cannot mkdir first).
132
+ # `Path.cwd()` is the right cwd for the git-config probe regardless of mode: in
133
+ # named mode it equals project_root.parent (which always exists); in here mode it
134
+ # equals project_root itself. git walks upward in both cases, so the resolved name
135
+ # is identical to the legacy behaviour of probing inside project_root post-mkdir.
136
+ authors = resolve.resolve_authors_or_warn(Path.cwd(), warnings)
137
+ language = resolve.resolve_language()
138
+ integration_cls, parsed_options = resolve.resolve_integration(
139
+ integration, integration_options, json_output=json_output
140
+ )
141
+ integration_instance = integration_cls()
142
+ skills_dir = integration_instance.resolve_skills_dir(parsed_options).as_posix()
143
+
144
+ git_status: Literal[
145
+ "initialized",
146
+ "skipped_by_flag",
147
+ "skipped_no_binary",
148
+ "skipped_existing_repo",
149
+ ]
150
+ if no_git:
151
+ git_status = "skipped_by_flag"
152
+ elif not git.git_available():
153
+ git_status = "skipped_no_binary"
154
+ elif git.is_inside_existing_repo(project_root):
155
+ git_status = "skipped_existing_repo"
156
+ else:
157
+ git_status = "initialized"
158
+
159
+ try:
160
+ resolved = ResolvedInvocation(
161
+ mode=mode,
162
+ project_name=title if mode == "named" else None,
163
+ project_slug=project_slug,
164
+ project_root=project_root.as_posix(),
165
+ title=title,
166
+ authors=list(authors),
167
+ language=language,
168
+ integration_key=integration,
169
+ integration_skills_dir=skills_dir,
170
+ integration_options=dict(parsed_options),
171
+ no_git=no_git,
172
+ force=force,
173
+ json_output=json_output,
174
+ git_status=git_status,
175
+ deprecated_flags_seen=list(deprecated_seen),
176
+ )
177
+ except ValidationError as exc:
178
+ first = exc.errors()[0]
179
+ field = ".".join(str(part) for part in first.get("loc", ()))
180
+ envelope.emit_error(
181
+ code="malformed_option",
182
+ message=f"invalid {field}: {first.get('msg', str(exc))}",
183
+ details={
184
+ "field": field,
185
+ "value": str(first.get("input", "")),
186
+ "rule": first.get("type", "value_error"),
187
+ },
188
+ exit_code=5,
189
+ json_output=json_output,
190
+ rolled_back=False,
191
+ )
192
+
193
+ if not project_root.exists():
194
+ try:
195
+ project_root.mkdir(parents=True, exist_ok=False)
196
+ except PermissionError as exc:
197
+ envelope.emit_error(
198
+ code="permission_denied",
199
+ message=f"could not create {project_root}: {exc}",
200
+ details={"path": str(project_root), "errno": exc.errno or 0},
201
+ exit_code=6,
202
+ json_output=json_output,
203
+ rolled_back=False,
204
+ )
205
+ except OSError as exc:
206
+ envelope.emit_error(
207
+ code="filesystem_error",
208
+ message=f"could not create {project_root}: {exc}",
209
+ details={"path": str(project_root), "errno": exc.errno or 0},
210
+ exit_code=6,
211
+ json_output=json_output,
212
+ rolled_back=False,
213
+ )
214
+
215
+ ledger = conflict.seed_backup_ledger(project_root, cleanup_project_root)
216
+
217
+ def _rollback_and_cleanup() -> None:
218
+ ledger.rollback()
219
+ if cleanup_project_root and project_root.exists():
220
+ import shutil # noqa: PLC0415 — local cleanup only
221
+
222
+ shutil.rmtree(project_root, ignore_errors=True)
223
+
224
+ try:
225
+ scaffold.run_scaffold_steps(
226
+ resolved=resolved,
227
+ integration=integration_instance,
228
+ parsed_options=parsed_options,
229
+ ledger=ledger,
230
+ warnings=warnings,
231
+ author_name=authors[0],
232
+ )
233
+ except (KeyboardInterrupt, SystemExit):
234
+ # Signal-like interruptions: roll back the partial scaffold and re-raise
235
+ # without writing an envelope, so the user sees the original signal.
236
+ _rollback_and_cleanup()
237
+ raise
238
+ except Exception as exc:
239
+ _rollback_and_cleanup()
240
+ if isinstance(exc, typer.Exit):
241
+ raise
242
+ envelope.emit_scaffold_failure(exc, json_output=json_output)
243
+
244
+ ledger.commit()
245
+
246
+ if json_output:
247
+ payload = envelope.success_envelope(resolved, warnings)
248
+ envelope.dump_success_to_stdout(payload)
249
+ else:
250
+ Console(stderr=True, highlight=False, soft_wrap=True).print(
251
+ f"bookwright: created [bold]{project_root}[/bold] "
252
+ f"(integration={resolved.integration_key}, "
253
+ f"git={resolved.git_status})"
254
+ )
255
+
256
+ raise typer.Exit(0)
257
+
258
+
259
+ CONTEXT_SETTINGS: dict[str, Any] = {
260
+ "allow_extra_args": True,
261
+ "ignore_unknown_options": True,
262
+ }
263
+ """Click context settings expected by ``cli.py`` when wiring the command."""