vstack 0.0.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 (119) hide show
  1. vstack/__init__.py +5 -0
  2. vstack/__main__.py +5 -0
  3. vstack/_templates/agents/_partials/agent-skill-boundary.md +5 -0
  4. vstack/_templates/agents/architect/config.yaml +38 -0
  5. vstack/_templates/agents/architect/template.md +84 -0
  6. vstack/_templates/agents/designer/config.yaml +36 -0
  7. vstack/_templates/agents/designer/template.md +99 -0
  8. vstack/_templates/agents/engineer/config.yaml +36 -0
  9. vstack/_templates/agents/engineer/template.md +88 -0
  10. vstack/_templates/agents/product/config.yaml +37 -0
  11. vstack/_templates/agents/product/template.md +87 -0
  12. vstack/_templates/agents/release/config.yaml +35 -0
  13. vstack/_templates/agents/release/template.md +86 -0
  14. vstack/_templates/agents/tester/config.yaml +41 -0
  15. vstack/_templates/agents/tester/template.md +90 -0
  16. vstack/_templates/instructions/git/config.yaml +4 -0
  17. vstack/_templates/instructions/git/template.md +36 -0
  18. vstack/_templates/instructions/python/config.yaml +4 -0
  19. vstack/_templates/instructions/python/template.md +37 -0
  20. vstack/_templates/prompts/code-review/config.yaml +10 -0
  21. vstack/_templates/prompts/code-review/template.md +39 -0
  22. vstack/_templates/skills/_partials/base-branch.md +8 -0
  23. vstack/_templates/skills/_partials/observability-checklist.md +36 -0
  24. vstack/_templates/skills/_partials/run-tests.md +22 -0
  25. vstack/_templates/skills/_partials/skill-context.md +21 -0
  26. vstack/_templates/skills/adr/config.yaml +17 -0
  27. vstack/_templates/skills/adr/template.md +167 -0
  28. vstack/_templates/skills/analyse/config.yaml +16 -0
  29. vstack/_templates/skills/analyse/template.md +188 -0
  30. vstack/_templates/skills/architecture/config.yaml +18 -0
  31. vstack/_templates/skills/architecture/template.md +213 -0
  32. vstack/_templates/skills/cicd/config.yaml +16 -0
  33. vstack/_templates/skills/cicd/template.md +169 -0
  34. vstack/_templates/skills/code-review/config.yaml +16 -0
  35. vstack/_templates/skills/code-review/template.md +180 -0
  36. vstack/_templates/skills/concise/config.yaml +16 -0
  37. vstack/_templates/skills/concise/template.md +128 -0
  38. vstack/_templates/skills/consult/config.yaml +18 -0
  39. vstack/_templates/skills/consult/template.md +195 -0
  40. vstack/_templates/skills/container/config.yaml +17 -0
  41. vstack/_templates/skills/container/template.md +122 -0
  42. vstack/_templates/skills/debug/config.yaml +16 -0
  43. vstack/_templates/skills/debug/template.md +247 -0
  44. vstack/_templates/skills/dependency/config.yaml +18 -0
  45. vstack/_templates/skills/dependency/template.md +293 -0
  46. vstack/_templates/skills/design/config.yaml +16 -0
  47. vstack/_templates/skills/design/template.md +231 -0
  48. vstack/_templates/skills/docs/config.yaml +17 -0
  49. vstack/_templates/skills/docs/template.md +128 -0
  50. vstack/_templates/skills/explore/config.yaml +17 -0
  51. vstack/_templates/skills/explore/template.md +188 -0
  52. vstack/_templates/skills/guardrails/config.yaml +16 -0
  53. vstack/_templates/skills/guardrails/template.md +45 -0
  54. vstack/_templates/skills/incident/config.yaml +17 -0
  55. vstack/_templates/skills/incident/template.md +293 -0
  56. vstack/_templates/skills/inspect/config.yaml +16 -0
  57. vstack/_templates/skills/inspect/template.md +105 -0
  58. vstack/_templates/skills/migrate/config.yaml +17 -0
  59. vstack/_templates/skills/migrate/template.md +298 -0
  60. vstack/_templates/skills/onboard/config.yaml +18 -0
  61. vstack/_templates/skills/onboard/template.md +289 -0
  62. vstack/_templates/skills/openapi/config.yaml +17 -0
  63. vstack/_templates/skills/openapi/template.md +382 -0
  64. vstack/_templates/skills/performance/config.yaml +15 -0
  65. vstack/_templates/skills/performance/template.md +198 -0
  66. vstack/_templates/skills/pr/config.yaml +15 -0
  67. vstack/_templates/skills/pr/template.md +108 -0
  68. vstack/_templates/skills/refactor/config.yaml +18 -0
  69. vstack/_templates/skills/refactor/template.md +283 -0
  70. vstack/_templates/skills/release-notes/config.yaml +16 -0
  71. vstack/_templates/skills/release-notes/template.md +127 -0
  72. vstack/_templates/skills/requirements/config.yaml +17 -0
  73. vstack/_templates/skills/requirements/template.md +187 -0
  74. vstack/_templates/skills/security/config.yaml +17 -0
  75. vstack/_templates/skills/security/template.md +256 -0
  76. vstack/_templates/skills/verify/config.yaml +17 -0
  77. vstack/_templates/skills/verify/template.md +201 -0
  78. vstack/_templates/skills/vision/config.yaml +19 -0
  79. vstack/_templates/skills/vision/template.md +169 -0
  80. vstack/agents/__init__.py +5 -0
  81. vstack/agents/config.py +67 -0
  82. vstack/agents/constants.py +14 -0
  83. vstack/agents/generator.py +20 -0
  84. vstack/artifacts/__init__.py +17 -0
  85. vstack/artifacts/config.py +111 -0
  86. vstack/artifacts/constants.py +6 -0
  87. vstack/artifacts/generator.py +406 -0
  88. vstack/artifacts/models.py +55 -0
  89. vstack/artifacts/protocol.py +50 -0
  90. vstack/cli/__init__.py +3 -0
  91. vstack/cli/commands.py +596 -0
  92. vstack/cli/constants.py +33 -0
  93. vstack/cli/manifest.py +166 -0
  94. vstack/cli/parser.py +156 -0
  95. vstack/constants.py +84 -0
  96. vstack/frontmatter/__init__.py +8 -0
  97. vstack/frontmatter/parser.py +272 -0
  98. vstack/frontmatter/schema.py +142 -0
  99. vstack/frontmatter/serializer.py +208 -0
  100. vstack/instructions/__init__.py +5 -0
  101. vstack/instructions/config.py +21 -0
  102. vstack/instructions/constants.py +9 -0
  103. vstack/instructions/generator.py +13 -0
  104. vstack/main.py +71 -0
  105. vstack/models.py +35 -0
  106. vstack/prompts/__init__.py +5 -0
  107. vstack/prompts/config.py +21 -0
  108. vstack/prompts/constants.py +9 -0
  109. vstack/prompts/generator.py +13 -0
  110. vstack/skills/__init__.py +5 -0
  111. vstack/skills/config.py +58 -0
  112. vstack/skills/constants.py +17 -0
  113. vstack/skills/generator.py +20 -0
  114. vstack/skills/models.py +15 -0
  115. vstack-0.0.0.dist-info/METADATA +725 -0
  116. vstack-0.0.0.dist-info/RECORD +119 -0
  117. vstack-0.0.0.dist-info/WHEEL +4 -0
  118. vstack-0.0.0.dist-info/entry_points.txt +3 -0
  119. vstack-0.0.0.dist-info/licenses/LICENSE +21 -0
vstack/cli/manifest.py ADDED
@@ -0,0 +1,166 @@
1
+ """vstack manifest — reads and writes ``vstack.json``.
2
+
3
+ The manifest tracks every artifact installed by ``vstack install`` so that
4
+ ``vstack uninstall`` can remove exactly those files without touching anything
5
+ the user placed there manually.
6
+
7
+ Format::
8
+
9
+ {
10
+ "vstack_version": "…",
11
+ "installed_at": "…",
12
+ "artifacts": {
13
+ "skills": [{"name": "vision", "version": "1.0.1", "file": "skills/vision/SKILL.md"}],
14
+ "agents": [{"name": "engineer", "file": "agents/engineer.agent.md"}],
15
+ "instructions": [{"name": "python", "file": "instructions/python.instructions.md"}],
16
+ "prompts": [{"name": "code-review", "file": "prompts/code-review.prompt.md"}]
17
+ }
18
+ }
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ from vstack.constants import MANIFEST_FILENAME
28
+
29
+
30
+ @dataclass
31
+ class ArtifactEntry:
32
+ """Represent a single installed artifact entry in ``vstack.json``."""
33
+
34
+ name: str
35
+ file: str
36
+ version: str | None = None
37
+
38
+
39
+ @dataclass
40
+ class Manifest:
41
+ """Represent the parsed install manifest stored in ``vstack.json``."""
42
+
43
+ vstack_version: str
44
+ installed_at: str
45
+ artifacts: dict[str, list[ArtifactEntry]] = field(default_factory=dict)
46
+
47
+ # ── Accessors ─────────────────────────────────────────────────────────────
48
+
49
+ def entries_for(self, type_name: str) -> list[ArtifactEntry]:
50
+ """Return manifest entries for a single artifact type key.
51
+
52
+ Args:
53
+ type_name: Manifest artifact key such as ``"skills"``.
54
+ """
55
+ return self.artifacts.get(type_name, [])
56
+
57
+ def names_for(self, type_name: str) -> list[str]:
58
+ """Return artifact names for a single manifest type key.
59
+
60
+ Args:
61
+ type_name: Manifest artifact key such as ``"skills"``.
62
+ """
63
+ return [e.name for e in self.entries_for(type_name)]
64
+
65
+ def files_for(self, type_name: str) -> list[str]:
66
+ """Return relative output file paths for a manifest type key.
67
+
68
+ Args:
69
+ type_name: Manifest artifact key such as ``"skills"``.
70
+ """
71
+ return [e.file for e in self.entries_for(type_name)]
72
+
73
+ # ── Serialisation ─────────────────────────────────────────────────────────
74
+
75
+ def to_dict(self) -> dict:
76
+ """Serialize the manifest into JSON-compatible primitives.
77
+
78
+ Returns:
79
+ A nested dictionary structure suitable for ``json.dumps``.
80
+ """
81
+ return {
82
+ "vstack_version": self.vstack_version,
83
+ "installed_at": self.installed_at,
84
+ "artifacts": {
85
+ type_name: [
86
+ {
87
+ "name": e.name,
88
+ "file": e.file,
89
+ **({} if e.version is None else {"version": e.version}),
90
+ }
91
+ for e in entries
92
+ ]
93
+ for type_name, entries in self.artifacts.items()
94
+ },
95
+ }
96
+
97
+ @classmethod
98
+ def from_dict(cls, data: dict) -> Manifest:
99
+ """Create a :class:`Manifest` from parsed JSON data.
100
+
101
+ Args:
102
+ data: Parsed JSON object read from ``vstack.json``.
103
+
104
+ Returns:
105
+ A normalized in-memory manifest representation.
106
+ """
107
+ artifacts: dict[str, list[ArtifactEntry]] = {}
108
+ for type_name, entries in data.get("artifacts", {}).items():
109
+ artifacts[type_name] = [
110
+ ArtifactEntry(
111
+ name=e["name"],
112
+ file=e["file"],
113
+ version=e.get("version"),
114
+ )
115
+ for e in entries
116
+ if isinstance(e, dict) and "name" in e
117
+ ]
118
+ return cls(
119
+ vstack_version=data.get("vstack_version", ""),
120
+ installed_at=data.get("installed_at", ""),
121
+ artifacts=artifacts,
122
+ )
123
+
124
+
125
+ class ManifestFile:
126
+ """Read and write the ``vstack.json`` manifest inside an install root."""
127
+
128
+ def __init__(self, parent_dir: Path) -> None:
129
+ """Create a manifest accessor rooted at the provided install directory.
130
+
131
+ Args:
132
+ parent_dir: Install root that contains or will contain
133
+ ``vstack.json``.
134
+ """
135
+ self.path = parent_dir / MANIFEST_FILENAME
136
+
137
+ def exists(self) -> bool:
138
+ """Return ``True`` when the manifest file exists on disk."""
139
+ return self.path.exists()
140
+
141
+ def read(self) -> Manifest | None:
142
+ """Parse the manifest file from disk.
143
+
144
+ Returns:
145
+ The parsed manifest, or ``None`` when the file is missing or
146
+ cannot be decoded safely.
147
+ """
148
+ if not self.path.exists():
149
+ return None
150
+ try:
151
+ data = json.loads(self.path.read_text(encoding="utf-8"))
152
+ return Manifest.from_dict(data)
153
+ except (json.JSONDecodeError, KeyError):
154
+ return None
155
+
156
+ def write(self, manifest: Manifest) -> None:
157
+ """Write a manifest to disk in stable, human-readable JSON format.
158
+
159
+ Args:
160
+ manifest: Manifest data to persist.
161
+ """
162
+ self.path.parent.mkdir(parents=True, exist_ok=True)
163
+ self.path.write_text(
164
+ json.dumps(manifest.to_dict(), indent=2, ensure_ascii=False) + "\n",
165
+ encoding="utf-8",
166
+ )
vstack/cli/parser.py ADDED
@@ -0,0 +1,156 @@
1
+ """CLI argument parser and install target resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from vstack.constants import VERSION
10
+
11
+
12
+ class CommandLineParser:
13
+ """Create the vstack command-line parser and resolve install targets."""
14
+
15
+ def vscode_user_dir(self) -> Path | None:
16
+ """Return the first detected VS Code user data directory.
17
+
18
+ Returns:
19
+ The detected VS Code or VS Code Server user directory, or ``None``
20
+ when no known location exists on the current machine.
21
+ """
22
+ candidates = [
23
+ Path.home() / ".vscode-server" / "data" / "User",
24
+ Path.home() / ".config" / "Code" / "User",
25
+ Path.home() / "Library" / "Application Support" / "Code" / "User",
26
+ Path.home() / "AppData" / "Roaming" / "Code" / "User",
27
+ ]
28
+ return next((p for p in candidates if p.exists()), None)
29
+
30
+ def resolve_targets(self, args: argparse.Namespace) -> Path:
31
+ """Resolve the install root directory from parsed CLI arguments.
32
+
33
+ Args:
34
+ args: Parsed ``argparse`` namespace for an install-like command.
35
+
36
+ Returns:
37
+ The effective install root directory.
38
+
39
+ Raises:
40
+ SystemExit: If ``--global`` cannot be resolved or the explicit
41
+ ``--target`` directory does not exist.
42
+ """
43
+ if getattr(args, "use_global", False):
44
+ user_dir = self.vscode_user_dir()
45
+ if user_dir is None:
46
+ print(
47
+ "ERROR: Could not detect VS Code user data directory.\n"
48
+ "Specify manually with: vstack install --target ~/.config/Code/User",
49
+ file=sys.stderr,
50
+ )
51
+ sys.exit(1)
52
+ return user_dir
53
+
54
+ if getattr(args, "target", None):
55
+ target = Path(args.target).expanduser().resolve()
56
+ if not target.exists():
57
+ print(f"ERROR: target directory does not exist: {target}", file=sys.stderr)
58
+ sys.exit(1)
59
+ return target / ".github"
60
+
61
+ # default: current working directory
62
+ return Path.cwd() / ".github"
63
+
64
+ def build(self) -> argparse.ArgumentParser:
65
+ """Create and configure the top-level ``argparse`` parser for vstack.
66
+
67
+ Returns:
68
+ A fully configured parser with all supported subcommands and flags.
69
+ """
70
+ parser = argparse.ArgumentParser(
71
+ prog="vstack",
72
+ description="Manage vstack skill generation and installation.",
73
+ )
74
+ parser.add_argument("--version", action="version", version=f"vstack {VERSION}")
75
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
76
+ sub.required = True
77
+
78
+ p_validate = sub.add_parser(
79
+ "validate", help="Render templates in memory, report unresolved tokens"
80
+ )
81
+ p_validate.add_argument(
82
+ "--only",
83
+ nargs="+",
84
+ metavar="<type>",
85
+ help="Validate only these artifact types, e.g. --only skill agent",
86
+ )
87
+
88
+ p = sub.add_parser("verify", help="Validate source templates and/or installed output")
89
+ group = p.add_mutually_exclusive_group()
90
+ group.add_argument("--target", metavar="<dir>", help="Install into <dir>/.github/")
91
+ group.add_argument(
92
+ "--global",
93
+ dest="use_global",
94
+ action="store_true",
95
+ help="VS Code user profile (agents/prompts/instructions/skills)",
96
+ )
97
+ p.add_argument(
98
+ "--only",
99
+ nargs="+",
100
+ metavar="<type>",
101
+ help="Verify only these artifact types, e.g. --only agent prompt",
102
+ )
103
+ p.add_argument(
104
+ "--no-source",
105
+ dest="source",
106
+ action="store_false",
107
+ default=True,
108
+ help="Skip source template checks",
109
+ )
110
+ p.add_argument(
111
+ "--no-output",
112
+ dest="output",
113
+ action="store_false",
114
+ default=True,
115
+ help="Skip installed output checks",
116
+ )
117
+
118
+ for cmd, help_text in [
119
+ ("install", "Generate and install artifacts (--only to filter types)"),
120
+ ("uninstall", "Remove vstack-managed files"),
121
+ ]:
122
+ p = sub.add_parser(cmd, help=help_text)
123
+ group = p.add_mutually_exclusive_group()
124
+ group.add_argument("--target", metavar="<dir>", help="Install into <dir>/.github/")
125
+ group.add_argument(
126
+ "--global",
127
+ dest="use_global",
128
+ action="store_true",
129
+ help="VS Code user profile (agents/prompts/instructions/skills)",
130
+ )
131
+ if cmd == "install":
132
+ p.add_argument(
133
+ "--only",
134
+ nargs="+",
135
+ metavar="<type>",
136
+ help="Install only these artifact types, e.g. --only skill agent",
137
+ )
138
+ mode = p.add_mutually_exclusive_group()
139
+ mode.add_argument(
140
+ "--force",
141
+ action="store_true",
142
+ help="Overwrite existing artifacts unconditionally",
143
+ )
144
+ mode.add_argument(
145
+ "--update",
146
+ action="store_true",
147
+ help="Install only when a newer version is available",
148
+ )
149
+ p.add_argument(
150
+ "--dry-run",
151
+ dest="dry_run",
152
+ action="store_true",
153
+ help="Show what would be installed without writing files",
154
+ )
155
+
156
+ return parser
vstack/constants.py ADDED
@@ -0,0 +1,84 @@
1
+ """Project-wide constants and version helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from importlib.metadata import PackageNotFoundError
8
+ from importlib.metadata import version as _pkg_version
9
+ from importlib.resources import files
10
+ from pathlib import Path
11
+
12
+ # Package root — works both in editable installs and after pip install
13
+ _PACKAGE_ROOT = files("vstack")
14
+
15
+ TEMPLATES_ROOT = Path(str(_PACKAGE_ROOT / "_templates"))
16
+
17
+ _SEMVER_TAG_RE = re.compile(r"^\d+\.\d+\.\d+$")
18
+
19
+
20
+ def _version_tuple(tag: str) -> tuple[int, int, int]:
21
+ major, minor, patch = tag.split(".")
22
+ return int(major), int(minor), int(patch)
23
+
24
+
25
+ def _vstack_repo_root() -> Path | None:
26
+ """Return the root of the vstack source checkout, or None if not in one.
27
+
28
+ Walks up from the package directory looking for a .git directory.
29
+ Returns None when invoked outside the vstack source tree, preventing
30
+ accidental version reads from the user's working directory.
31
+ """
32
+ candidate = Path(str(_PACKAGE_ROOT)).resolve()
33
+ for parent in [candidate, *candidate.parents]:
34
+ if (parent / ".git").exists():
35
+ # Confirm this is the vstack repo, not an arbitrary repo that
36
+ # happens to contain the installed package somewhere inside it.
37
+ if (parent / "src" / "vstack").exists():
38
+ return parent
39
+ break
40
+ return None
41
+
42
+
43
+ def _head_semver_tag() -> str | None:
44
+ """Return the highest plain semver tag pointing at HEAD, if available.
45
+
46
+ Only runs git inside the vstack source checkout to prevent picking up
47
+ tags from whatever repository the user is working in.
48
+ """
49
+ repo_root = _vstack_repo_root()
50
+ if repo_root is None:
51
+ return None
52
+ try:
53
+ out = subprocess.check_output(
54
+ ["git", "-C", str(repo_root), "tag", "--points-at", "HEAD"],
55
+ stderr=subprocess.DEVNULL,
56
+ text=True,
57
+ )
58
+ except (FileNotFoundError, subprocess.CalledProcessError):
59
+ return None
60
+
61
+ tags = [line.strip() for line in out.splitlines() if _SEMVER_TAG_RE.fullmatch(line.strip())]
62
+ if not tags:
63
+ return None
64
+ return max(tags, key=_version_tuple)
65
+
66
+
67
+ def _resolve_version() -> str:
68
+ """Resolve the package version from git tags, package metadata, or fallback."""
69
+ version = _head_semver_tag() or ""
70
+ if version:
71
+ return version
72
+
73
+ try:
74
+ # Reads from installed package metadata, populated by build-time versioning.
75
+ return _pkg_version("vstack")
76
+ except PackageNotFoundError:
77
+ # Fallback: uninstalled source tree or shallow clone without any git tag.
78
+ return "0.0.0"
79
+
80
+
81
+ # Prefer an exact semver tag on HEAD for source checkouts.
82
+ VERSION = _resolve_version()
83
+
84
+ MANIFEST_FILENAME = "vstack.json"
@@ -0,0 +1,8 @@
1
+ """vstack.frontmatter — YAML frontmatter parsing, building, and schema validation."""
2
+
3
+ from vstack.frontmatter.parser import FrontmatterContent as FrontmatterContent
4
+ from vstack.frontmatter.parser import FrontmatterParser as FrontmatterParser
5
+ from vstack.frontmatter.schema import FieldSpec as FieldSpec
6
+ from vstack.frontmatter.schema import FieldType as FieldType
7
+ from vstack.frontmatter.schema import FrontmatterSchema as FrontmatterSchema
8
+ from vstack.frontmatter.serializer import FrontmatterSerializer as FrontmatterSerializer
@@ -0,0 +1,272 @@
1
+ """YAML frontmatter parser — no external dependencies.
2
+
3
+ Supports:
4
+ - String scalars (quoted and unquoted)
5
+ - Inline lists ``[a, b, c]``
6
+ - Block lists ``\n - item``
7
+ - Block sequences of mappings (object-lists) ``\n - key: val\n key2: val2``
8
+ - Block scalars ``|``
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass, field
15
+
16
+ _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n(.*)", re.DOTALL)
17
+
18
+
19
+ @dataclass
20
+ class FrontmatterContent:
21
+ """Result of parsing a document that may contain YAML frontmatter.
22
+
23
+ Attributes:
24
+ metadata: Parsed key/value pairs from the ``---`` block.
25
+ Empty dict when no frontmatter was found.
26
+ content: The body text after the closing ``---``.
27
+ Equals the original input when no frontmatter was found.
28
+ """
29
+
30
+ metadata: dict = field(default_factory=dict)
31
+ content: str = ""
32
+
33
+ # ── Convenience accessors ─────────────────────────────────────────────────
34
+
35
+ def get(self, key: str, default: object = None) -> object:
36
+ """Return *key* from metadata, falling back to *default*."""
37
+ return self.metadata.get(key, default)
38
+
39
+ def __contains__(self, key: object) -> bool:
40
+ """Return ``True`` when *key* exists in parsed metadata."""
41
+ return key in self.metadata
42
+
43
+ def __getitem__(self, key: str) -> object:
44
+ """Provide dict-style indexing for metadata lookups."""
45
+ return self.metadata[key]
46
+
47
+ def __bool__(self) -> bool:
48
+ """Return ``True`` when parsed metadata is non-empty."""
49
+ return bool(self.metadata)
50
+
51
+
52
+ class FrontmatterParser:
53
+ """Parse the repository's supported subset of YAML frontmatter."""
54
+
55
+ @staticmethod
56
+ def _is_current_object_list_item(meta: dict, current_key: str) -> bool:
57
+ """Return ``True`` when ``current_key`` points to the active object-list item."""
58
+ return (
59
+ bool(current_key)
60
+ and isinstance(meta.get(current_key), list)
61
+ and bool(meta[current_key])
62
+ and isinstance(meta[current_key][-1], dict)
63
+ )
64
+
65
+ @staticmethod
66
+ def _flush_object_block_scalar(
67
+ *,
68
+ meta: dict,
69
+ current_key: str,
70
+ object_scalar_field: str,
71
+ object_block_lines: list[str],
72
+ ) -> None:
73
+ """Flush buffered block-scalar content into the active object-list item."""
74
+ text = " ".join(b for b in object_block_lines if b).strip()
75
+ if FrontmatterParser._is_current_object_list_item(meta, current_key):
76
+ meta[current_key][-1][object_scalar_field] = text
77
+
78
+ @staticmethod
79
+ def _flush_raw_block(*, meta: dict, current_key: str, raw_lines: list[str]) -> None:
80
+ """Flush buffered raw block content into the current top-level key."""
81
+ meta[current_key] = "\n".join(raw_lines).rstrip()
82
+
83
+ @staticmethod
84
+ def _flush_block_scalar(*, meta: dict, current_key: str, block_lines: list[str]) -> None:
85
+ """Flush a buffered top-level block scalar into the current key."""
86
+ meta[current_key] = " ".join(b for b in block_lines if b).strip()
87
+
88
+ @staticmethod
89
+ def parse(content: str) -> FrontmatterContent:
90
+ """Split YAML frontmatter from body content.
91
+
92
+ Returns a :class:`FrontmatterContent` instance. When no frontmatter
93
+ block is present, ``metadata`` is empty and ``content`` equals the
94
+ original input.
95
+ """
96
+ match = _FRONTMATTER_RE.match(content)
97
+ if not match:
98
+ return FrontmatterContent(metadata={}, content=content)
99
+ meta = FrontmatterParser._parse_yaml_block(match.group(1))
100
+ return FrontmatterContent(metadata=meta, content=match.group(2))
101
+
102
+ @staticmethod
103
+ def parse_yaml(raw: str) -> dict:
104
+ """Parse a raw YAML string without frontmatter delimiters.
105
+
106
+ Args:
107
+ raw: YAML content without surrounding ``---`` delimiters.
108
+
109
+ Returns:
110
+ A parsed metadata dictionary.
111
+ """
112
+ return FrontmatterParser._parse_yaml_block(raw)
113
+
114
+ # ── Internal ──────────────────────────────────────────────────────────────
115
+
116
+ @staticmethod
117
+ def _parse_scalar(val: str) -> str:
118
+ """Strip surrounding quotes from a YAML scalar string."""
119
+ return val.strip().strip("\"'")
120
+
121
+ @staticmethod
122
+ def _parse_yaml_block(raw: str) -> dict:
123
+ """Parse a minimal YAML subset (no external dependencies).
124
+
125
+ Supports: string values, inline lists ``[a, b]``,
126
+ block lists ``\n - item``, block scalars ``|``,
127
+ block sequences of mappings (object-lists):
128
+ ``\n - key: val\n key2: val2``, and
129
+ raw mapping blocks where the value is indented non-list YAML content:
130
+ ``\n server:\n type: local`` (used for ``mcp-servers``, ``hooks``, etc.).
131
+ """
132
+ meta: dict = {}
133
+ current_key = ""
134
+ in_block_scalar = False
135
+ block_lines: list[str] = []
136
+ in_raw_block = False
137
+ raw_lines: list[str] = []
138
+ in_object_block_scalar = False
139
+ object_scalar_field = ""
140
+ object_block_lines: list[str] = []
141
+
142
+ for line in raw.split("\n"):
143
+ if line.strip().startswith("#"):
144
+ continue
145
+
146
+ if in_object_block_scalar:
147
+ if line.startswith(" ") or line == "":
148
+ object_block_lines.append(line.strip())
149
+ continue
150
+ else:
151
+ FrontmatterParser._flush_object_block_scalar(
152
+ meta=meta,
153
+ current_key=current_key,
154
+ object_scalar_field=object_scalar_field,
155
+ object_block_lines=object_block_lines,
156
+ )
157
+ in_object_block_scalar = False
158
+ object_scalar_field = ""
159
+ object_block_lines = []
160
+
161
+ # ── Raw block accumulation ────────────────────────────────────────
162
+ if in_raw_block:
163
+ if line == "" or line.startswith(" "):
164
+ raw_lines.append(line)
165
+ continue
166
+ else:
167
+ # Non-indented line closes the raw block; fall through to process it
168
+ FrontmatterParser._flush_raw_block(
169
+ meta=meta,
170
+ current_key=current_key,
171
+ raw_lines=raw_lines,
172
+ )
173
+ in_raw_block = False
174
+ raw_lines = []
175
+
176
+ if in_block_scalar:
177
+ if line.startswith(" ") or line == "":
178
+ block_lines.append(line.strip())
179
+ continue
180
+ else:
181
+ FrontmatterParser._flush_block_scalar(
182
+ meta=meta,
183
+ current_key=current_key,
184
+ block_lines=block_lines,
185
+ )
186
+ in_block_scalar = False
187
+ block_lines = []
188
+
189
+ # 4-space key: continuation of an object-list item
190
+ obj_kv = re.match(r"^ ([a-zA-Z_-]+):\s*(.*)$", line)
191
+ if obj_kv and FrontmatterParser._is_current_object_list_item(meta, current_key):
192
+ obj_key = obj_kv.group(1)
193
+ obj_val = obj_kv.group(2).strip()
194
+ if obj_val in ("|", "|-", "|+", ">", ">-", ">+"):
195
+ in_object_block_scalar = True
196
+ object_scalar_field = obj_key
197
+ object_block_lines = []
198
+ meta[current_key][-1][obj_key] = ""
199
+ else:
200
+ meta[current_key][-1][obj_key] = FrontmatterParser._parse_scalar(obj_val)
201
+ continue
202
+
203
+ # Raw block trigger: 2-space non-list indented line when the current key
204
+ # has an empty provisional value (set by a bare ``key:`` with no value).
205
+ if (
206
+ line.startswith(" ")
207
+ and not line.startswith(" - ")
208
+ and current_key
209
+ and meta.get(current_key) == []
210
+ ):
211
+ in_raw_block = True
212
+ raw_lines = [line]
213
+ meta[current_key] = "" # clear empty-list placeholder
214
+ continue
215
+
216
+ # 2-space list item
217
+ list_match = re.match(r"^ - (.+)$", line)
218
+ if list_match and current_key:
219
+ item_str = list_match.group(1).strip()
220
+ item_kv = re.match(r"^([a-zA-Z_-]+):\s*(.*)$", item_str)
221
+ if item_kv:
222
+ # Object-list item — first key bootstraps the dict
223
+ if not isinstance(meta.get(current_key), list):
224
+ meta[current_key] = []
225
+ meta[current_key].append(
226
+ {item_kv.group(1): FrontmatterParser._parse_scalar(item_kv.group(2))}
227
+ )
228
+ else:
229
+ if not isinstance(meta.get(current_key), list):
230
+ meta[current_key] = []
231
+ meta[current_key].append(item_str.strip("\"'"))
232
+ continue
233
+
234
+ kv = re.match(r"^([a-zA-Z_-]+):\s*(.*)$", line)
235
+ if kv:
236
+ current_key = kv.group(1)
237
+ val = kv.group(2).strip()
238
+ if val.startswith("[") and val.endswith("]"):
239
+ meta[current_key] = [
240
+ v.strip().strip("\"'") for v in val[1:-1].split(",") if v.strip()
241
+ ]
242
+ elif val in ("|", "|-", "|+", ">", ">-", ">+"):
243
+ in_block_scalar = True
244
+ block_lines = []
245
+ meta[current_key] = ""
246
+ elif val == "":
247
+ meta[current_key] = [] # provisional: may become a raw block
248
+ else:
249
+ meta[current_key] = val.strip("\"'")
250
+
251
+ if in_object_block_scalar:
252
+ if object_scalar_field:
253
+ FrontmatterParser._flush_object_block_scalar(
254
+ meta=meta,
255
+ current_key=current_key,
256
+ object_scalar_field=object_scalar_field,
257
+ object_block_lines=object_block_lines,
258
+ )
259
+ if in_raw_block and raw_lines:
260
+ FrontmatterParser._flush_raw_block(
261
+ meta=meta,
262
+ current_key=current_key,
263
+ raw_lines=raw_lines,
264
+ )
265
+ if in_block_scalar and block_lines:
266
+ FrontmatterParser._flush_block_scalar(
267
+ meta=meta,
268
+ current_key=current_key,
269
+ block_lines=block_lines,
270
+ )
271
+
272
+ return meta