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,193 @@
1
+ """Resolution helpers consumed by ``init.run`` (research §R1, §R2, §R3, §R7).
2
+
3
+ Each helper is one source of truth so the orchestrator never needs to
4
+ re-derive the values. Tests monkeypatch a single symbol per behaviour.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import locale
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from slugify import slugify
16
+
17
+ from bookwright import integrations as _integrations
18
+ from bookwright.core.iso639_1 import ISO_639_1_CODES
19
+
20
+ from .envelope import emit_error
21
+ from .validate import (
22
+ InvalidProjectNameError,
23
+ check_slug_not_reserved,
24
+ )
25
+
26
+ AUTHOR_SENTINEL = "Unknown Author"
27
+ DEFAULT_LANGUAGE = "es"
28
+
29
+ AUTHOR_FALLBACK_WARNING = (
30
+ "bookwright: warning: author could not be resolved from git config or $USER; "
31
+ "using 'Unknown Author'"
32
+ )
33
+
34
+
35
+ def _git_config_user_name(cwd: Path) -> str | None:
36
+ """Read ``git config --get user.name`` scoped to ``cwd``.
37
+
38
+ Returns the trimmed value, or ``None`` if git is missing, the lookup
39
+ failed, or the value is empty.
40
+ """
41
+
42
+ try:
43
+ completed = subprocess.run(
44
+ ["git", "config", "--get", "user.name"],
45
+ cwd=str(cwd),
46
+ capture_output=True,
47
+ text=True,
48
+ check=False,
49
+ )
50
+ except (FileNotFoundError, OSError):
51
+ return None
52
+ if completed.returncode != 0:
53
+ return None
54
+ candidate = completed.stdout.strip()
55
+ return candidate or None
56
+
57
+
58
+ def resolve_authors(project_root: Path) -> tuple[list[str], bool]:
59
+ """Resolve ``book.authors`` per FR-016 / research §R1.
60
+
61
+ Returns ``(authors, fellback_to_sentinel)``. ``project_root`` is the
62
+ directory used as ``cwd`` for the ``git config`` probe (the project
63
+ parent in ``named`` mode is fine — git walks upward). Always returns
64
+ a non-empty list.
65
+ """
66
+
67
+ candidate = _git_config_user_name(project_root)
68
+ if candidate:
69
+ return [candidate], False
70
+
71
+ env_user = os.environ.get("USER", "").strip()
72
+ if env_user:
73
+ return [env_user], False
74
+
75
+ return [AUTHOR_SENTINEL], True
76
+
77
+
78
+ def resolve_language() -> str:
79
+ """Resolve ``book.language`` per FR-018 / research §R2.
80
+
81
+ Reads ``locale.getlocale()`` (no ``setlocale`` mutation), takes the
82
+ two-letter prefix, lower-cases it, and validates against
83
+ ``ISO_639_1_CODES``. Falls back silently to ``"es"`` on any failure.
84
+ """
85
+
86
+ try:
87
+ lang, _encoding = locale.getlocale()
88
+ except (ValueError, TypeError):
89
+ return DEFAULT_LANGUAGE
90
+ if not lang:
91
+ return DEFAULT_LANGUAGE
92
+ prefix = lang[:2].lower()
93
+ if prefix in ISO_639_1_CODES:
94
+ return prefix
95
+ return DEFAULT_LANGUAGE
96
+
97
+
98
+ def derive_slug(raw_name: str) -> str:
99
+ """Slugify ``raw_name`` per FR-021 / research §R3.
100
+
101
+ Uses ``python-slugify`` with the documented settings. Re-checks the
102
+ slug against the FR-021a reserved-name list so a name that slugifies
103
+ to ``"con"`` still trips.
104
+ """
105
+
106
+ slug = slugify(
107
+ raw_name,
108
+ lowercase=True,
109
+ separator="-",
110
+ allow_unicode=False,
111
+ regex_pattern=r"[^A-Za-z0-9]+",
112
+ )
113
+ if not slug:
114
+ raise InvalidProjectNameError(value=raw_name, rule="empty")
115
+ return check_slug_not_reserved(slug)
116
+
117
+
118
+ def is_interactive() -> bool:
119
+ """``True`` when stdin AND stdout are both TTYs (research §R7)."""
120
+
121
+ return sys.stdin.isatty() and sys.stdout.isatty()
122
+
123
+
124
+ def parse_named_slug(name: str, json_output: bool) -> str:
125
+ """Run ``derive_slug`` and translate failures to an envelope."""
126
+
127
+ try:
128
+ return derive_slug(name)
129
+ except InvalidProjectNameError as exc:
130
+ emit_error(
131
+ code=exc.code,
132
+ message=str(exc),
133
+ details=exc.details or {},
134
+ exit_code=2,
135
+ json_output=json_output,
136
+ rolled_back=False,
137
+ )
138
+
139
+
140
+ def resolve_authors_or_warn(
141
+ project_root: Path,
142
+ warnings: list[str],
143
+ ) -> list[str]:
144
+ """Resolve authors and emit the FR-016 fallback warning on stderr if used.
145
+
146
+ Per contract §5 the warning goes to stderr regardless of ``--json``; the
147
+ JSON envelope mirrors it via the ``warnings`` list on the success path.
148
+ """
149
+
150
+ authors, fellback = resolve_authors(project_root)
151
+ if fellback:
152
+ warnings.append(AUTHOR_FALLBACK_WARNING)
153
+ sys.stderr.write(AUTHOR_FALLBACK_WARNING + "\n")
154
+ return authors
155
+
156
+
157
+ def resolve_integration(
158
+ key: str,
159
+ raw_options: str,
160
+ *,
161
+ json_output: bool,
162
+ ) -> tuple[type[_integrations.SkillsIntegration], dict[str, str | bool]]:
163
+ """Look up the integration class and parse its options, emitting on failure."""
164
+
165
+ try:
166
+ integration_cls = _integrations.get(key)
167
+ except _integrations.UnknownIntegrationError as exc:
168
+ emit_error(
169
+ code=exc.code,
170
+ message=exc.message,
171
+ details=exc.details or {},
172
+ exit_code=5,
173
+ json_output=json_output,
174
+ rolled_back=False,
175
+ )
176
+
177
+ try:
178
+ parsed_options = _integrations.parse_options(raw_options, integration_cls)
179
+ except (
180
+ _integrations.UnknownOptionError,
181
+ _integrations.MalformedOptionError,
182
+ _integrations.InvalidOptionDeclarationError,
183
+ ) as exc:
184
+ emit_error(
185
+ code=exc.code,
186
+ message=exc.message,
187
+ details=exc.details or {},
188
+ exit_code=5,
189
+ json_output=json_output,
190
+ rolled_back=False,
191
+ )
192
+
193
+ return integration_cls, parsed_options
@@ -0,0 +1,242 @@
1
+ """Manifest writer + template walker + orchestration for ``bookwright init``.
2
+
3
+ The atomic-or-nothing rollback ledger and the tracked fs primitives now live in
4
+ the shared :mod:`bookwright.io.fs` module (extracted in iteration 9 so the skills
5
+ materializer can record through the same ledger). They are re-exported here for
6
+ backward compatibility with the ``init`` package's importers. The template walker
7
+ drives Jinja2 over packaged resources (research §R9).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from importlib.resources import as_file, files
14
+ from importlib.resources.abc import Traversable
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import jinja2
19
+
20
+ from bookwright import integrations as _integrations
21
+ from bookwright.core.manifest import Manifest
22
+ from bookwright.io.fs import (
23
+ BackupCreationError,
24
+ BackupLedger,
25
+ TargetOutsideProjectRootError,
26
+ _register_target,
27
+ mkdir_tracked,
28
+ write_bytes_atomic,
29
+ )
30
+
31
+ from . import envelope, git
32
+
33
+ if TYPE_CHECKING:
34
+ from .envelope import ResolvedInvocation
35
+
36
+ # Re-exported from io.fs for the init package's importers (envelope, git,
37
+ # conflict) and the resource-render tests. Listed in __all__ so linters do not
38
+ # flag the re-exports as unused.
39
+ __all__ = [
40
+ "COMMIT_MESSAGE",
41
+ "GIT_EXISTING_WARNING",
42
+ "GIT_MISSING_WARNING",
43
+ "BackupCreationError",
44
+ "BackupLedger",
45
+ "TargetOutsideProjectRootError",
46
+ "copy_resource_file",
47
+ "dump_manifest_tracked",
48
+ "mkdir_tracked",
49
+ "render_resource_tree",
50
+ "run_scaffold_steps",
51
+ "write_bytes_atomic",
52
+ ]
53
+
54
+ COMMIT_MESSAGE = "Initial commit from bookwright init"
55
+
56
+ GIT_MISSING_WARNING = (
57
+ "bookwright: warning: git not found on PATH; project created without a repository"
58
+ )
59
+ GIT_EXISTING_WARNING = "bookwright: warning: existing .git/ detected; skipped git init and commit"
60
+
61
+
62
+ def dump_manifest_tracked(manifest: Manifest, target: Path, ledger: BackupLedger) -> None:
63
+ """Register ``target`` with the ledger, then delegate to ``Manifest.dump``.
64
+
65
+ Keeps ``Manifest.dump`` as the sole TOML writer (FR-015) while still
66
+ participating the write in the backup ledger (FR-030).
67
+ """
68
+
69
+ _register_target(target, ledger)
70
+ manifest.dump(target, overwrite=target.exists())
71
+
72
+
73
+ _J2_SUFFIX = ".j2"
74
+ _RESOURCE_PACKAGE = "bookwright.resources.project"
75
+
76
+
77
+ def _iter_resource_files(root: Traversable) -> list[tuple[str, Traversable]]:
78
+ """Walk a ``Traversable`` resource tree, yielding ``(rel_posix, node)``.
79
+
80
+ ``rel_posix`` uses ``/`` separators and is relative to ``root``. The
81
+ ``__init__.py`` marker file is filtered out — it is implementation
82
+ detail of the package layout, not part of the project template.
83
+ """
84
+
85
+ results: list[tuple[str, Traversable]] = []
86
+
87
+ def _walk(node: Traversable, prefix: str) -> None:
88
+ for child in node.iterdir():
89
+ name = child.name
90
+ if name == "__pycache__":
91
+ continue
92
+ if prefix == "" and name == "__init__.py":
93
+ continue
94
+ rel = f"{prefix}{name}" if prefix == "" else f"{prefix}/{name}"
95
+ if child.is_dir():
96
+ _walk(child, rel)
97
+ else:
98
+ results.append((rel, child))
99
+
100
+ _walk(root, "")
101
+ return results
102
+
103
+
104
+ def _target_relpath(rel: str) -> Path:
105
+ """Drop the ``.j2`` suffix when present; preserve directory layout."""
106
+
107
+ return Path(rel[: -len(_J2_SUFFIX)]) if rel.endswith(_J2_SUFFIX) else Path(rel)
108
+
109
+
110
+ def render_resource_tree(
111
+ target_root: Path,
112
+ context: dict[str, Any],
113
+ ledger: BackupLedger,
114
+ ) -> None:
115
+ """Walk ``bookwright.resources.project`` and render it into ``target_root``.
116
+
117
+ ``.j2`` files go through one shared Jinja2 environment with strict
118
+ undefined; everything else is byte-copied. Empty directories are
119
+ preserved via ``.gitkeep`` resources.
120
+ """
121
+
122
+ env = jinja2.Environment(
123
+ loader=jinja2.PackageLoader(_RESOURCE_PACKAGE, ""),
124
+ autoescape=False,
125
+ keep_trailing_newline=True,
126
+ undefined=jinja2.StrictUndefined,
127
+ )
128
+
129
+ package_root = files(_RESOURCE_PACKAGE)
130
+ entries = _iter_resource_files(package_root)
131
+
132
+ for rel, node in sorted(entries, key=lambda item: item[0]):
133
+ target = target_root / _target_relpath(rel)
134
+ mkdir_tracked(target.parent, ledger)
135
+
136
+ if rel.endswith(_J2_SUFFIX):
137
+ template = env.get_template(rel)
138
+ rendered = template.render(**context)
139
+ write_bytes_atomic(target, rendered.encode("utf-8"), ledger)
140
+ continue
141
+
142
+ with as_file(node) as src:
143
+ payload = Path(src).read_bytes()
144
+ write_bytes_atomic(target, payload, ledger)
145
+
146
+
147
+ def copy_resource_file(
148
+ package: str,
149
+ name: str,
150
+ target: Path,
151
+ ledger: BackupLedger,
152
+ ) -> None:
153
+ """Copy a single packaged resource file to ``target`` through the ledger."""
154
+
155
+ node = files(package).joinpath(name)
156
+ with as_file(node) as src:
157
+ payload = Path(src).read_bytes()
158
+ write_bytes_atomic(target, payload, ledger)
159
+
160
+
161
+ def run_scaffold_steps( # noqa: PLR0913 — orchestrator: gathers every previously-resolved input in one call
162
+ *,
163
+ resolved: ResolvedInvocation,
164
+ integration: _integrations.SkillsIntegration,
165
+ parsed_options: dict[str, str | bool],
166
+ ledger: BackupLedger,
167
+ warnings: list[str],
168
+ author_name: str,
169
+ ) -> None:
170
+ """All filesystem mutations + integration setup + git step.
171
+
172
+ The orchestrator behind ``bookwright init``'s success path: renders
173
+ the packaged template tree, dumps the manifest, copies vocabularies,
174
+ runs the integration's ``setup()`` and finally ``git init + commit``
175
+ when applicable. The ``git_status`` on ``resolved`` is settled by
176
+ ``main.run`` before this function is called.
177
+ """
178
+
179
+ project_root = Path(resolved.project_root)
180
+
181
+ template_context = {
182
+ "title": resolved.title,
183
+ "project_slug": resolved.project_slug,
184
+ "author": resolved.authors[0],
185
+ "language": resolved.language,
186
+ "integration_key": resolved.integration_key,
187
+ }
188
+
189
+ # 1) Render the packaged template tree.
190
+ render_resource_tree(project_root, template_context, ledger)
191
+
192
+ # 2) Build and dump the manifest.
193
+ manifest = Manifest.build(
194
+ title=resolved.title,
195
+ authors=list(resolved.authors),
196
+ integration_key=resolved.integration_key,
197
+ integration_skills_dir=resolved.integration_skills_dir,
198
+ integration_options=dict(parsed_options),
199
+ language=resolved.language,
200
+ type="novel",
201
+ status="idea",
202
+ uri_base=f"https://example.org/{resolved.project_slug}/",
203
+ )
204
+ dump_manifest_tracked(manifest, project_root / "manifest.toml", ledger)
205
+
206
+ # 3) Copy bundled vocabularies into .bookwright/vocabularies/.
207
+ vocab_target = project_root / ".bookwright" / "vocabularies"
208
+ mkdir_tracked(vocab_target, ledger)
209
+ for vocab in ("propp.ttl", "greimas.ttl"):
210
+ copy_resource_file(
211
+ "bookwright.resources.vocabularies",
212
+ vocab,
213
+ vocab_target / vocab,
214
+ ledger,
215
+ )
216
+
217
+ # 4) Wire the integration's setup() through the ledger. The materializer
218
+ # records every directory and file it creates via this live BackupLedger, so
219
+ # a failed init unwinds all materialized skills — including over a pre-existing
220
+ # skills_dir (FR-019/SC-008). setup() resolves + mkdir_tracked's the target.
221
+ integration.setup(project_root, manifest, parsed_options, ledger=ledger)
222
+
223
+ # 5) Write the init-options record (git_status already settled by main.run).
224
+ record = envelope.build_options_record(resolved)
225
+ options_payload = envelope.serialize_options_record(record)
226
+ write_bytes_atomic(
227
+ project_root / ".bookwright" / "init-options.json",
228
+ options_payload,
229
+ ledger,
230
+ )
231
+
232
+ # 6) Run git init + commit if applicable. Warnings go to stderr regardless
233
+ # of ``--json`` (contract §5); the JSON envelope mirrors them via the
234
+ # ``warnings`` list on the success path.
235
+ if resolved.git_status == "initialized":
236
+ git.init_and_commit(project_root, COMMIT_MESSAGE, author_name, ledger)
237
+ elif resolved.git_status == "skipped_existing_repo":
238
+ warnings.append(GIT_EXISTING_WARNING)
239
+ sys.stderr.write(GIT_EXISTING_WARNING + "\n")
240
+ elif resolved.git_status == "skipped_no_binary":
241
+ warnings.append(GIT_MISSING_WARNING)
242
+ sys.stderr.write(GIT_MISSING_WARNING + "\n")
@@ -0,0 +1,172 @@
1
+ """FR-021a ``PROJECT_NAME`` validation.
2
+
3
+ Pure functions only; no filesystem side-effects. Surfaces structured
4
+ ``InvalidProjectNameError`` so the caller can branch on ``rule`` instead of
5
+ prose. Rules per research §R3 and contract §4.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Literal
11
+
12
+ from bookwright.errors import BookwrightError
13
+
14
+ from .envelope import emit_error
15
+
16
+ ProjectNameRule = Literal[
17
+ "empty",
18
+ "path_separator",
19
+ "dot_or_dotdot",
20
+ "leading_dot",
21
+ "too_long",
22
+ "reserved_name",
23
+ ]
24
+
25
+ _MAX_LENGTH = 100
26
+
27
+ _WINDOWS_RESERVED: frozenset[str] = frozenset(
28
+ {
29
+ "CON",
30
+ "PRN",
31
+ "AUX",
32
+ "NUL",
33
+ *(f"COM{i}" for i in range(1, 10)),
34
+ *(f"LPT{i}" for i in range(1, 10)),
35
+ }
36
+ )
37
+
38
+
39
+ class InvalidProjectNameError(BookwrightError):
40
+ """Raised when ``PROJECT_NAME`` violates one of the FR-021a rules."""
41
+
42
+ code = "invalid_project_name"
43
+
44
+ def __init__(self, *, value: str, rule: ProjectNameRule) -> None:
45
+ self.value = value
46
+ self.rule = rule
47
+ super().__init__(
48
+ f"invalid project name {value!r}; rule: {rule}",
49
+ {"value": value, "rule": rule},
50
+ )
51
+
52
+
53
+ def _is_reserved(candidate: str) -> bool:
54
+ return candidate.upper() in _WINDOWS_RESERVED
55
+
56
+
57
+ def validate_project_name(value: str) -> str:
58
+ """Validate and normalise a raw ``PROJECT_NAME``.
59
+
60
+ Returns the value with leading/trailing whitespace stripped. Raises
61
+ ``InvalidProjectNameError`` on any rule violation. The check order is
62
+ fixed: empty → path_separator → dot_or_dotdot → leading_dot → too_long
63
+ → reserved_name. The first failing rule wins (so ``"."`` is
64
+ ``dot_or_dotdot``, not ``leading_dot``).
65
+ """
66
+
67
+ stripped = value.strip()
68
+ if not stripped:
69
+ raise InvalidProjectNameError(value=value, rule="empty")
70
+ if "/" in stripped or "\\" in stripped:
71
+ raise InvalidProjectNameError(value=stripped, rule="path_separator")
72
+ if stripped in {".", ".."}:
73
+ raise InvalidProjectNameError(value=stripped, rule="dot_or_dotdot")
74
+ if stripped.startswith("."):
75
+ raise InvalidProjectNameError(value=stripped, rule="leading_dot")
76
+ if len(stripped) > _MAX_LENGTH:
77
+ raise InvalidProjectNameError(value=stripped, rule="too_long")
78
+ if _is_reserved(stripped):
79
+ raise InvalidProjectNameError(value=stripped, rule="reserved_name")
80
+ return stripped
81
+
82
+
83
+ def check_slug_not_reserved(slug: str) -> str:
84
+ """Re-check a slug against the reserved-name list (R3).
85
+
86
+ Same exception family so callers can fold both checks into one
87
+ error envelope.
88
+ """
89
+
90
+ if not slug:
91
+ raise InvalidProjectNameError(value=slug, rule="empty")
92
+ if _is_reserved(slug):
93
+ raise InvalidProjectNameError(value=slug, rule="reserved_name")
94
+ return slug
95
+
96
+
97
+ def check_mutex(project_name: str | None, here: bool, *, json_output: bool) -> None:
98
+ """FR-002 — exactly one of ``PROJECT_NAME`` / ``--here`` is required.
99
+
100
+ Emits a ``mutually_exclusive`` envelope and exits with code 2 on
101
+ violation.
102
+ """
103
+
104
+ if project_name is not None and here:
105
+ emit_error(
106
+ code="mutually_exclusive",
107
+ message="PROJECT_NAME and --here are mutually exclusive",
108
+ details={},
109
+ exit_code=2,
110
+ json_output=json_output,
111
+ rolled_back=False,
112
+ )
113
+ if project_name is None and not here:
114
+ emit_error(
115
+ code="mutually_exclusive",
116
+ message="must specify PROJECT_NAME or --here",
117
+ details={},
118
+ exit_code=2,
119
+ json_output=json_output,
120
+ rolled_back=False,
121
+ )
122
+
123
+
124
+ def parse_named_name(value: str, json_output: bool) -> str:
125
+ """Run ``validate_project_name`` and translate failures to an envelope."""
126
+
127
+ try:
128
+ return validate_project_name(value)
129
+ except InvalidProjectNameError as exc:
130
+ emit_error(
131
+ code=exc.code,
132
+ message=str(exc),
133
+ details=exc.details or {},
134
+ exit_code=2,
135
+ json_output=json_output,
136
+ rolled_back=False,
137
+ )
138
+
139
+
140
+ def parse_here_basename(basename: str, json_output: bool) -> str:
141
+ """Reduced FR-021a check for ``--here``: empty / path-separator / reserved only."""
142
+
143
+ if not basename.strip():
144
+ emit_error(
145
+ code="invalid_project_name",
146
+ message="current directory basename is empty",
147
+ details={"value": basename, "rule": "empty"},
148
+ exit_code=2,
149
+ json_output=json_output,
150
+ rolled_back=False,
151
+ )
152
+ if "/" in basename or "\\" in basename:
153
+ emit_error(
154
+ code="invalid_project_name",
155
+ message=f"current directory basename {basename!r} contains a path separator",
156
+ details={"value": basename, "rule": "path_separator"},
157
+ exit_code=2,
158
+ json_output=json_output,
159
+ rolled_back=False,
160
+ )
161
+ try:
162
+ check_slug_not_reserved(basename)
163
+ except InvalidProjectNameError as exc:
164
+ emit_error(
165
+ code=exc.code,
166
+ message=str(exc),
167
+ details=exc.details or {},
168
+ exit_code=2,
169
+ json_output=json_output,
170
+ rolled_back=False,
171
+ )
172
+ return basename
@@ -0,0 +1,22 @@
1
+ """The ``bookwright integration`` Typer sub-app.
2
+
3
+ Manages a project's active agent integration. ``use`` lives in its own module
4
+ (Principle IV) and registers its callback here. Wired into the root CLI in
5
+ :mod:`bookwright.cli` via ``app.add_typer(integration.app, name="integration")``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+
12
+ app = typer.Typer(
13
+ name="integration",
14
+ help="Manage the project's agent integration (re-materialize skills).",
15
+ no_args_is_help=True,
16
+ add_completion=False,
17
+ )
18
+
19
+ # `use` registers its callback on `app` at import time; importing it here keeps
20
+ # the sub-app self-contained. The `as` redirect marks the import as an
21
+ # intentional re-export (registration side effect).
22
+ from . import use as use # noqa: E402