agnostic-prompt-aps 1.1.5__py3-none-any.whl → 1.1.7__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.
aps_cli/core.py CHANGED
@@ -3,21 +3,51 @@ from __future__ import annotations
3
3
  import json
4
4
  import os
5
5
  import shutil
6
- from dataclasses import dataclass
6
+ import sys
7
+ from dataclasses import dataclass, field
7
8
  from pathlib import Path
8
- from typing import Iterable, Optional
9
+ from typing import Callable, Literal, Optional
10
+
11
+ from .schemas import safe_parse_platform_manifest
9
12
 
10
13
  SKILL_ID = "agnostic-prompt-standard"
11
14
 
15
+ # Explicit ordering for known adapters in UI
16
+ DEFAULT_ADAPTER_ORDER: tuple[str, ...] = ("vscode-copilot", "claude-code", "opencode")
17
+
18
+ KnownAdapterId = Literal["vscode-copilot", "claude-code", "opencode"]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class DetectionMarker:
23
+ """A marker file or directory used to detect a platform."""
24
+
25
+ kind: Literal["file", "dir"]
26
+ label: str
27
+ rel_path: str
28
+
12
29
 
13
30
  @dataclass(frozen=True)
14
31
  class Platform:
32
+ """Information about a platform adapter."""
33
+
15
34
  platform_id: str
16
35
  display_name: str
17
36
  adapter_version: Optional[str]
37
+ detection_markers: tuple[DetectionMarker, ...] = field(default_factory=tuple)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class AdapterDetection:
42
+ """Result of detecting a platform adapter in a workspace."""
43
+
44
+ platform_id: str
45
+ detected: bool
46
+ reasons: tuple[str, ...]
18
47
 
19
48
 
20
49
  def is_tty() -> bool:
50
+ """Check if running in interactive terminal."""
21
51
  try:
22
52
  return bool(os.isatty(0) and os.isatty(1))
23
53
  except Exception:
@@ -25,6 +55,7 @@ def is_tty() -> bool:
25
55
 
26
56
 
27
57
  def find_repo_root(start_dir: Path) -> Optional[Path]:
58
+ """Find git repository root by walking up from start directory."""
28
59
  cur = start_dir.resolve()
29
60
  while True:
30
61
  if (cur / ".git").exists():
@@ -35,6 +66,13 @@ def find_repo_root(start_dir: Path) -> Optional[Path]:
35
66
  cur = parent
36
67
 
37
68
 
69
+ def pick_workspace_root(cli_root: Optional[str]) -> Optional[Path]:
70
+ """Resolve workspace root from CLI option or auto-detect."""
71
+ if cli_root:
72
+ return Path(cli_root).expanduser().resolve()
73
+ return find_repo_root(Path.cwd())
74
+
75
+
38
76
  def default_project_skill_path(repo_root: Path, *, claude: bool = False) -> Path:
39
77
  """Return the project-skill path for the detected agent ecosystem.
40
78
 
@@ -55,11 +93,70 @@ def default_personal_skill_path(*, claude: bool = False) -> Path:
55
93
  return base / SKILL_ID
56
94
 
57
95
 
96
+ def is_claude_platform(platform_id: str) -> bool:
97
+ """Check if platform uses Claude-specific paths."""
98
+ return platform_id == "claude-code"
99
+
100
+
101
+ def compute_skill_destinations(
102
+ scope: Literal["repo", "personal"],
103
+ workspace_root: Optional[Path],
104
+ selected_platforms: list[str],
105
+ ) -> list[Path]:
106
+ """Compute skill installation destinations based on selected platforms.
107
+
108
+ Args:
109
+ scope: Installation scope (repo or personal)
110
+ workspace_root: Workspace root path (required for repo scope)
111
+ selected_platforms: List of selected platform IDs
112
+
113
+ Returns:
114
+ List of unique destination paths
115
+ """
116
+ wants_claude = any(is_claude_platform(p) for p in selected_platforms)
117
+ wants_non_claude = any(not is_claude_platform(p) for p in selected_platforms)
118
+
119
+ # Default to non-Claude location if no adapters selected
120
+ include_claude = wants_claude
121
+ include_non_claude = wants_non_claude or len(selected_platforms) == 0
122
+
123
+ if scope == "repo":
124
+ if not workspace_root:
125
+ raise ValueError("Repo install selected but no workspace root found.")
126
+ dests: list[Path] = []
127
+ if include_non_claude:
128
+ dests.append(default_project_skill_path(workspace_root, claude=False))
129
+ if include_claude:
130
+ dests.append(default_project_skill_path(workspace_root, claude=True))
131
+ return _unique_paths(dests)
132
+
133
+ dests = []
134
+ if include_non_claude:
135
+ dests.append(default_personal_skill_path(claude=False))
136
+ if include_claude:
137
+ dests.append(default_personal_skill_path(claude=True))
138
+ return _unique_paths(dests)
139
+
140
+
141
+ def _unique_paths(paths: list[Path]) -> list[Path]:
142
+ """Remove duplicate paths while preserving order."""
143
+ seen: set[Path] = set()
144
+ out: list[Path] = []
145
+ for p in paths:
146
+ if p not in seen:
147
+ seen.add(p)
148
+ out.append(p)
149
+ return out
150
+
151
+
58
152
  def infer_platform_id(workspace_root: Path) -> Optional[str]:
153
+ """Infer platform ID based on workspace directory structure (legacy)."""
59
154
  gh = workspace_root / ".github"
60
155
  has_agents = (gh / "agents").exists()
61
156
  has_prompts = (gh / "prompts").exists()
62
- has_instructions = (gh / "copilot-instructions.md").exists() or (gh / "instructions").exists()
157
+ has_instructions = (gh / "copilot-instructions.md").exists() or (
158
+ gh / "instructions"
159
+ ).exists()
63
160
  if has_agents or has_prompts or has_instructions:
64
161
  return "vscode-copilot"
65
162
  return None
@@ -83,12 +180,16 @@ def resolve_payload_skill_dir() -> Path:
83
180
  if dev.is_dir():
84
181
  return dev
85
182
 
86
- raise FileNotFoundError("APS payload not found. (Did you run tools/sync_payload.py before building?)")
183
+ raise FileNotFoundError(
184
+ "APS payload not found. (Did you run tools/sync_payload.py before building?)"
185
+ )
87
186
 
88
187
 
89
188
  def load_platforms(skill_dir: Path) -> list[Platform]:
189
+ """Load all platform adapters from the skill's platforms directory."""
90
190
  platforms_dir = skill_dir / "platforms"
91
191
  out: list[Platform] = []
192
+
92
193
  for entry in platforms_dir.iterdir():
93
194
  if not entry.is_dir():
94
195
  continue
@@ -97,42 +198,152 @@ def load_platforms(skill_dir: Path) -> list[Platform]:
97
198
  manifest_path = entry / "manifest.json"
98
199
  if not manifest_path.exists():
99
200
  continue
201
+
100
202
  try:
101
- manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
203
+ raw = json.loads(manifest_path.read_text(encoding="utf-8"))
102
204
  except Exception:
103
205
  continue
104
- platform_id = manifest.get("platformId", entry.name)
105
- display_name = manifest.get("displayName", entry.name)
106
- adapter_version = manifest.get("adapterVersion")
206
+
207
+ # Validate with Pydantic
208
+ manifest, error = safe_parse_platform_manifest(raw)
209
+ if error:
210
+ print(
211
+ f"Warning: Invalid platform manifest at {manifest_path}: {error}",
212
+ file=sys.stderr,
213
+ )
214
+ # Fall back to partial extraction
215
+ platform_id = raw.get("platformId", entry.name)
216
+ display_name = raw.get("displayName", entry.name)
217
+ adapter_version = raw.get("adapterVersion")
218
+ detection_markers: tuple[DetectionMarker, ...] = ()
219
+ else:
220
+ assert manifest is not None
221
+ platform_id = manifest.platform_id
222
+ display_name = manifest.display_name
223
+ adapter_version = manifest.adapter_version
224
+ # Get normalized detection markers from manifest
225
+ detection_markers = tuple(
226
+ DetectionMarker(
227
+ kind=m.kind, # type: ignore[arg-type]
228
+ label=m.label,
229
+ rel_path=m.rel_path,
230
+ )
231
+ for m in manifest.detection_markers
232
+ )
233
+
107
234
  out.append(
108
235
  Platform(
109
236
  platform_id=platform_id,
110
237
  display_name=display_name,
111
238
  adapter_version=adapter_version,
239
+ detection_markers=detection_markers,
112
240
  )
113
241
  )
114
- out.sort(key=lambda p: p.display_name.lower())
242
+
243
+ return out
244
+
245
+
246
+ def sort_platforms_for_ui(platforms: list[Platform]) -> list[Platform]:
247
+ """Sort platforms with known adapters first in defined order."""
248
+ known_order = {pid: i for i, pid in enumerate(DEFAULT_ADAPTER_ORDER)}
249
+ known = [p for p in platforms if p.platform_id in known_order]
250
+ remaining = [p for p in platforms if p.platform_id not in known_order]
251
+
252
+ known.sort(key=lambda p: known_order[p.platform_id])
253
+ remaining.sort(key=lambda p: p.display_name.lower())
254
+
255
+ return known + remaining
256
+
257
+
258
+ def _marker_exists(workspace_root: Path, marker: DetectionMarker) -> bool:
259
+ """Check if a marker file or directory exists."""
260
+ full = workspace_root / marker.rel_path
261
+ if marker.kind == "dir":
262
+ return full.is_dir()
263
+ return full.exists()
264
+
265
+
266
+ def detect_adapters(
267
+ workspace_root: Path, platforms: list[Platform]
268
+ ) -> dict[str, AdapterDetection]:
269
+ """Detect which platform adapters are present in a workspace.
270
+
271
+ Args:
272
+ workspace_root: Workspace root directory
273
+ platforms: List of platforms with detection markers
274
+
275
+ Returns:
276
+ Dict mapping platform IDs to detection results
277
+ """
278
+ out: dict[str, AdapterDetection] = {}
279
+
280
+ for platform in platforms:
281
+ reasons: list[str] = []
282
+ for marker in platform.detection_markers:
283
+ if _marker_exists(workspace_root, marker):
284
+ reasons.append(marker.label)
285
+
286
+ out[platform.platform_id] = AdapterDetection(
287
+ platform_id=platform.platform_id,
288
+ detected=len(reasons) > 0,
289
+ reasons=tuple(reasons),
290
+ )
291
+
115
292
  return out
116
293
 
117
294
 
295
+ def format_detection_label(detection: AdapterDetection) -> str:
296
+ """Format a detection result as a label suffix."""
297
+ if not detection.detected:
298
+ return ""
299
+ return " (detected)"
300
+
301
+
302
+ def detect_platforms(workspace_root: Path, skill_dir: Path) -> list[str]:
303
+ """Detect all platforms with markers present in workspace (legacy API).
304
+
305
+ Args:
306
+ workspace_root: Path to workspace root
307
+ skill_dir: Path to skill directory with platform manifests
308
+
309
+ Returns:
310
+ List of detected platform IDs
311
+ """
312
+ platforms = load_platforms(skill_dir)
313
+ detections = detect_adapters(workspace_root, platforms)
314
+ return [pid for pid, det in detections.items() if det.detected]
315
+
316
+
118
317
  def ensure_dir(p: Path) -> None:
318
+ """Ensure a directory exists, creating it recursively if needed."""
119
319
  p.mkdir(parents=True, exist_ok=True)
120
320
 
121
321
 
122
322
  def remove_dir(p: Path) -> None:
323
+ """Remove a directory recursively."""
123
324
  shutil.rmtree(p, ignore_errors=True)
124
325
 
125
326
 
126
327
  def copy_dir(src: Path, dst: Path) -> None:
328
+ """Copy a directory recursively."""
127
329
  shutil.copytree(src, dst)
128
330
 
129
331
 
332
+ def list_files_recursive(root_dir: Path) -> list[Path]:
333
+ """Recursively list all files in a directory."""
334
+ results: list[Path] = []
335
+ for item in root_dir.rglob("*"):
336
+ if item.is_file():
337
+ results.append(item)
338
+ return results
339
+
340
+
130
341
  def copy_template_tree(
131
342
  src_dir: Path,
132
343
  dst_root: Path,
133
344
  *,
134
345
  force: bool = False,
135
- filter_fn: Optional[callable] = None,
346
+ filter_fn: Optional[Callable[[str], bool]] = None,
136
347
  ) -> list[str]:
137
348
  """Copy template files individually with optional filtering.
138
349
 
@@ -159,6 +370,4 @@ def copy_template_tree(
159
370
  dst_file.parent.mkdir(parents=True, exist_ok=True)
160
371
  shutil.copy2(src_file, dst_file)
161
372
  copied.append(rel_str)
162
- return copied
163
-
164
-
373
+ return copied
@@ -3,9 +3,10 @@ name: agnostic-prompt-standard
3
3
  description: The reference framework to generate, compile, and lint greenfield prompts that conform to the Agnostic Prompt Standard (APS) v1.0.
4
4
  license: MIT
5
5
  metadata:
6
+ repository: "https://github.com/chris-buckley/agnostic-prompt-standard"
6
7
  authors: "Christopher Buckley; Juan Burckhardt; Anastasiya Smirnova"
7
8
  spec_version: "1.0"
8
- framework_revision: "1.1.5"
9
+ framework_revision: "1.1.7"
9
10
  last_updated: "2026-01-15"
10
11
  ---
11
12
 
@@ -0,0 +1,25 @@
1
+ <format id="DOCS_INDEX_V1" name="Documentation Index" purpose="Token-efficient hierarchical documentation map for AI navigation.">
2
+ # <PROJECT_TITLE> Documentation Map
3
+
4
+ > Fetch the complete documentation index at: <INDEX_URL>
5
+ > Last updated: <TIMESTAMP>
6
+
7
+ ## <GROUP_NAME>
8
+
9
+ ### [<PAGE_TITLE>](<PAGE_URL>)
10
+ * <HEADING_TEXT>
11
+ * <SUBHEADING_TEXT>
12
+
13
+ ...
14
+
15
+ WHERE:
16
+ - <PROJECT_TITLE> is String; name of the project or documentation set.
17
+ - <INDEX_URL> is URI; URL where this index can be fetched.
18
+ - <TIMESTAMP> is ISO8601; when the index was generated.
19
+ - <GROUP_NAME> is String; documentation section/category name.
20
+ - <PAGE_TITLE> is String; title of the documentation page.
21
+ - <PAGE_URL> is URI; link to the documentation page.
22
+ - <HEADING_TEXT> is String; H2/H3 heading text from the page.
23
+ - <SUBHEADING_TEXT> is String; nested heading under parent.
24
+ - ... denotes repetition; groups contain pages, pages contain headings.
25
+ </format>
@@ -6,7 +6,8 @@
6
6
  "required": [
7
7
  "platformId",
8
8
  "displayName",
9
- "adapterVersion"
9
+ "adapterVersion",
10
+ "fileConventions"
10
11
  ],
11
12
  "properties": {
12
13
  "platformId": {
@@ -60,8 +61,11 @@
60
61
  },
61
62
  "instructions": {
62
63
  "type": "array",
64
+ "minItems": 1,
65
+ "uniqueItems": true,
63
66
  "items": {
64
- "type": "string"
67
+ "type": "string",
68
+ "minLength": 1
65
69
  },
66
70
  "description": "Instruction/memory file locations"
67
71
  },
@@ -3,6 +3,15 @@
3
3
  "displayName": "Claude Code CLI",
4
4
  "adapterVersion": "1.0.0",
5
5
  "lastUpdated": "2026-01-20",
6
+ "detectionMarkers": [
7
+ ".claude",
8
+ "CLAUDE.md",
9
+ "CLAUDE.local.md",
10
+ ".mcp.json",
11
+ ".claude/settings.json",
12
+ ".claude/agents",
13
+ ".claude/rules"
14
+ ],
6
15
  "docs": {
7
16
  "memory": "https://docs.anthropic.com/en/docs/claude-code/memory",
8
17
  "settings": "https://docs.anthropic.com/en/docs/claude-code/settings",
@@ -67,5 +76,21 @@
67
76
  "mcpTools": "MCP tools use pattern: mcp__<server>__<tool>",
68
77
  "qualification": "No qualification needed - tool names are global identifiers."
69
78
  },
70
- "notes": "Claude Code uses a hierarchical memory system with CLAUDE.md files. Custom subagents are defined in .claude/agents/*.md with YAML frontmatter."
79
+ "notes": "Claude Code uses a hierarchical memory system with CLAUDE.md files. Custom subagents are defined in .claude/agents/*.md with YAML frontmatter.",
80
+ "agentVersioning": {
81
+ "templates": [
82
+ {
83
+ "path": "templates/.claude/agents/aps-v{major}.{minor}.{patch}.md",
84
+ "currentPath": "templates/.claude/agents/aps-v1.1.7.md",
85
+ "frontmatter": {
86
+ "name": {
87
+ "pattern": "aps-v{major}-{minor}-{patch}"
88
+ },
89
+ "description": {
90
+ "pattern": "Generate APS v{major}.{minor}.{patch} subagent files: load APS+Claude Code adapter, extract intent, then generate+lint (and write if allowed)."
91
+ }
92
+ }
93
+ }
94
+ ]
95
+ }
71
96
  }
@@ -0,0 +1,32 @@
1
+ {
2
+ "platformId": "opencode",
3
+ "displayName": "OpenCode",
4
+ "adapterVersion": "1.0.0",
5
+ "lastUpdated": "2026-01-27",
6
+ "detectionMarkers": [
7
+ ".opencode",
8
+ ".opencode/opencode.jsonc",
9
+ ".opencode/opencode.json",
10
+ "opencode.json",
11
+ "opencode.jsonc",
12
+ ".opencode.json"
13
+ ],
14
+ "docs": {
15
+ "official": "https://opencode.ai/docs",
16
+ "config": "https://opencode.ai/docs/config"
17
+ },
18
+ "fileConventions": {
19
+ "instructions": [
20
+ "AGENTS.md",
21
+ ".opencode/instructions.md"
22
+ ],
23
+ "config": [
24
+ ".opencode/opencode.jsonc",
25
+ ".opencode/opencode.json",
26
+ "opencode.json",
27
+ "opencode.jsonc",
28
+ ".opencode.json"
29
+ ]
30
+ },
31
+ "notes": "OpenCode is an AI coding agent by anomalyco. Configuration supports JSONC (JSON with comments). The AGENTS.md file follows the OpenAI agents.md standard and is a shared marker with other platforms."
32
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "platformId": "opencode",
3
+ "adapterVersion": "1.0.0",
4
+ "lastUpdated": "2026-01-29",
5
+ "toolNaming": {},
6
+ "toolSets": [],
7
+ "tools": [],
8
+ "recommended": {}
9
+ }
@@ -3,6 +3,13 @@
3
3
  "displayName": "VS Code + GitHub Copilot",
4
4
  "adapterVersion": "1.0.0",
5
5
  "lastUpdated": "2026-01-15",
6
+ "detectionMarkers": [
7
+ ".github/copilot-instructions.md",
8
+ ".github/agents",
9
+ ".github/prompts",
10
+ ".github/instructions",
11
+ ".github/skills"
12
+ ],
6
13
  "docs": {
7
14
  "agentSkills": "https://code.visualstudio.com/docs/copilot/customization/agent-skills",
8
15
  "promptFiles": "https://code.visualstudio.com/docs/copilot/customization/prompt-files",
@@ -35,5 +42,21 @@
35
42
  "frontmatterTools": "Prompt/agent YAML frontmatter uses tool names (no leading #).",
36
43
  "qualification": "Newer VS Code versions may require fully-qualified tool names (example: search/codebase). Always prefer the name displayed in the VS Code tools picker."
37
44
  },
38
- "notes": "This adapter documents VS Code Copilot-specific frontmatter and tool naming so APS prompts can be generated reproducibly."
45
+ "notes": "This adapter documents VS Code Copilot-specific frontmatter and tool naming so APS prompts can be generated reproducibly.",
46
+ "agentVersioning": {
47
+ "templates": [
48
+ {
49
+ "path": "templates/.github/agents/aps-v{major}.{minor}.{patch}.agent.md",
50
+ "currentPath": "templates/.github/agents/aps-v1.1.7.agent.md",
51
+ "frontmatter": {
52
+ "name": {
53
+ "pattern": "APS v{major}.{minor}.{patch} Agent"
54
+ },
55
+ "description": {
56
+ "pattern": "Generate APS v{major}.{minor}.{patch} .prompt.md files: load APS+VS Code adapter, extract intent, then generate+lint (and write if allowed)."
57
+ }
58
+ }
59
+ }
60
+ ]
61
+ }
39
62
  }
aps_cli/schemas.py ADDED
@@ -0,0 +1,170 @@
1
+ """Pydantic v2 schemas for manifest and skill validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, Union
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
8
+
9
+
10
+ class FileConventions(BaseModel):
11
+ """File convention paths for a platform."""
12
+
13
+ instructions: Optional[list[str]] = None
14
+ agents: Optional[list[str]] = None
15
+ prompts: Optional[list[str]] = None
16
+ skills: Optional[list[str]] = None
17
+
18
+
19
+ class DetectionMarkerObject(BaseModel):
20
+ """A marker file or directory used to detect a platform (object format)."""
21
+
22
+ model_config = ConfigDict(populate_by_name=True)
23
+
24
+ kind: str = Field(..., pattern="^(file|dir)$")
25
+ label: str
26
+ rel_path: str = Field(..., alias="relPath")
27
+
28
+
29
+ class DetectionMarker(BaseModel):
30
+ """Normalized detection marker with all fields."""
31
+
32
+ kind: str
33
+ label: str
34
+ rel_path: str
35
+
36
+
37
+ def normalize_detection_marker(
38
+ input_marker: Union[str, dict, DetectionMarkerObject],
39
+ ) -> DetectionMarker:
40
+ """Convert a detection marker input (string or object) to normalized format.
41
+
42
+ Args:
43
+ input_marker: String path or marker object/dict
44
+
45
+ Returns:
46
+ Normalized DetectionMarker
47
+ """
48
+ if isinstance(input_marker, str):
49
+ # String format: ".github/agents/" -> dir, ".github/copilot-instructions.md" -> file
50
+ is_dir = input_marker.endswith("/")
51
+ rel_path = input_marker.rstrip("/") if is_dir else input_marker
52
+ return DetectionMarker(
53
+ kind="dir" if is_dir else "file",
54
+ label=input_marker,
55
+ rel_path=rel_path,
56
+ )
57
+ elif isinstance(input_marker, DetectionMarkerObject):
58
+ return DetectionMarker(
59
+ kind=input_marker.kind,
60
+ label=input_marker.label,
61
+ rel_path=input_marker.rel_path,
62
+ )
63
+ elif isinstance(input_marker, dict):
64
+ # Validate dict input to match Node's strictness.
65
+ try:
66
+ obj = DetectionMarkerObject.model_validate(input_marker)
67
+ except ValidationError as e:
68
+ raise ValueError(f"Invalid detection marker object: {e}") from e
69
+ return DetectionMarker(kind=obj.kind, label=obj.label, rel_path=obj.rel_path)
70
+ else:
71
+ raise ValueError(f"Invalid detection marker type: {type(input_marker)}")
72
+
73
+
74
+ class PlatformManifest(BaseModel):
75
+ """Schema for platform manifest.json files."""
76
+
77
+ model_config = ConfigDict(populate_by_name=True)
78
+
79
+ platform_id: str = Field(..., min_length=1, alias="platformId")
80
+ display_name: str = Field(..., min_length=1, alias="displayName")
81
+ adapter_version: Optional[str] = Field(None, alias="adapterVersion")
82
+ description: Optional[str] = None
83
+ skill_root: Optional[str] = Field(None, alias="skillRoot")
84
+ file_conventions: Optional[FileConventions] = Field(None, alias="fileConventions")
85
+ detection_markers_raw: Optional[list[Union[str, DetectionMarkerObject]]] = Field(
86
+ None, alias="detectionMarkers"
87
+ )
88
+
89
+ @property
90
+ def detection_markers(self) -> list[DetectionMarker]:
91
+ """Get normalized detection markers."""
92
+ if not self.detection_markers_raw:
93
+ return []
94
+ return [normalize_detection_marker(m) for m in self.detection_markers_raw]
95
+
96
+
97
+ class SkillFrontmatter(BaseModel):
98
+ """Schema for SKILL.md frontmatter."""
99
+
100
+ model_config = ConfigDict(extra="allow")
101
+
102
+ name: Optional[str] = None
103
+ version: Optional[str] = None
104
+ description: Optional[str] = None
105
+ author: Optional[str] = None
106
+ license: Optional[str] = None
107
+
108
+
109
+ def parse_platform_manifest(data: dict) -> PlatformManifest:
110
+ """Parse and validate a platform manifest.
111
+
112
+ Args:
113
+ data: Raw manifest data
114
+
115
+ Returns:
116
+ Validated PlatformManifest
117
+
118
+ Raises:
119
+ ValidationError: If validation fails
120
+ """
121
+ return PlatformManifest.model_validate(data)
122
+
123
+
124
+ def safe_parse_platform_manifest(
125
+ data: dict,
126
+ ) -> tuple[Optional[PlatformManifest], Optional[ValidationError]]:
127
+ """Safely parse a platform manifest without raising.
128
+
129
+ Args:
130
+ data: Raw manifest data
131
+
132
+ Returns:
133
+ Tuple of (manifest, error) - one will be None
134
+ """
135
+ try:
136
+ return parse_platform_manifest(data), None
137
+ except ValidationError as e:
138
+ return None, e
139
+
140
+
141
+ def parse_skill_frontmatter(data: dict) -> SkillFrontmatter:
142
+ """Parse and validate skill frontmatter.
143
+
144
+ Args:
145
+ data: Raw frontmatter data
146
+
147
+ Returns:
148
+ Validated SkillFrontmatter
149
+
150
+ Raises:
151
+ ValidationError: If validation fails
152
+ """
153
+ return SkillFrontmatter.model_validate(data)
154
+
155
+
156
+ def safe_parse_skill_frontmatter(
157
+ data: dict,
158
+ ) -> tuple[Optional[SkillFrontmatter], Optional[ValidationError]]:
159
+ """Safely parse skill frontmatter without raising.
160
+
161
+ Args:
162
+ data: Raw frontmatter data
163
+
164
+ Returns:
165
+ Tuple of (frontmatter, error) - one will be None
166
+ """
167
+ try:
168
+ return parse_skill_frontmatter(data), None
169
+ except ValidationError as e:
170
+ return None, e