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,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)."""
|