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.
- bookwright/__init__.py +3 -0
- bookwright/__main__.py +6 -0
- bookwright/cli.py +19 -0
- bookwright/commands/__init__.py +0 -0
- bookwright/commands/_envelope.py +36 -0
- bookwright/commands/check.py +75 -0
- bookwright/commands/graph/__init__.py +23 -0
- bookwright/commands/graph/build.py +157 -0
- bookwright/commands/graph/envelope.py +26 -0
- bookwright/commands/graph/query.py +98 -0
- bookwright/commands/init/__init__.py +5 -0
- bookwright/commands/init/conflict.py +107 -0
- bookwright/commands/init/envelope.py +322 -0
- bookwright/commands/init/git.py +96 -0
- bookwright/commands/init/main.py +263 -0
- bookwright/commands/init/resolve.py +193 -0
- bookwright/commands/init/scaffold.py +242 -0
- bookwright/commands/init/validate.py +172 -0
- bookwright/commands/integration/__init__.py +22 -0
- bookwright/commands/integration/use.py +120 -0
- bookwright/commands/validate.py +160 -0
- bookwright/commands/version.py +35 -0
- bookwright/core/__init__.py +35 -0
- bookwright/core/_blocks.py +239 -0
- bookwright/core/_build.py +154 -0
- bookwright/core/_research_block.py +56 -0
- bookwright/core/_translate.py +90 -0
- bookwright/core/errors.py +127 -0
- bookwright/core/iso639_1.py +200 -0
- bookwright/core/manifest.py +343 -0
- bookwright/errors.py +47 -0
- bookwright/golem/__init__.py +71 -0
- bookwright/golem/base.py +200 -0
- bookwright/golem/errors.py +29 -0
- bookwright/golem/modules/__init__.py +1 -0
- bookwright/golem/modules/character.py +109 -0
- bookwright/golem/modules/event.py +91 -0
- bookwright/golem/modules/feature.py +161 -0
- bookwright/golem/modules/inference.py +41 -0
- bookwright/golem/modules/narrative.py +55 -0
- bookwright/golem/modules/provenance.py +197 -0
- bookwright/golem/modules/relationship.py +38 -0
- bookwright/golem/modules/setting.py +30 -0
- bookwright/golem/namespaces.py +332 -0
- bookwright/golem/serialize.py +25 -0
- bookwright/golem/slug.py +22 -0
- bookwright/indexers/__init__.py +47 -0
- bookwright/indexers/base.py +55 -0
- bookwright/indexers/errors.py +80 -0
- bookwright/indexers/rdflib_indexer.py +89 -0
- bookwright/integrations/__init__.py +155 -0
- bookwright/integrations/base.py +117 -0
- bookwright/integrations/claude/__init__.py +29 -0
- bookwright/integrations/constants.py +38 -0
- bookwright/integrations/descriptions.py +48 -0
- bookwright/integrations/errors.py +170 -0
- bookwright/integrations/generic/__init__.py +56 -0
- bookwright/integrations/lint.py +160 -0
- bookwright/integrations/materialize.py +202 -0
- bookwright/integrations/options.py +203 -0
- bookwright/io/__init__.py +1 -0
- bookwright/io/bible.py +500 -0
- bookwright/io/errors.py +98 -0
- bookwright/io/frontmatter.py +61 -0
- bookwright/io/fs.py +226 -0
- bookwright/io/manuscript.py +15 -0
- bookwright/io/project.py +21 -0
- bookwright/io/report.py +107 -0
- bookwright/io/research.py +427 -0
- bookwright/resources/__init__.py +1 -0
- bookwright/resources/commands/bookwright-analyze.md +66 -0
- bookwright/resources/commands/bookwright-bible.md +96 -0
- bookwright/resources/commands/bookwright-checklist.md +67 -0
- bookwright/resources/commands/bookwright-clarify.md +65 -0
- bookwright/resources/commands/bookwright-constitution.md +79 -0
- bookwright/resources/commands/bookwright-continuity.md +70 -0
- bookwright/resources/commands/bookwright-draft.md +74 -0
- bookwright/resources/commands/bookwright-outline.md +71 -0
- bookwright/resources/commands/bookwright-research.md +107 -0
- bookwright/resources/commands/bookwright-scenes.md +66 -0
- bookwright/resources/commands/bookwright-synopsis.md +67 -0
- bookwright/resources/commands/bookwright-verify.md +136 -0
- bookwright/resources/commands/references/golem-character.md +65 -0
- bookwright/resources/commands/references/golem-events-timeline.md +56 -0
- bookwright/resources/commands/references/golem-relationships.md +53 -0
- bookwright/resources/commands/references/greimas-actants.md +57 -0
- bookwright/resources/commands/references/pending-protocol.md +72 -0
- bookwright/resources/commands/references/propp-functions.md +54 -0
- bookwright/resources/commands/references/research-format.md +136 -0
- bookwright/resources/project/.bookwright/cache/.gitkeep +0 -0
- bookwright/resources/project/.bookwright/schema/.gitkeep +0 -0
- bookwright/resources/project/.bookwright/templates/.gitkeep +0 -0
- bookwright/resources/project/.gitignore +23 -0
- bookwright/resources/project/README.md.j2 +40 -0
- bookwright/resources/project/__init__.py +6 -0
- bookwright/resources/project/bible/characters/.gitkeep +0 -0
- bookwright/resources/project/bible/constitution.md.j2 +74 -0
- bookwright/resources/project/bible/glossary.md +36 -0
- bookwright/resources/project/bible/locations/.gitkeep +0 -0
- bookwright/resources/project/bible/pov-structure.md +43 -0
- bookwright/resources/project/bible/relationships.md +36 -0
- bookwright/resources/project/bible/research/_index.md +28 -0
- bookwright/resources/project/bible/research/sources.md +23 -0
- bookwright/resources/project/bible/settings/.gitkeep +0 -0
- bookwright/resources/project/bible/subplots.md +35 -0
- bookwright/resources/project/bible/themes.md +36 -0
- bookwright/resources/project/bible/timeline.md +38 -0
- bookwright/resources/project/manuscript/.gitkeep +0 -0
- bookwright/resources/project/outline/arcs.md +34 -0
- bookwright/resources/project/outline/scenes.md +31 -0
- bookwright/resources/project/outline/structure.md +35 -0
- bookwright/resources/project/outline/synopsis.md +25 -0
- bookwright/resources/schemas/__init__.py +19 -0
- bookwright/resources/schemas/golem-1.1/VERSION +1 -0
- bookwright/resources/schemas/golem-1.1/golem.ttl +1947 -0
- bookwright/resources/schemas/golem-1.1/version.json +8 -0
- bookwright/resources/templates/__init__.py +1 -0
- bookwright/resources/templates/bible/character.md.tmpl +63 -0
- bookwright/resources/templates/bible/location.md.tmpl +37 -0
- bookwright/resources/templates/bible/research/_index.md.tmpl +25 -0
- bookwright/resources/templates/bible/research/sources.md.tmpl +21 -0
- bookwright/resources/templates/bible/research/tema.md.tmpl +37 -0
- bookwright/resources/templates/bible/setting.md.tmpl +38 -0
- bookwright/resources/templates/manifest.template.toml +79 -0
- bookwright/resources/templates/manuscript/chapter.md.tmpl +36 -0
- bookwright/resources/templates/scenes/scene.md.tmpl +37 -0
- bookwright/resources/vocabularies/__init__.py +6 -0
- bookwright/resources/vocabularies/greimas.ttl +4 -0
- bookwright/resources/vocabularies/propp.ttl +4 -0
- bookwright/resources/vocabularies/sources.ttl +82 -0
- bookwright/validation/__init__.py +33 -0
- bookwright/validation/anchor_queries.py +223 -0
- bookwright/validation/base.py +233 -0
- bookwright/validation/queries.py +197 -0
- bookwright/validation/registry.py +185 -0
- bookwright/validation/report.py +106 -0
- bookwright/validation/runner.py +65 -0
- bookwright/validation/validators/__init__.py +9 -0
- bookwright/validation/validators/character_presence.py +202 -0
- bookwright/validation/validators/factual_anchor.py +291 -0
- bookwright/validation/validators/focalization.py +152 -0
- bookwright/validation/validators/setting_continuity.py +100 -0
- bookwright/validation/validators/temporal.py +277 -0
- bookwright_cli-0.2.0.dist-info/METADATA +218 -0
- bookwright_cli-0.2.0.dist-info/RECORD +149 -0
- bookwright_cli-0.2.0.dist-info/WHEEL +4 -0
- bookwright_cli-0.2.0.dist-info/entry_points.txt +2 -0
- bookwright_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- 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."""
|