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,120 @@
|
|
|
1
|
+
"""``bookwright integration use <key>`` — switch the project's agent integration.
|
|
2
|
+
|
|
3
|
+
Re-materializes one ``SKILL.md`` per source command under the chosen integration's
|
|
4
|
+
skills directory (reusing the shared materializer + the plugin registry —
|
|
5
|
+
Principles V/VI/VII), updates the manifest's ``[integration]`` block, and leaves
|
|
6
|
+
any previously-materialized skills directory **untouched** (the swap-residue
|
|
7
|
+
policy: no cleanup in v0). The operation is atomic — a materialization or lint
|
|
8
|
+
failure rolls the whole change back through a ``BackupLedger``.
|
|
9
|
+
|
|
10
|
+
This is the supported integration-swap mechanism. ``init`` deliberately refuses to
|
|
11
|
+
re-initialize an existing project (the ``.bookwright/`` guard), so switching the
|
|
12
|
+
*integration* of a live book is its own intention-revealing command rather than a
|
|
13
|
+
re-init flag. Principle IX: under ``--json`` exactly one JSON document on stdout;
|
|
14
|
+
all human prose goes to stderr.
|
|
15
|
+
|
|
16
|
+
Fault model: missing project / unparseable manifest / unknown integration key →
|
|
17
|
+
exit 2; a skill materialization or lint failure → exit 3 (fully rolled back).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
from rich.console import Console
|
|
28
|
+
|
|
29
|
+
from bookwright.core.errors import ManifestError
|
|
30
|
+
from bookwright.core.manifest import Manifest
|
|
31
|
+
from bookwright.integrations import UnknownIntegrationError, get
|
|
32
|
+
from bookwright.integrations.errors import SkillLintError, SkillMaterializationError
|
|
33
|
+
from bookwright.io.errors import ProjectNotFoundError
|
|
34
|
+
from bookwright.io.fs import BackupLedger
|
|
35
|
+
from bookwright.io.project import find_project_root
|
|
36
|
+
|
|
37
|
+
from .._envelope import invalid_manifest_payload
|
|
38
|
+
from . import app
|
|
39
|
+
|
|
40
|
+
EXIT_CONFIG = 2
|
|
41
|
+
EXIT_MATERIALIZE = 3
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command("use")
|
|
45
|
+
def run(
|
|
46
|
+
key: str = typer.Argument(..., help="Integration key to switch to (e.g. 'claude', 'generic')."),
|
|
47
|
+
json_output: bool = typer.Option(
|
|
48
|
+
False, "--json", help="Emit the result as one JSON document on stdout."
|
|
49
|
+
),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Switch the project to integration ``key`` and re-materialize its skills."""
|
|
52
|
+
try:
|
|
53
|
+
payload = _use(key)
|
|
54
|
+
except ProjectNotFoundError as exc:
|
|
55
|
+
_emit_error(exc.to_json(), json_output)
|
|
56
|
+
raise typer.Exit(EXIT_CONFIG) from exc
|
|
57
|
+
except ManifestError as exc:
|
|
58
|
+
_emit_error(invalid_manifest_payload(exc), json_output)
|
|
59
|
+
raise typer.Exit(EXIT_CONFIG) from exc
|
|
60
|
+
except UnknownIntegrationError as exc:
|
|
61
|
+
_emit_error(exc.to_json(), json_output)
|
|
62
|
+
raise typer.Exit(EXIT_CONFIG) from exc
|
|
63
|
+
except (SkillLintError, SkillMaterializationError) as exc:
|
|
64
|
+
_emit_error(exc.to_json(), json_output)
|
|
65
|
+
raise typer.Exit(EXIT_MATERIALIZE) from exc
|
|
66
|
+
|
|
67
|
+
if json_output:
|
|
68
|
+
_emit_json(payload)
|
|
69
|
+
else:
|
|
70
|
+
Console(stderr=True, highlight=False).print(
|
|
71
|
+
f"bookwright: switched integration to '{payload['integration']}' "
|
|
72
|
+
f"({payload['count']} skills → {payload['skills_dir']})"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _use(key: str) -> dict[str, Any]:
|
|
77
|
+
"""Materialize the new integration, update the manifest, and return the report.
|
|
78
|
+
|
|
79
|
+
Resolves the integration *before* any filesystem mutation (an unknown key is a
|
|
80
|
+
no-op exit-2). All writes — the skills and the manifest overwrite — go through a
|
|
81
|
+
single ``BackupLedger`` so a failure leaves the project byte-identical.
|
|
82
|
+
"""
|
|
83
|
+
root = find_project_root()
|
|
84
|
+
manifest_path = root / "manifest.toml"
|
|
85
|
+
manifest = Manifest.load(manifest_path)
|
|
86
|
+
|
|
87
|
+
integration = get(key)() # raises UnknownIntegrationError on an unknown key
|
|
88
|
+
skills_dir = integration.resolve_skills_dir().as_posix()
|
|
89
|
+
|
|
90
|
+
ledger = BackupLedger(root)
|
|
91
|
+
try:
|
|
92
|
+
integration.setup(root, manifest, ledger=ledger)
|
|
93
|
+
ledger.record_overwrite(manifest_path)
|
|
94
|
+
manifest.set_integration(key=key, skills_dir=skills_dir)
|
|
95
|
+
manifest.dump(manifest_path, overwrite=True)
|
|
96
|
+
except BaseException:
|
|
97
|
+
ledger.rollback()
|
|
98
|
+
raise
|
|
99
|
+
ledger.commit()
|
|
100
|
+
|
|
101
|
+
materialized = sorted(p.parent.name for p in (root / skills_dir).rglob("SKILL.md"))
|
|
102
|
+
return {
|
|
103
|
+
"status": "ok",
|
|
104
|
+
"integration": key,
|
|
105
|
+
"skills_dir": skills_dir,
|
|
106
|
+
"materialized": materialized,
|
|
107
|
+
"count": len(materialized),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _emit_json(payload: dict[str, Any]) -> None:
|
|
112
|
+
sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _emit_error(payload: dict[str, Any], json_output: bool) -> None:
|
|
116
|
+
if json_output:
|
|
117
|
+
_emit_json(payload)
|
|
118
|
+
else:
|
|
119
|
+
message = payload.get("message", payload.get("code", "error"))
|
|
120
|
+
sys.stderr.write(f"bookwright: error: {message}\n")
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""``bookwright validate`` — run the active validators and report violations.
|
|
2
|
+
|
|
3
|
+
Binding: contracts/cli-validate.md, FR-008..014, Principle IX. The CI gate (exit 1)
|
|
4
|
+
is computed from the **unfiltered** error-severity set, so ``--scope`` / ``--severity``
|
|
5
|
+
(display filters) can never hide an error. Under ``--json`` exactly one JSON document
|
|
6
|
+
goes to stdout; all prose goes to stderr.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Annotated, Any
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
|
|
19
|
+
from bookwright.core.errors import ManifestError
|
|
20
|
+
from bookwright.core.manifest import Manifest
|
|
21
|
+
from bookwright.errors import BookwrightError
|
|
22
|
+
from bookwright.indexers import GraphLoadError, UnknownIndexerError, resolve_indexer
|
|
23
|
+
from bookwright.io.errors import ProjectNotFoundError
|
|
24
|
+
from bookwright.io.project import find_project_root
|
|
25
|
+
from bookwright.validation import (
|
|
26
|
+
ScopeFilter,
|
|
27
|
+
Severity,
|
|
28
|
+
UnknownValidatorError,
|
|
29
|
+
ValidationContext,
|
|
30
|
+
ValidationReport,
|
|
31
|
+
discover_validators,
|
|
32
|
+
resolve_active,
|
|
33
|
+
run_validators,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from ._envelope import INVALID_MANIFEST_CODE
|
|
37
|
+
|
|
38
|
+
EXIT_OK = 0
|
|
39
|
+
EXIT_GATE = 1
|
|
40
|
+
EXIT_CONFIG = 2
|
|
41
|
+
|
|
42
|
+
_CUSTOM_SUBPATH = (".bookwright", "validators")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _UsageError(BookwrightError):
|
|
46
|
+
"""An exit-2 config/usage failure carrying a contract error envelope.
|
|
47
|
+
|
|
48
|
+
A single class whose ``code`` is set per instance (``no_project`` /
|
|
49
|
+
``invalid_manifest`` / ``unknown_validator`` / ``empty_scope``) — hence the
|
|
50
|
+
per-instance ``self.code`` override the base supports (research Decision 2).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
|
|
54
|
+
self.code = code
|
|
55
|
+
super().__init__(message, details)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run(
|
|
59
|
+
scope: Annotated[
|
|
60
|
+
Path | None,
|
|
61
|
+
typer.Option("--scope", help="Limit the reported violations to this file or directory."),
|
|
62
|
+
] = None,
|
|
63
|
+
severity: Annotated[
|
|
64
|
+
Severity,
|
|
65
|
+
typer.Option("--severity", help="Report this level and above (error>warning>info)."),
|
|
66
|
+
] = Severity.info,
|
|
67
|
+
json_output: Annotated[
|
|
68
|
+
bool, typer.Option("--json", help="Emit one JSON document on stdout and nothing else.")
|
|
69
|
+
] = False,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Validate the project and report coherence violations (exit 1 gates on errors)."""
|
|
72
|
+
try:
|
|
73
|
+
report, scope_filter = _validate(scope)
|
|
74
|
+
except _UsageError as exc:
|
|
75
|
+
_emit_error(exc.to_json(), json_output)
|
|
76
|
+
raise typer.Exit(EXIT_CONFIG) from exc
|
|
77
|
+
|
|
78
|
+
if json_output:
|
|
79
|
+
_emit_json(report.to_json(scope=scope_filter, severity=severity))
|
|
80
|
+
else:
|
|
81
|
+
report.render(Console(), scope=scope_filter, severity=severity)
|
|
82
|
+
|
|
83
|
+
raise typer.Exit(EXIT_GATE if report.failed else EXIT_OK)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _validate(scope: Path | None) -> tuple[ValidationReport, ScopeFilter | None]:
|
|
87
|
+
"""Run the pipeline, raising :class:`_UsageError` for every exit-2 condition."""
|
|
88
|
+
try:
|
|
89
|
+
root = find_project_root()
|
|
90
|
+
except ProjectNotFoundError as exc:
|
|
91
|
+
raise _UsageError("no_project", str(exc), {"start": exc.start}) from exc
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
manifest = Manifest.load(root / "manifest.toml")
|
|
95
|
+
except ManifestError as exc:
|
|
96
|
+
raise _UsageError(INVALID_MANIFEST_CODE, str(exc)) from exc
|
|
97
|
+
|
|
98
|
+
indexer = _load_indexer(manifest, root)
|
|
99
|
+
project = ValidationContext(root=root, manifest=manifest)
|
|
100
|
+
|
|
101
|
+
builtins, customs, load_errors = discover_validators(root.joinpath(*_CUSTOM_SUBPATH))
|
|
102
|
+
try:
|
|
103
|
+
active = resolve_active(builtins, customs, manifest.validators)
|
|
104
|
+
except UnknownValidatorError as exc:
|
|
105
|
+
# UnknownValidatorError is itself a BookwrightError carrying the exact
|
|
106
|
+
# ``unknown_validator`` envelope; re-thread its code/message/details into
|
|
107
|
+
# the exit-2 funnel rather than rebuilding them by hand.
|
|
108
|
+
raise _UsageError(exc.code, exc.message, exc.details) from exc
|
|
109
|
+
|
|
110
|
+
scope_filter = _resolve_scope(scope, root)
|
|
111
|
+
|
|
112
|
+
violations, run_errors, ran = run_validators(active, project, indexer)
|
|
113
|
+
report = ValidationReport(
|
|
114
|
+
violations=tuple(violations),
|
|
115
|
+
errors=(*load_errors, *run_errors),
|
|
116
|
+
ran=tuple(ran),
|
|
117
|
+
)
|
|
118
|
+
return report, scope_filter
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _load_indexer(manifest: Manifest, root: Path) -> Any:
|
|
122
|
+
"""The manifest-selected engine, with ``graph.ttl`` loaded when it exists."""
|
|
123
|
+
try:
|
|
124
|
+
engine_cls = resolve_indexer(manifest.bookwright.indexer)
|
|
125
|
+
except UnknownIndexerError as exc:
|
|
126
|
+
raise _UsageError("invalid_manifest", str(exc)) from exc
|
|
127
|
+
engine = engine_cls()
|
|
128
|
+
graph_path = root / manifest.paths.graph
|
|
129
|
+
if graph_path.is_file():
|
|
130
|
+
try:
|
|
131
|
+
engine.load(graph_path)
|
|
132
|
+
except GraphLoadError as exc:
|
|
133
|
+
raise _UsageError("invalid_manifest", str(exc)) from exc
|
|
134
|
+
return engine
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _resolve_scope(scope: Path | None, root: Path) -> ScopeFilter | None:
|
|
138
|
+
"""Resolve ``--scope`` under the project root, or exit 2 ``empty_scope`` (D10)."""
|
|
139
|
+
if scope is None:
|
|
140
|
+
return None
|
|
141
|
+
resolved = scope if scope.is_absolute() else (Path.cwd() / scope)
|
|
142
|
+
resolved = resolved.resolve()
|
|
143
|
+
if not resolved.exists():
|
|
144
|
+
raise _UsageError("empty_scope", f"scope path does not exist: {scope}")
|
|
145
|
+
try:
|
|
146
|
+
rel = resolved.relative_to(root).as_posix()
|
|
147
|
+
except ValueError as exc:
|
|
148
|
+
raise _UsageError("empty_scope", f"scope path is outside the project: {scope}") from exc
|
|
149
|
+
return ScopeFilter(rel=rel, is_dir=resolved.is_dir())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _emit_json(payload: dict[str, Any]) -> None:
|
|
153
|
+
sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _emit_error(payload: dict[str, Any], json_output: bool) -> None:
|
|
157
|
+
if json_output:
|
|
158
|
+
_emit_json(payload)
|
|
159
|
+
else:
|
|
160
|
+
sys.stderr.write(f"bookwright: error: {payload['message']}\n")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""`bookwright version` — report the package and GOLEM schema versions."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from bookwright import __version__
|
|
10
|
+
from bookwright.resources.schemas import load_schema_version
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _read_golem_schema_version() -> str:
|
|
14
|
+
try:
|
|
15
|
+
return load_schema_version()
|
|
16
|
+
except FileNotFoundError:
|
|
17
|
+
return "unknown"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(
|
|
21
|
+
json_output: bool = typer.Option(
|
|
22
|
+
False, "--json", help="Emit a single JSON document on stdout."
|
|
23
|
+
),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Print the bookwright package version and the bundled GOLEM schema version."""
|
|
26
|
+
payload = {
|
|
27
|
+
"package_version": __version__,
|
|
28
|
+
"golem_schema_version": _read_golem_schema_version(),
|
|
29
|
+
}
|
|
30
|
+
if json_output:
|
|
31
|
+
sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
|
32
|
+
return
|
|
33
|
+
console = Console()
|
|
34
|
+
console.print(f"bookwright {payload['package_version']}")
|
|
35
|
+
console.print(f"GOLEM schema: {payload['golem_schema_version']}")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Public API for the Bookwright core domain model.
|
|
2
|
+
|
|
3
|
+
The members re-exported below are the stable iteration-2 contract; anything
|
|
4
|
+
else is implementation detail. See specs/002-manifest-model/contracts/manifest_api.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from bookwright.core._research_block import ResearchBlock
|
|
8
|
+
from bookwright.core.errors import (
|
|
9
|
+
ManifestError,
|
|
10
|
+
ManifestNotFoundError,
|
|
11
|
+
ManifestOverwriteError,
|
|
12
|
+
ManifestSyntaxError,
|
|
13
|
+
ManifestValidationError,
|
|
14
|
+
ManifestWarning,
|
|
15
|
+
)
|
|
16
|
+
from bookwright.core.manifest import (
|
|
17
|
+
BOOK_STATUSES,
|
|
18
|
+
BOOK_TYPES,
|
|
19
|
+
KNOWN_MANIFEST_VERSIONS,
|
|
20
|
+
Manifest,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"BOOK_STATUSES",
|
|
25
|
+
"BOOK_TYPES",
|
|
26
|
+
"KNOWN_MANIFEST_VERSIONS",
|
|
27
|
+
"Manifest",
|
|
28
|
+
"ManifestError",
|
|
29
|
+
"ManifestNotFoundError",
|
|
30
|
+
"ManifestOverwriteError",
|
|
31
|
+
"ManifestSyntaxError",
|
|
32
|
+
"ManifestValidationError",
|
|
33
|
+
"ManifestWarning",
|
|
34
|
+
"ResearchBlock",
|
|
35
|
+
]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""TOML block models for the Bookwright manifest.
|
|
2
|
+
|
|
3
|
+
Internal helper extracted from `bookwright.core.manifest` to honour the
|
|
4
|
+
Principle IV 500-line ceiling, mirroring the established `_build`,
|
|
5
|
+
`_translate`, and `_research_block` split. The root `Manifest` model and the
|
|
6
|
+
load/dump/build entry points stay in `manifest.py`, which re-exports the
|
|
7
|
+
public members defined here (`BOOK_TYPES`, `BOOK_STATUSES`) so the
|
|
8
|
+
`bookwright.core` surface is unchanged.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any, Literal, get_args
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from packaging.version import InvalidVersion, Version
|
|
18
|
+
from pydantic import (
|
|
19
|
+
BaseModel,
|
|
20
|
+
ConfigDict,
|
|
21
|
+
Field,
|
|
22
|
+
field_validator,
|
|
23
|
+
)
|
|
24
|
+
from pydantic_core import PydanticCustomError
|
|
25
|
+
|
|
26
|
+
from bookwright.core.iso639_1 import ISO_639_1_CODES
|
|
27
|
+
|
|
28
|
+
_MANIFEST_VERSION_RE = re.compile(r"^[1-9][0-9]*$")
|
|
29
|
+
|
|
30
|
+
BookType = Literal["novel", "essay", "memoir", "non-fiction-narrative", "other"]
|
|
31
|
+
BookStatus = Literal["idea", "structuring", "drafting", "revising", "done"]
|
|
32
|
+
|
|
33
|
+
BOOK_TYPES: frozenset[str] = frozenset(get_args(BookType))
|
|
34
|
+
BOOK_STATUSES: frozenset[str] = frozenset(get_args(BookStatus))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_manifest_version(raw: str) -> int:
|
|
38
|
+
"""Parse the `bookwright.manifest_version` string into a positive int.
|
|
39
|
+
|
|
40
|
+
Raises a Pydantic-friendly error when the value does not match the
|
|
41
|
+
`^[1-9][0-9]*$` shape required by FR-013.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
if not _MANIFEST_VERSION_RE.match(raw):
|
|
45
|
+
raise PydanticCustomError(
|
|
46
|
+
"not_positive_integer_string",
|
|
47
|
+
"manifest_version must match ^[1-9][0-9]*$ (got '{value}')",
|
|
48
|
+
{"value": raw},
|
|
49
|
+
)
|
|
50
|
+
return int(raw)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BookwrightBlock(BaseModel):
|
|
54
|
+
"""`[bookwright]` block — CLI/schema floor and project URI namespace."""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
57
|
+
|
|
58
|
+
cli_version_min: str
|
|
59
|
+
schema_version: str
|
|
60
|
+
manifest_version: str
|
|
61
|
+
uri_base: str
|
|
62
|
+
indexer: str = "rdflib"
|
|
63
|
+
|
|
64
|
+
@field_validator("cli_version_min", mode="after")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _check_cli_version_min(cls, value: str) -> str:
|
|
67
|
+
try:
|
|
68
|
+
Version(value)
|
|
69
|
+
except InvalidVersion as exc:
|
|
70
|
+
raise PydanticCustomError(
|
|
71
|
+
"not_pep440",
|
|
72
|
+
"cli_version_min '{value}' is not a valid PEP 440 version",
|
|
73
|
+
{"value": value},
|
|
74
|
+
) from exc
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
@field_validator("schema_version", mode="after")
|
|
78
|
+
@classmethod
|
|
79
|
+
def _check_schema_version(cls, value: str) -> str:
|
|
80
|
+
stripped = value.strip()
|
|
81
|
+
if not stripped:
|
|
82
|
+
raise PydanticCustomError(
|
|
83
|
+
"empty",
|
|
84
|
+
"schema_version must be a non-empty string",
|
|
85
|
+
{"value": value},
|
|
86
|
+
)
|
|
87
|
+
if stripped != value:
|
|
88
|
+
raise PydanticCustomError(
|
|
89
|
+
"whitespace",
|
|
90
|
+
"schema_version must not have leading or trailing whitespace",
|
|
91
|
+
{"value": value},
|
|
92
|
+
)
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
@field_validator("manifest_version", mode="after")
|
|
96
|
+
@classmethod
|
|
97
|
+
def _check_manifest_version(cls, value: str) -> str:
|
|
98
|
+
_parse_manifest_version(value)
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
@field_validator("uri_base", mode="after")
|
|
102
|
+
@classmethod
|
|
103
|
+
def _check_uri_base(cls, value: str) -> str:
|
|
104
|
+
try:
|
|
105
|
+
parts = urlsplit(value)
|
|
106
|
+
except ValueError as exc:
|
|
107
|
+
raise PydanticCustomError(
|
|
108
|
+
"invalid_uri",
|
|
109
|
+
"uri_base '{value}' is not a parseable URI",
|
|
110
|
+
{"value": value},
|
|
111
|
+
) from exc
|
|
112
|
+
scheme = parts.scheme.lower()
|
|
113
|
+
if scheme not in {"http", "https"}:
|
|
114
|
+
raise PydanticCustomError(
|
|
115
|
+
"wrong_scheme",
|
|
116
|
+
"uri_base must use http or https (got scheme '{scheme}')",
|
|
117
|
+
{"scheme": parts.scheme, "value": value},
|
|
118
|
+
)
|
|
119
|
+
if not parts.netloc:
|
|
120
|
+
raise PydanticCustomError(
|
|
121
|
+
"empty_host",
|
|
122
|
+
"uri_base must include a non-empty host",
|
|
123
|
+
{"value": value},
|
|
124
|
+
)
|
|
125
|
+
if parts.query:
|
|
126
|
+
raise PydanticCustomError(
|
|
127
|
+
"has_query",
|
|
128
|
+
"uri_base must not include a query string",
|
|
129
|
+
{"value": value},
|
|
130
|
+
)
|
|
131
|
+
if parts.fragment:
|
|
132
|
+
raise PydanticCustomError(
|
|
133
|
+
"has_fragment",
|
|
134
|
+
"uri_base must not include a fragment",
|
|
135
|
+
{"value": value},
|
|
136
|
+
)
|
|
137
|
+
if not value.endswith("/"):
|
|
138
|
+
raise PydanticCustomError(
|
|
139
|
+
"no_trailing_slash",
|
|
140
|
+
"uri_base must end with '/'",
|
|
141
|
+
{"value": value},
|
|
142
|
+
)
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class BookBlock(BaseModel):
|
|
147
|
+
"""`[book]` block — author-facing metadata about the work."""
|
|
148
|
+
|
|
149
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
150
|
+
|
|
151
|
+
title: str
|
|
152
|
+
type: BookType
|
|
153
|
+
language: str
|
|
154
|
+
authors: list[str]
|
|
155
|
+
subtitle: str = ""
|
|
156
|
+
genre: list[str] = Field(default_factory=list)
|
|
157
|
+
target_length_words: int | None = None
|
|
158
|
+
status: BookStatus = "drafting"
|
|
159
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
160
|
+
|
|
161
|
+
@field_validator("title", mode="after")
|
|
162
|
+
@classmethod
|
|
163
|
+
def _check_title(cls, value: str) -> str:
|
|
164
|
+
if not value.strip():
|
|
165
|
+
raise PydanticCustomError(
|
|
166
|
+
"empty",
|
|
167
|
+
"title must be a non-empty string",
|
|
168
|
+
{"value": value},
|
|
169
|
+
)
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
@field_validator("language", mode="after")
|
|
173
|
+
@classmethod
|
|
174
|
+
def _check_language(cls, value: str) -> str:
|
|
175
|
+
if value not in ISO_639_1_CODES:
|
|
176
|
+
raise PydanticCustomError(
|
|
177
|
+
"not_iso_639_1",
|
|
178
|
+
"language '{value}' is not a valid ISO 639-1 code",
|
|
179
|
+
{"value": value},
|
|
180
|
+
)
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
@field_validator("authors", mode="after")
|
|
184
|
+
@classmethod
|
|
185
|
+
def _check_authors(cls, value: list[str]) -> list[str]:
|
|
186
|
+
if not value:
|
|
187
|
+
raise PydanticCustomError(
|
|
188
|
+
"empty",
|
|
189
|
+
"authors must contain at least one entry",
|
|
190
|
+
{"value": value},
|
|
191
|
+
)
|
|
192
|
+
for index, entry in enumerate(value):
|
|
193
|
+
if not entry.strip():
|
|
194
|
+
raise PydanticCustomError(
|
|
195
|
+
"entry.empty",
|
|
196
|
+
"authors[{index}] must be a non-empty string",
|
|
197
|
+
{"index": index, "value": entry},
|
|
198
|
+
)
|
|
199
|
+
return value
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class VocabulariesBlock(BaseModel):
|
|
203
|
+
"""`[vocabularies]` block — names of active vocabulary lists."""
|
|
204
|
+
|
|
205
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
206
|
+
|
|
207
|
+
active: list[str] = Field(default_factory=list)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class ValidatorsBlock(BaseModel):
|
|
211
|
+
"""`[validators]` block — built-in and custom validator names."""
|
|
212
|
+
|
|
213
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
214
|
+
|
|
215
|
+
enabled: list[str] = Field(default_factory=list)
|
|
216
|
+
disabled: list[str] = Field(default_factory=list)
|
|
217
|
+
custom: list[str] = Field(default_factory=list)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class IntegrationBlock(BaseModel):
|
|
221
|
+
"""`[integration]` block — opaque data the loader never dispatches on."""
|
|
222
|
+
|
|
223
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
224
|
+
|
|
225
|
+
key: str
|
|
226
|
+
skills_dir: str
|
|
227
|
+
options: dict[str, Any] = Field(default_factory=dict)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class PathsBlock(BaseModel):
|
|
231
|
+
"""`[paths]` block — project-relative content roots."""
|
|
232
|
+
|
|
233
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
234
|
+
|
|
235
|
+
manuscript: str = "manuscript/"
|
|
236
|
+
bible: str = "bible/"
|
|
237
|
+
outline: str = "outline/"
|
|
238
|
+
graph: str = "bible/graph.ttl"
|
|
239
|
+
constitution: str = "bible/constitution.md"
|