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,56 @@
1
+ """``GenericIntegration`` — neutral agentskills.io layout (FR-008, FR-010, FR-014, FR-024)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import ClassVar
8
+
9
+ from bookwright.integrations.base import SkillsIntegration
10
+ from bookwright.integrations.options import IntegrationOption
11
+
12
+
13
+ class GenericIntegration(SkillsIntegration):
14
+ """Integration for any agentskills.io-compliant agent (Codex CLI, Cursor, ...).
15
+
16
+ Declares one option (``--skills-dir``) so the user can re-target the
17
+ skills layout (e.g., ``.cursor/skills``) without writing a dedicated
18
+ integration class.
19
+ """
20
+
21
+ key: ClassVar[str] = "generic"
22
+ default_skills_dir: ClassVar[str] = ".agents/skills"
23
+ # FR-008: `context_file` MUST NOT be present in the generic config.
24
+ config: ClassVar[dict[str, str | bool]] = {
25
+ "name": "Generic (Agent Skills standard)",
26
+ "install_url": "https://agentskills.io",
27
+ "requires_cli": False,
28
+ }
29
+
30
+ supports_dynamic_context: ClassVar[bool] = False
31
+ supports_subagents: ClassVar[bool] = False
32
+ supports_tool_restrictions: ClassVar[bool] = False
33
+
34
+ @classmethod
35
+ def options(cls) -> list[IntegrationOption]:
36
+ return [
37
+ IntegrationOption(
38
+ flag="--skills-dir",
39
+ type="string",
40
+ required=False,
41
+ default=".agents/skills",
42
+ help=(
43
+ "Directory where SKILL.md files are materialized. "
44
+ "Default: .agents/skills (Codex/Cursor convention). "
45
+ "Common alternatives: .cursor/skills, .github/skills."
46
+ ),
47
+ ),
48
+ ]
49
+
50
+ def resolve_skills_dir(
51
+ self,
52
+ parsed_options: Mapping[str, object] | None = None,
53
+ ) -> Path:
54
+ if parsed_options and "skills_dir" in parsed_options:
55
+ return Path(str(parsed_options["skills_dir"]))
56
+ return Path(self.default_skills_dir)
@@ -0,0 +1,160 @@
1
+ """Ad-hoc agentskills.io linter for a materialized skill directory (FR-015).
2
+
3
+ ``lint_skill_md`` enforces the agentskills.io invariants on one
4
+ ``<skills_dir>/<command>/SKILL.md`` and raises :class:`SkillLintError` on the
5
+ **first** violation (Principle VII — fail loudly, never silently truncate or
6
+ auto-fix). It is pure: it reads the file, it never mutates the filesystem.
7
+
8
+ The full validation system is iteration 11; this module is the minimum gate
9
+ needed for Principle VII (and is reused by that later system).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import re
16
+ import shlex
17
+ from pathlib import Path
18
+
19
+ from bookwright.integrations.constants import (
20
+ INJECTION_READ_COMMANDS,
21
+ SKILL_BODY_MAX_TOKENS,
22
+ SKILL_DESCRIPTION_MAX_LENGTH,
23
+ SKILL_NAME_MAX_LENGTH,
24
+ )
25
+ from bookwright.integrations.errors import SkillLintError
26
+ from bookwright.io.frontmatter import parse_frontmatter
27
+
28
+ #: Matches a `` !`<cmd>` `` dynamic-context injection in a skill body.
29
+ _INJECTION = re.compile(r"!`([^`]*)`")
30
+
31
+
32
+ def approx_tokens(text: str) -> int:
33
+ """Token estimate for the Tier-2 body budget (R6).
34
+
35
+ Deterministic ``ceil(len / 4)`` char heuristic — the *same* definition of
36
+ "token" as the iteration-8 source-side authoring gate, so a body that passed
37
+ iteration 8 passes here, and the verdict never depends on which packages
38
+ happen to be installed. The budget is a 5x-margin regression guard, not an
39
+ authoring constraint, so the precision of a real tokenizer buys nothing here;
40
+ if iteration 11 needs exact counts it can introduce one where justified.
41
+ """
42
+
43
+ return math.ceil(len(text) / 4)
44
+
45
+
46
+ def _check_injections(skill_name: str, body: str) -> None:
47
+ """Rule 5 — deny-by-default allowlist for `` !`…` `` injections (FR-013).
48
+
49
+ Enumerate the only two valid shapes and reject everything else:
50
+ - ``argv[0] == "bookwright"`` (the stable SKILL.md ↔ CLI contract), or
51
+ - ``argv[0]`` in :data:`INJECTION_READ_COMMANDS` AND no argument is an
52
+ absolute (``/``) or home-relative (``~``) path.
53
+
54
+ Pure/read-only — the invariant is about the *shape* of the injection, never
55
+ whether the target currently exists on disk.
56
+ """
57
+
58
+ for match in _INJECTION.finditer(body):
59
+ cmd = match.group(1)
60
+ try:
61
+ argv = shlex.split(cmd)
62
+ except ValueError as exc:
63
+ # Unbalanced quotes etc. — surface as a structured lint failure
64
+ # rather than letting shlex's ValueError escape the JSON envelope
65
+ # (Principle IX). lint_skill_md is user-edit-facing.
66
+ raise SkillLintError(
67
+ skill=skill_name,
68
+ rule="forbidden_injection",
69
+ detail=f"unparseable dynamic-context injection {cmd!r}: {exc}",
70
+ ) from exc
71
+ if not argv:
72
+ raise SkillLintError(
73
+ skill=skill_name,
74
+ rule="forbidden_injection",
75
+ detail=f"empty dynamic-context injection: {cmd!r}",
76
+ )
77
+ if argv[0] == "bookwright":
78
+ continue
79
+ if argv[0] in INJECTION_READ_COMMANDS:
80
+ bad = [a for a in argv[1:] if a.startswith("/") or a.startswith("~")]
81
+ if bad:
82
+ raise SkillLintError(
83
+ skill=skill_name,
84
+ rule="forbidden_injection",
85
+ detail=f"read command targets a non-project path: {bad!r} in {cmd!r}",
86
+ )
87
+ continue
88
+ raise SkillLintError(
89
+ skill=skill_name,
90
+ rule="forbidden_injection",
91
+ detail=f"injection invokes a non-allowlisted executable: {argv[0]!r} in {cmd!r}",
92
+ )
93
+
94
+
95
+ def lint_skill_md(skill_dir: Path) -> None:
96
+ """Validate one materialized skill dir against the agentskills.io spec.
97
+
98
+ Raises :class:`SkillLintError` (``rule`` + ``detail``) on the FIRST violation.
99
+ Returns ``None`` when compliant. Pure read-only; never mutates the filesystem.
100
+ """
101
+
102
+ skill_name = skill_dir.name
103
+ skill_md = skill_dir / "SKILL.md"
104
+
105
+ # Rule 1 — invalid_frontmatter: SKILL.md exists, valid YAML fence, non-empty metadata.
106
+ if not skill_md.is_file():
107
+ raise SkillLintError(
108
+ skill=skill_name,
109
+ rule="invalid_frontmatter",
110
+ detail=f"missing SKILL.md at {skill_md}",
111
+ )
112
+ parsed = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
113
+ metadata = parsed.metadata
114
+ if not metadata:
115
+ raise SkillLintError(
116
+ skill=skill_name,
117
+ rule="invalid_frontmatter",
118
+ detail="empty or unparseable frontmatter metadata",
119
+ )
120
+
121
+ # Rule 2 — name_mismatch: metadata["name"] == dir and < SKILL_NAME_MAX_LENGTH.
122
+ # Once name == skill_name is established, the length check is on the (always
123
+ # non-empty) directory name, so no separate type/lower-bound guard is needed.
124
+ name = metadata.get("name")
125
+ if name != skill_name:
126
+ raise SkillLintError(
127
+ skill=skill_name,
128
+ rule="name_mismatch",
129
+ detail=f"frontmatter name {name!r} != directory {skill_name!r}",
130
+ )
131
+ if len(skill_name) >= SKILL_NAME_MAX_LENGTH:
132
+ raise SkillLintError(
133
+ skill=skill_name,
134
+ rule="name_mismatch",
135
+ detail=f"name length {len(skill_name)} not below {SKILL_NAME_MAX_LENGTH}",
136
+ )
137
+
138
+ # Rule 3 — description_too_long: 0 < len(description) < SKILL_DESCRIPTION_MAX_LENGTH.
139
+ description = metadata.get("description")
140
+ if not isinstance(description, str) or not (
141
+ 0 < len(description) < SKILL_DESCRIPTION_MAX_LENGTH
142
+ ):
143
+ length = len(description) if isinstance(description, str) else None
144
+ raise SkillLintError(
145
+ skill=skill_name,
146
+ rule="description_too_long",
147
+ detail=f"len={length} not in (0, {SKILL_DESCRIPTION_MAX_LENGTH})",
148
+ )
149
+
150
+ # Rule 4 — body_over_budget: approx_tokens(body) < SKILL_BODY_MAX_TOKENS.
151
+ tokens = approx_tokens(parsed.body)
152
+ if tokens >= SKILL_BODY_MAX_TOKENS:
153
+ raise SkillLintError(
154
+ skill=skill_name,
155
+ rule="body_over_budget",
156
+ detail=f"approx_tokens={tokens} >= {SKILL_BODY_MAX_TOKENS}",
157
+ )
158
+
159
+ # Rule 5 — forbidden_injection: deny-by-default allowlist (FR-013).
160
+ _check_injections(skill_name, parsed.body)
@@ -0,0 +1,202 @@
1
+ """Shared ``SKILL.md`` materializer (FR-001..FR-020).
2
+
3
+ ``generate_skill_md`` turns one packaged source command into a per-skill
4
+ directory ``<skills_dir>/<command>/SKILL.md`` with authoritative frontmatter, the
5
+ source body (only ``{ARGS}`` → ``$ARGUMENTS`` substituted), and its cited
6
+ ``references/`` copied alongside — then lints the result. All *authoring*
7
+ validation runs strictly **before** the first filesystem write, so a rejected
8
+ source leaves zero on-disk state; a lint failure is the only post-write error and
9
+ deletes its own half-written dir.
10
+
11
+ Every directory and file it creates is recorded through a ``FileLedger`` so
12
+ ``init`` can roll the whole materialization back (FR-019), even over a
13
+ pre-existing ``skills_dir``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import shutil
20
+ from importlib.resources import files
21
+ from importlib.resources.abc import Traversable
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ import yaml
26
+
27
+ import bookwright
28
+ from bookwright.integrations.constants import DEFAULT_SKILL_LICENSE
29
+ from bookwright.integrations.descriptions import get_description
30
+ from bookwright.integrations.errors import SkillMaterializationError
31
+ from bookwright.integrations.lint import lint_skill_md
32
+ from bookwright.io.frontmatter import parse_frontmatter
33
+ from bookwright.io.fs import mkdir_tracked, write_bytes_atomic
34
+
35
+ if TYPE_CHECKING:
36
+ from bookwright.integrations.base import SkillsIntegration
37
+ from bookwright.io.fs import FileLedger
38
+
39
+ #: Matches a ``references/<file>.md`` citation anywhere in a source body.
40
+ _REFERENCE_CITATION = re.compile(r"references/([\w-]+)\.md")
41
+
42
+ #: Tokens that MUST NOT survive the body transform (SC-003).
43
+ _RESIDUAL_TOKENS = ("{ARGS}", "{SCRIPT}")
44
+
45
+
46
+ def iter_command_sources() -> list[Traversable]:
47
+ """Yield the packaged source command ``*.md`` (excludes ``references/``, R4).
48
+
49
+ Enumerates the top level of ``bookwright.resources.commands`` — the whole
50
+ resources tree is force-included in the wheel, so this survives both editable
51
+ and wheel installs.
52
+ """
53
+
54
+ commands = files("bookwright.resources").joinpath("commands")
55
+ return sorted(
56
+ (child for child in commands.iterdir() if child.is_file() and child.name.endswith(".md")),
57
+ key=lambda node: node.name,
58
+ )
59
+
60
+
61
+ def _transform_body(skill_name: str, body: str) -> str:
62
+ """Substitute the sole ``{ARGS}`` token; reject any residual token (SC-003).
63
+
64
+ Raises ``SkillMaterializationError`` (``residual_token``) rather than asserting,
65
+ so the fail-loud guarantee survives ``python -O`` (which strips ``assert``).
66
+ Pre-write, so a rejected source still leaves zero on-disk state.
67
+ """
68
+
69
+ transformed = body.replace("{ARGS}", "$ARGUMENTS")
70
+ for token in _RESIDUAL_TOKENS:
71
+ if token in transformed:
72
+ raise SkillMaterializationError(
73
+ skill=skill_name,
74
+ rule="residual_token",
75
+ detail=f"residual token {token!r} survived body transform",
76
+ )
77
+ return transformed
78
+
79
+
80
+ def _render_frontmatter(name: str, description: str, license_: str, version: str) -> str:
81
+ """Render ordered ``name``/``description``/``license``/``metadata`` YAML frontmatter."""
82
+
83
+ document: dict[str, object] = {
84
+ "name": name,
85
+ "description": description,
86
+ "license": license_,
87
+ "metadata": {"author": "bookwright", "version": version},
88
+ }
89
+ block = yaml.safe_dump(document, allow_unicode=True, sort_keys=False)
90
+ return f"---\n{block}---\n"
91
+
92
+
93
+ def _resolve_references(skill_name: str, body: str) -> list[tuple[str, Traversable]]:
94
+ """Resolve each distinct ``references/<file>.md`` cited in ``body`` (pure, no writes).
95
+
96
+ A citation with no matching packaged source raises ``SkillMaterializationError``
97
+ (``dangling_reference``) — before any directory is created. Returns the resolved
98
+ ``(filename, source_node)`` copy-list consumed by :func:`_copy_references`.
99
+
100
+ ``skill_name`` is the citing command's name, carried onto the error so the JSON
101
+ envelope's ``skill`` field identifies the skill (not the missing reference file).
102
+ """
103
+
104
+ refs_root = files("bookwright.resources").joinpath("commands").joinpath("references")
105
+ seen: dict[str, Traversable] = {}
106
+ for match in _REFERENCE_CITATION.finditer(body):
107
+ filename = f"{match.group(1)}.md"
108
+ if filename in seen:
109
+ continue
110
+ node = refs_root.joinpath(filename)
111
+ if not node.is_file():
112
+ raise SkillMaterializationError(
113
+ skill=skill_name,
114
+ rule="dangling_reference",
115
+ detail=f"cited reference {filename!r} has no packaged source",
116
+ )
117
+ seen[filename] = node
118
+ return list(seen.items())
119
+
120
+
121
+ def _copy_references(
122
+ skill_dir: Path,
123
+ copy_list: list[tuple[str, Traversable]],
124
+ ledger: FileLedger,
125
+ ) -> None:
126
+ """Write the already-resolved reference copy-list into ``skill_dir/references/``."""
127
+
128
+ if not copy_list:
129
+ return
130
+ refs_target = skill_dir / "references"
131
+ mkdir_tracked(refs_target, ledger)
132
+ for filename, node in copy_list:
133
+ write_bytes_atomic(refs_target / filename, node.read_bytes(), ledger)
134
+
135
+
136
+ def generate_skill_md(
137
+ command_path: Traversable | Path,
138
+ target_dir: Path,
139
+ integration: SkillsIntegration,
140
+ *,
141
+ ledger: FileLedger,
142
+ ) -> Path | None:
143
+ """Materialize one source command into a per-skill directory.
144
+
145
+ Returns the written ``SKILL.md`` path, or ``None`` if the skill already
146
+ existed (idempotency skip). Raises ``SkillLintError`` on a lint failure (after
147
+ removing the half-written skill dir). Raises ``SkillMaterializationError`` on a
148
+ dangling reference or a frontmatter ``name`` ≠ filename-stem mismatch.
149
+ """
150
+
151
+ # `integration` is read structurally in v0 only via the shared roster; its
152
+ # `supports_dynamic_context` flag is intentionally NOT acted on (FR-011).
153
+ del integration
154
+
155
+ name = Path(command_path.name).stem
156
+ skill_dir = target_dir / name
157
+
158
+ # Step 2 — idempotency (FR-014): never overwrite an existing skill.
159
+ if (skill_dir / "SKILL.md").exists():
160
+ return None
161
+
162
+ source_text = command_path.read_text(encoding="utf-8")
163
+ parsed = parse_frontmatter(source_text)
164
+
165
+ # Step 1 — authoring invariant (FR-020): frontmatter name == filename stem.
166
+ fm_name = parsed.metadata.get("name")
167
+ if fm_name != name:
168
+ raise SkillMaterializationError(
169
+ skill=name,
170
+ rule="name_frontmatter_mismatch",
171
+ detail=f"frontmatter name {fm_name!r} != filename stem {name!r}",
172
+ )
173
+
174
+ # Step 3 — authoritative description (R3, FR-004). The 1024-char cap is owned
175
+ # by get_description and re-enforced loudly by lint_skill_md's Rule 3 below.
176
+ description = get_description(name, parsed.metadata.get("description", ""))
177
+
178
+ # Step 4 — body transform (sole token substitution).
179
+ body = _transform_body(name, parsed.body)
180
+
181
+ # Step 5 — resolve cited references (pure; a dangling ref aborts pre-write).
182
+ copy_list = _resolve_references(name, body)
183
+
184
+ # Step 6 — frontmatter (honour a source-declared license, else the design default).
185
+ license_ = parsed.metadata.get("license", DEFAULT_SKILL_LICENSE)
186
+ frontmatter = _render_frontmatter(name, description, license_, bookwright.__version__)
187
+ skill_md_payload = (frontmatter + body).encode("utf-8")
188
+
189
+ # Step 7 — the single first mutation point. Every created path is recorded.
190
+ mkdir_tracked(skill_dir, ledger)
191
+ skill_md = skill_dir / "SKILL.md"
192
+ write_bytes_atomic(skill_md, skill_md_payload, ledger)
193
+ _copy_references(skill_dir, copy_list, ledger)
194
+
195
+ # Lint the result; on failure remove this skill dir and re-raise (FR-016).
196
+ try:
197
+ lint_skill_md(skill_dir)
198
+ except Exception:
199
+ shutil.rmtree(skill_dir, ignore_errors=True)
200
+ raise
201
+
202
+ return skill_md
@@ -0,0 +1,203 @@
1
+ """``IntegrationOption`` declarative descriptor and ``parse_options`` parser.
2
+
3
+ The descriptor is intentionally validation-free at construction time
4
+ (per research R1) — structural validation runs the first time the parser
5
+ introspects an integration's ``options()`` list, surfacing as
6
+ ``InvalidOptionDeclarationError`` (FR-015).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shlex
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Literal
14
+
15
+ from bookwright.integrations.errors import (
16
+ InvalidOptionDeclarationError,
17
+ MalformedOptionError,
18
+ UnknownOptionError,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from bookwright.integrations.base import SkillsIntegration
23
+
24
+
25
+ _VALID_TYPES: frozenset[str] = frozenset({"flag", "string"})
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class IntegrationOption:
30
+ """Immutable declarative descriptor for one ``--integration-options`` flag.
31
+
32
+ Subclasses return a list of these from their ``options()`` classmethod.
33
+ The ``flag`` field MUST start with ``"--"`` (FR-012). The ``type`` field
34
+ drives parser behaviour: ``"flag"`` is presence-only (boolean),
35
+ ``"string"`` consumes the next token as its value.
36
+ """
37
+
38
+ flag: str
39
+ type: Literal["flag", "string"] = "flag"
40
+ required: bool = False
41
+ default: str | None = None
42
+ help: str = ""
43
+
44
+
45
+ def _validate_descriptor(option: IntegrationOption) -> None:
46
+ """First-introspection validation of one declared option descriptor (FR-015)."""
47
+
48
+ if not option.flag.startswith("--"):
49
+ raise InvalidOptionDeclarationError(rule="bad_flag_prefix", value=option.flag)
50
+ if option.type not in _VALID_TYPES:
51
+ raise InvalidOptionDeclarationError(rule="bad_type", value=str(option.type))
52
+
53
+
54
+ def _normalize_identifier(flag: str) -> str:
55
+ """``--skills-dir`` → ``skills_dir``."""
56
+
57
+ return flag.removeprefix("--").replace("-", "_")
58
+
59
+
60
+ def parse_options( # noqa: PLR0912, PLR0915 — small hand-rolled state machine, one branch per FR-016..FR-021 rule.
61
+ raw: str | None,
62
+ integration_cls: type[SkillsIntegration],
63
+ ) -> dict[str, str | bool]:
64
+ """Parse ``--integration-options`` raw input against an integration's options().
65
+
66
+ Returns a dict keyed by each captured option's normalized identifier
67
+ (``--skills-dir`` → ``"skills_dir"``). Empty / ``None`` / whitespace
68
+ input skips the tokenization loop and the required-flag check (FR-020
69
+ wins over FR-021), but declared defaults are still applied (R8). All
70
+ error paths raise structured exceptions; nothing is written to
71
+ stdout/stderr.
72
+ """
73
+
74
+ # Validate every declared descriptor up front (FR-015, R9).
75
+ # Runs BEFORE the empty-input short-circuit so a broken options()
76
+ # declaration surfaces on the first parse_options call, not only when
77
+ # the user happens to pass non-empty --integration-options.
78
+ declared = integration_cls.options()
79
+ for option in declared:
80
+ _validate_descriptor(option)
81
+
82
+ # R14 — two IntegrationOption descriptors with the same flag must not
83
+ # silently coalesce in the lookup dict; surface the programming error
84
+ # explicitly so the integration author fixes their options() list.
85
+ flags = [opt.flag for opt in declared]
86
+ if len(set(flags)) != len(flags):
87
+ dup = next(f for f in flags if flags.count(f) > 1)
88
+ raise InvalidOptionDeclarationError(rule="duplicate_flag", value=dup)
89
+
90
+ # R15 — two flags that normalize to the same identifier
91
+ # (e.g. `--skills-dir` and `--skills_dir` both → `skills_dir`) would
92
+ # silently last-wins in `result`; detect at declaration time. Build
93
+ # a flag→ident map so the error names BOTH colliding flags.
94
+ #
95
+ # R26 — `idents` is the canonical `flag → normalized identifier` map
96
+ # for the rest of this function: every subsequent ident lookup hits
97
+ # this dict instead of re-running `_normalize_identifier`, so the
98
+ # transform has exactly one source of truth and the normalization
99
+ # rule is in one place.
100
+ idents: dict[str, str] = {}
101
+ for flag in flags:
102
+ ident = _normalize_identifier(flag)
103
+ if ident in idents.values():
104
+ collider = next(f for f, i in idents.items() if i == ident)
105
+ raise InvalidOptionDeclarationError(
106
+ rule="colliding_identifiers",
107
+ value=f"{collider} and {flag} both normalize to {ident!r}",
108
+ )
109
+ idents[flag] = ident
110
+
111
+ # R28 — single source of truth for the empty-input gate so the
112
+ # tokenization branch and the FR-021 required-check branch can't
113
+ # drift on what counts as "empty" (FR-020 wins over FR-021 only
114
+ # when input is genuinely absent).
115
+ has_input = raw is not None and raw.strip() != ""
116
+
117
+ result: dict[str, str | bool] = {}
118
+
119
+ if has_input:
120
+ assert raw is not None # narrowed by `has_input` for mypy
121
+ lookup: dict[str, IntegrationOption] = {opt.flag: opt for opt in declared}
122
+ declared_flags = sorted(lookup.keys())
123
+
124
+ try:
125
+ tokens = shlex.split(raw, posix=True)
126
+ except ValueError as exc:
127
+ # `shlex.split` raises `ValueError("No closing quotation")` on
128
+ # unbalanced quotes. Translate into the structured-error contract
129
+ # FR-035 promises — otherwise iteration-4's `--json` envelope sees
130
+ # a bare ValueError without `code`/`message` keys (R7).
131
+ raise MalformedOptionError(rule="malformed_shell_syntax", value=raw) from exc
132
+
133
+ seen: set[str] = set()
134
+
135
+ index = 0
136
+ while index < len(tokens):
137
+ token = tokens[index]
138
+ if "=" in token and token.startswith("--"):
139
+ flag, _, value = token.partition("=")
140
+ has_inline_value = True
141
+ else:
142
+ flag = token
143
+ value = ""
144
+ has_inline_value = False
145
+
146
+ if flag not in lookup:
147
+ raise UnknownOptionError(
148
+ integration=integration_cls.key,
149
+ value=flag,
150
+ valid=declared_flags,
151
+ )
152
+
153
+ if flag in seen:
154
+ raise MalformedOptionError(rule="duplicate_flag", value=flag)
155
+ seen.add(flag)
156
+
157
+ option = lookup[flag]
158
+ ident = idents[flag]
159
+
160
+ if option.type == "string":
161
+ if has_inline_value:
162
+ # R11 — `--flag=` (empty inline value) is treated as a
163
+ # missing value, symmetric with bare `--flag`. Without
164
+ # this guard the empty string slipped through to the
165
+ # consumer and the failure surfaced later (e.g., as
166
+ # `resolves_to_project_root` in setup() when the
167
+ # consumer wrapped it as Path('')).
168
+ if value == "":
169
+ raise MalformedOptionError(rule="missing_value", value=flag)
170
+ result[ident] = value
171
+ index += 1
172
+ continue
173
+ if index + 1 >= len(tokens):
174
+ raise MalformedOptionError(rule="missing_value", value=flag)
175
+ result[ident] = tokens[index + 1]
176
+ index += 2
177
+ continue
178
+
179
+ # type == "flag"
180
+ if has_inline_value:
181
+ raise MalformedOptionError(rule="unexpected_value", value=flag)
182
+ result[ident] = True
183
+ index += 1
184
+
185
+ # R8 — apply declared defaults for opts the user did not supply. Runs
186
+ # in both paths (empty + non-empty input) so an integration that
187
+ # declares `default='X'` always sees `X` when the flag is omitted.
188
+ for option in declared:
189
+ if option.default is not None:
190
+ ident = idents[option.flag]
191
+ if ident not in result:
192
+ result[ident] = option.default
193
+
194
+ # Required-flag enforcement runs after defaults so `required=True,
195
+ # default='x'` is always satisfied (FR-021 only fires for required
196
+ # opts without a default that the user also omitted). Empty-input
197
+ # short-circuit at top of branch skips this — FR-020 wins.
198
+ if has_input:
199
+ for option in declared:
200
+ if option.required and idents[option.flag] not in result:
201
+ raise MalformedOptionError(rule="missing_required", value=option.flag)
202
+
203
+ return result
@@ -0,0 +1 @@
1
+ """Plain-text → GOLEM-model parsing (frontmatter, bible mapper, build report)."""