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,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
|