implemented steps 1-5
This commit is contained in:
@@ -1,74 +1,559 @@
|
||||
from pathlib import Path
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from importlib.resources import files
|
||||
from importlib.resources.abc import Traversable
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
SKILL_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||
SEMVER_RE = re.compile(
|
||||
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$"
|
||||
)
|
||||
|
||||
|
||||
def resolve_skill_document_path(
|
||||
*, skill_id: str, namespace: str, metadata: dict[str, Any]
|
||||
) -> Path:
|
||||
"""Resolve the canonical Markdown document path for a skill."""
|
||||
document_path = metadata.get("document_path")
|
||||
if isinstance(document_path, str) and document_path.strip():
|
||||
return _repo_root() / document_path.strip()
|
||||
@dataclass(frozen=True)
|
||||
class RegistryIssue:
|
||||
code: str
|
||||
message: str
|
||||
skill_id: str | None
|
||||
path: str
|
||||
hint: str
|
||||
|
||||
candidates: list[str] = []
|
||||
slug = metadata.get("slug")
|
||||
if isinstance(slug, str) and slug.strip():
|
||||
candidates.append(slug.strip())
|
||||
|
||||
candidates.extend(
|
||||
[
|
||||
skill_id,
|
||||
namespace.replace("_", "-"),
|
||||
namespace,
|
||||
]
|
||||
class DocsRegistryValidationError(Exception):
|
||||
def __init__(self, errors: list[RegistryIssue]) -> None:
|
||||
self.errors = errors
|
||||
summary = "\n".join(
|
||||
[
|
||||
(
|
||||
f"{issue.code}: {issue.message} "
|
||||
f"(skill={issue.skill_id or 'unknown'}, path={issue.path})"
|
||||
)
|
||||
for issue in errors
|
||||
]
|
||||
)
|
||||
super().__init__(summary)
|
||||
|
||||
|
||||
class ReferenceEntry(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
||||
|
||||
path: str
|
||||
mime_type: str = "text/markdown"
|
||||
title: str | None = None
|
||||
|
||||
@field_validator("path")
|
||||
@classmethod
|
||||
def validate_reference_path(cls, value: str) -> str:
|
||||
path = PurePosixPath(value)
|
||||
if path.is_absolute() or ".." in path.parts:
|
||||
raise ValueError("reference path must be a relative in-skill path")
|
||||
if not str(path).startswith("references/"):
|
||||
raise ValueError("reference path must stay under references/")
|
||||
if path.suffix.lower() != ".md":
|
||||
raise ValueError("reference path must target a markdown file")
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
class PersonalMcpMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
||||
|
||||
id: str
|
||||
version: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
capabilities: list[str] = Field(min_length=1)
|
||||
depends_on: list[str] = Field(default_factory=list)
|
||||
references: dict[str, ReferenceEntry] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def validate_id(cls, value: str) -> str:
|
||||
if not SKILL_ID_RE.fullmatch(value):
|
||||
raise ValueError("id must be lowercase kebab-case and start with a letter")
|
||||
return value
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def validate_version(cls, value: str) -> str:
|
||||
if not SEMVER_RE.fullmatch(value):
|
||||
raise ValueError("version must be semver")
|
||||
return value
|
||||
|
||||
@field_validator("tags")
|
||||
@classmethod
|
||||
def validate_tags(cls, value: list[str]) -> list[str]:
|
||||
for tag in value:
|
||||
if not SKILL_ID_RE.fullmatch(tag):
|
||||
raise ValueError(f"invalid tag: {tag}")
|
||||
return value
|
||||
|
||||
@field_validator("depends_on")
|
||||
@classmethod
|
||||
def validate_depends_on(cls, value: list[str]) -> list[str]:
|
||||
for dep in value:
|
||||
if not SKILL_ID_RE.fullmatch(dep):
|
||||
raise ValueError(f"invalid depends_on skill id: {dep}")
|
||||
return value
|
||||
|
||||
@field_validator("references")
|
||||
@classmethod
|
||||
def validate_reference_ids(
|
||||
cls, value: dict[str, ReferenceEntry]
|
||||
) -> dict[str, ReferenceEntry]:
|
||||
for ref_id in value:
|
||||
if not SKILL_ID_RE.fullmatch(ref_id):
|
||||
raise ValueError(f"invalid reference id: {ref_id}")
|
||||
return value
|
||||
|
||||
|
||||
class SkillFrontmatter(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
||||
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
description: str = Field(min_length=1, max_length=1024)
|
||||
when_to_use: str | None = None
|
||||
allowed_tools: str | list[str] | None = Field(default=None, alias="allowed-tools")
|
||||
disallowed_tools: str | list[str] | None = Field(
|
||||
default=None,
|
||||
alias="disallowed-tools",
|
||||
)
|
||||
disable_model_invocation: bool | None = Field(
|
||||
default=None,
|
||||
alias="disable-model-invocation",
|
||||
)
|
||||
user_invocable: bool | None = Field(default=None, alias="user-invocable")
|
||||
argument_hint: str | None = Field(default=None, alias="argument-hint")
|
||||
arguments: str | list[str] | None = None
|
||||
license: str | None = None
|
||||
compatibility: str | None = None
|
||||
metadata: dict[str, str] | None = None
|
||||
x_personal_mcp: PersonalMcpMetadata = Field(alias="x-personal-mcp")
|
||||
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
if candidate in seen:
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, value: str) -> str:
|
||||
if not SKILL_ID_RE.fullmatch(value):
|
||||
raise ValueError("name must be lowercase kebab-case and start with a letter")
|
||||
if "anthropic" in value or "claude" in value:
|
||||
raise ValueError("name must not contain reserved words anthropic or claude")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReferenceRecord:
|
||||
ref_id: str
|
||||
uri: str
|
||||
relpath: str
|
||||
mime_type: str
|
||||
title: str | None
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillRecord:
|
||||
skill_id: str
|
||||
name: str
|
||||
description: str
|
||||
version: str
|
||||
tags: tuple[str, ...]
|
||||
capabilities: tuple[str, ...]
|
||||
depends_on: tuple[str, ...]
|
||||
document_uri: str
|
||||
document_relpath: str
|
||||
document_content: str
|
||||
references: dict[str, ReferenceRecord]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillSummaryRecord:
|
||||
skill_id: str
|
||||
name: str
|
||||
description: str
|
||||
tags: tuple[str, ...]
|
||||
capabilities: tuple[str, ...]
|
||||
document_uri: str
|
||||
version: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocsRegistry:
|
||||
skills_by_id: dict[str, SkillRecord]
|
||||
skills_in_load_order: tuple[str, ...]
|
||||
skills_summary_in_load_order: tuple[SkillSummaryRecord, ...]
|
||||
docs_markdown_by_path: dict[str, str]
|
||||
docs_markdown_path_index: tuple[str, ...]
|
||||
tag_to_skill_ids: dict[str, tuple[str, ...]]
|
||||
capability_to_skill_ids: dict[str, tuple[str, ...]
|
||||
]
|
||||
|
||||
|
||||
def _parse_frontmatter(markdown: str, *, path: str) -> tuple[dict[str, Any], str]:
|
||||
if not markdown.startswith("---"):
|
||||
raise ValueError(f"missing YAML frontmatter: {path}")
|
||||
|
||||
lines = markdown.splitlines()
|
||||
if len(lines) < 3 or lines[0].strip() != "---":
|
||||
raise ValueError(f"invalid YAML frontmatter start: {path}")
|
||||
|
||||
end_index: int | None = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
end_index = i
|
||||
break
|
||||
|
||||
if end_index is None:
|
||||
raise ValueError(f"missing YAML frontmatter terminator: {path}")
|
||||
|
||||
raw_yaml = "\n".join(lines[1:end_index])
|
||||
body = "\n".join(lines[end_index + 1 :])
|
||||
parsed = yaml.safe_load(raw_yaml)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError(f"frontmatter must parse to an object: {path}")
|
||||
return parsed, body
|
||||
|
||||
|
||||
def _walk_markdown(
|
||||
node: Traversable,
|
||||
*,
|
||||
prefix: PurePosixPath = PurePosixPath(""),
|
||||
) -> list[tuple[str, Traversable]]:
|
||||
results: list[tuple[str, Traversable]] = []
|
||||
for child in sorted(node.iterdir(), key=lambda item: item.name):
|
||||
relpath = prefix.joinpath(child.name)
|
||||
if child.is_dir():
|
||||
results.extend(_walk_markdown(child, prefix=relpath))
|
||||
continue
|
||||
seen.add(candidate)
|
||||
|
||||
candidate_path = _repo_root() / "docs" / "skills" / candidate / "SKILL.md"
|
||||
if candidate_path.exists():
|
||||
return candidate_path
|
||||
|
||||
return _repo_root() / "docs" / "skills" / skill_id / "SKILL.md"
|
||||
if not child.is_file() or not child.name.lower().endswith(".md"):
|
||||
continue
|
||||
results.append((relpath.as_posix(), child))
|
||||
return results
|
||||
|
||||
|
||||
def load_markdown_document(*, skill_id: str, document_path: Path) -> dict[str, str]:
|
||||
"""Load an arbitrary Markdown document and expose it as a skill resource."""
|
||||
if not document_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Missing skill document for '{skill_id}': {document_path}"
|
||||
def _validate_skill_frontmatter(raw: dict[str, Any], *, skill_dir_name: str) -> SkillFrontmatter:
|
||||
model = SkillFrontmatter.model_validate(raw)
|
||||
if model.name != skill_dir_name:
|
||||
raise ValueError("frontmatter name must exactly match skill directory name")
|
||||
if model.x_personal_mcp.id != model.name:
|
||||
raise ValueError("x-personal-mcp.id must exactly match name")
|
||||
expected_capability = f"resource://skills/{model.name}/document"
|
||||
if expected_capability not in model.x_personal_mcp.capabilities:
|
||||
raise ValueError(f"capabilities must include {expected_capability}")
|
||||
return model
|
||||
|
||||
|
||||
def _normalize_docs_path(path: str) -> str:
|
||||
normalized = PurePosixPath(path)
|
||||
if normalized.is_absolute() or ".." in normalized.parts:
|
||||
raise ValueError("path must be a normalized docs-relative path")
|
||||
if normalized.suffix.lower() != ".md":
|
||||
raise ValueError("path must point to a markdown file")
|
||||
return normalized.as_posix()
|
||||
|
||||
|
||||
def _ensure_no_cycles(skills_by_id: dict[str, SkillRecord]) -> list[tuple[str, str]]:
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
cycles: list[tuple[str, str]] = []
|
||||
|
||||
def walk(skill_id: str, stack: list[str]) -> None:
|
||||
if skill_id in visited:
|
||||
return
|
||||
if skill_id in visiting:
|
||||
cycle_from = stack[stack.index(skill_id) :]
|
||||
cycles.append((skill_id, " -> ".join(cycle_from + [skill_id])))
|
||||
return
|
||||
|
||||
visiting.add(skill_id)
|
||||
stack.append(skill_id)
|
||||
for dep in skills_by_id[skill_id].depends_on:
|
||||
if dep in skills_by_id:
|
||||
walk(dep, stack)
|
||||
stack.pop()
|
||||
visiting.remove(skill_id)
|
||||
visited.add(skill_id)
|
||||
|
||||
for skill_id in sorted(skills_by_id):
|
||||
walk(skill_id, [])
|
||||
return cycles
|
||||
|
||||
|
||||
def load_docs_registry(
|
||||
*,
|
||||
package_anchor: str,
|
||||
docs_root: str = "docs",
|
||||
) -> DocsRegistry:
|
||||
docs_dir = files(package_anchor).joinpath(docs_root)
|
||||
issues: list[RegistryIssue] = []
|
||||
|
||||
if not docs_dir.is_dir():
|
||||
raise DocsRegistryValidationError(
|
||||
[
|
||||
RegistryIssue(
|
||||
code="missing_docs_root",
|
||||
message="docs root directory does not exist",
|
||||
skill_id=None,
|
||||
path=docs_root,
|
||||
hint="configure docs_root to a valid packaged docs path",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
docs_markdown_by_path: dict[str, str] = {}
|
||||
for relpath, doc_file in _walk_markdown(docs_dir):
|
||||
docs_markdown_by_path[relpath] = doc_file.read_text(encoding="utf-8")
|
||||
|
||||
skills_root = docs_dir.joinpath("skills")
|
||||
if not skills_root.is_dir():
|
||||
raise DocsRegistryValidationError(
|
||||
[
|
||||
RegistryIssue(
|
||||
code="missing_skills_root",
|
||||
message="skills directory does not exist under docs root",
|
||||
skill_id=None,
|
||||
path=f"{docs_root}/skills",
|
||||
hint="ensure docs/skills is included in packaged docs",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
skills_by_id: dict[str, SkillRecord] = {}
|
||||
summaries: list[SkillSummaryRecord] = []
|
||||
|
||||
for skill_dir in sorted(skills_root.iterdir(), key=lambda item: item.name):
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
|
||||
skill_dir_name = skill_dir.name
|
||||
skill_rel_root = PurePosixPath("skills").joinpath(skill_dir_name)
|
||||
skill_doc_relpath = skill_rel_root.joinpath("SKILL.md").as_posix()
|
||||
skill_doc_file = skill_dir.joinpath("SKILL.md")
|
||||
|
||||
if not skill_doc_file.is_file():
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="missing_skill_document",
|
||||
message="missing required SKILL.md",
|
||||
skill_id=skill_dir_name,
|
||||
path=skill_doc_relpath,
|
||||
hint="add docs/skills/<skill-id>/SKILL.md",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
skill_markdown = skill_doc_file.read_text(encoding="utf-8")
|
||||
try:
|
||||
raw_frontmatter, _ = _parse_frontmatter(
|
||||
skill_markdown,
|
||||
path=skill_doc_relpath,
|
||||
)
|
||||
frontmatter = _validate_skill_frontmatter(
|
||||
raw_frontmatter,
|
||||
skill_dir_name=skill_dir_name,
|
||||
)
|
||||
except (ValueError, ValidationError) as exc:
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="invalid_frontmatter",
|
||||
message=str(exc),
|
||||
skill_id=skill_dir_name,
|
||||
path=skill_doc_relpath,
|
||||
hint="fix SKILL.md YAML frontmatter to match the contract",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
references: dict[str, ReferenceRecord] = {}
|
||||
for ref_id, ref_entry in frontmatter.x_personal_mcp.references.items():
|
||||
ref_relpath = skill_rel_root.joinpath(ref_entry.path).as_posix()
|
||||
if ref_relpath not in docs_markdown_by_path:
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="missing_reference",
|
||||
message=f"reference target is missing for ref_id '{ref_id}'",
|
||||
skill_id=frontmatter.name,
|
||||
path=ref_relpath,
|
||||
hint="fix x-personal-mcp.references path or add the referenced markdown file",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
references[ref_id] = ReferenceRecord(
|
||||
ref_id=ref_id,
|
||||
uri=f"resource://skills/{frontmatter.name}/references/{ref_id}",
|
||||
relpath=ref_relpath,
|
||||
mime_type=ref_entry.mime_type,
|
||||
title=ref_entry.title,
|
||||
content=docs_markdown_by_path[ref_relpath],
|
||||
)
|
||||
|
||||
skill_id = frontmatter.name
|
||||
if skill_id in skills_by_id:
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="duplicate_skill_id",
|
||||
message="duplicate skill id discovered",
|
||||
skill_id=skill_id,
|
||||
path=skill_doc_relpath,
|
||||
hint="ensure each skill directory has a unique id",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
record = SkillRecord(
|
||||
skill_id=skill_id,
|
||||
name=frontmatter.name,
|
||||
description=frontmatter.description,
|
||||
version=frontmatter.x_personal_mcp.version,
|
||||
tags=tuple(frontmatter.x_personal_mcp.tags),
|
||||
capabilities=tuple(frontmatter.x_personal_mcp.capabilities),
|
||||
depends_on=tuple(frontmatter.x_personal_mcp.depends_on),
|
||||
document_uri=f"resource://skills/{skill_id}/document",
|
||||
document_relpath=skill_doc_relpath,
|
||||
document_content=skill_markdown,
|
||||
references=references,
|
||||
)
|
||||
skills_by_id[skill_id] = record
|
||||
summaries.append(
|
||||
SkillSummaryRecord(
|
||||
skill_id=record.skill_id,
|
||||
name=record.name,
|
||||
description=record.description,
|
||||
tags=record.tags,
|
||||
capabilities=record.capabilities,
|
||||
document_uri=record.document_uri,
|
||||
version=record.version,
|
||||
)
|
||||
)
|
||||
|
||||
for skill_id, record in sorted(skills_by_id.items()):
|
||||
for dependency in record.depends_on:
|
||||
if dependency == skill_id:
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="self_dependency",
|
||||
message="skill must not depend on itself",
|
||||
skill_id=skill_id,
|
||||
path=record.document_relpath,
|
||||
hint="remove the skill id from depends_on",
|
||||
)
|
||||
)
|
||||
elif dependency not in skills_by_id:
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="missing_dependency",
|
||||
message=f"depends_on target '{dependency}' does not exist",
|
||||
skill_id=skill_id,
|
||||
path=record.document_relpath,
|
||||
hint="add the missing skill or remove it from depends_on",
|
||||
)
|
||||
)
|
||||
|
||||
for cycle_start, cycle in _ensure_no_cycles(skills_by_id):
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="dependency_cycle",
|
||||
message=f"depends_on cycle detected: {cycle}",
|
||||
skill_id=cycle_start,
|
||||
path=skills_by_id[cycle_start].document_relpath,
|
||||
hint="remove at least one dependency edge in the cycle",
|
||||
)
|
||||
)
|
||||
|
||||
seen_uris: set[str] = set()
|
||||
for skill_id, record in sorted(skills_by_id.items()):
|
||||
uris = [record.document_uri] + [ref.uri for ref in record.references.values()]
|
||||
for uri in uris:
|
||||
if uri in seen_uris:
|
||||
issues.append(
|
||||
RegistryIssue(
|
||||
code="duplicate_uri",
|
||||
message=f"duplicate resource URI generated: {uri}",
|
||||
skill_id=skill_id,
|
||||
path=record.document_relpath,
|
||||
hint="ensure unique skill ids and reference ids",
|
||||
)
|
||||
)
|
||||
seen_uris.add(uri)
|
||||
|
||||
if issues:
|
||||
raise DocsRegistryValidationError(issues)
|
||||
|
||||
skill_ids = tuple(sorted(skills_by_id))
|
||||
summary_by_id = {summary.skill_id: summary for summary in summaries}
|
||||
ordered_summaries = tuple(summary_by_id[skill_id] for skill_id in skill_ids)
|
||||
|
||||
tag_index: dict[str, list[str]] = {}
|
||||
capability_index: dict[str, list[str]] = {}
|
||||
for skill_id in skill_ids:
|
||||
record = skills_by_id[skill_id]
|
||||
for tag in record.tags:
|
||||
tag_index.setdefault(tag, []).append(skill_id)
|
||||
for capability in record.capabilities:
|
||||
capability_index.setdefault(capability, []).append(skill_id)
|
||||
|
||||
return DocsRegistry(
|
||||
skills_by_id=skills_by_id,
|
||||
skills_in_load_order=skill_ids,
|
||||
skills_summary_in_load_order=ordered_summaries,
|
||||
docs_markdown_by_path=docs_markdown_by_path,
|
||||
docs_markdown_path_index=tuple(sorted(docs_markdown_by_path)),
|
||||
tag_to_skill_ids={
|
||||
key: tuple(sorted(values)) for key, values in sorted(tag_index.items())
|
||||
},
|
||||
capability_to_skill_ids={
|
||||
key: tuple(sorted(values))
|
||||
for key, values in sorted(capability_index.items())
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def read_skill_document(registry: DocsRegistry, skill_id: str) -> dict[str, str]:
|
||||
if skill_id not in registry.skills_by_id:
|
||||
raise KeyError(f"unknown skill_id: {skill_id}")
|
||||
skill = registry.skills_by_id[skill_id]
|
||||
return {
|
||||
"id": skill_id,
|
||||
"uri": f"resource://skills/{skill_id}/document",
|
||||
"id": skill.skill_id,
|
||||
"uri": skill.document_uri,
|
||||
"format": "markdown",
|
||||
"source_path": str(document_path),
|
||||
"content": document_path.read_text(encoding="utf-8"),
|
||||
"source_path": f"docs/{skill.document_relpath}",
|
||||
"content": skill.document_content,
|
||||
}
|
||||
|
||||
|
||||
def load_skill_document(*, skill_id: str, skill_slug: str) -> dict[str, str]:
|
||||
"""Load the canonical skill markdown document for an MCP skill."""
|
||||
document_path = _repo_root() / "docs" / "skills" / skill_slug / "SKILL.md"
|
||||
return load_markdown_document(skill_id=skill_id, document_path=document_path)
|
||||
|
||||
|
||||
def load_skill_document_from_metadata(
|
||||
*, skill_id: str, namespace: str, metadata: dict[str, Any]
|
||||
def read_skill_reference(
|
||||
registry: DocsRegistry,
|
||||
*,
|
||||
skill_id: str,
|
||||
ref_id: str,
|
||||
) -> dict[str, str]:
|
||||
"""Load a skill document using metadata overrides when present."""
|
||||
document_path = resolve_skill_document_path(
|
||||
skill_id=skill_id,
|
||||
namespace=namespace,
|
||||
metadata=metadata,
|
||||
)
|
||||
return load_markdown_document(skill_id=skill_id, document_path=document_path)
|
||||
if skill_id not in registry.skills_by_id:
|
||||
raise KeyError(f"unknown skill_id: {skill_id}")
|
||||
skill = registry.skills_by_id[skill_id]
|
||||
if ref_id not in skill.references:
|
||||
raise KeyError(f"unknown ref_id '{ref_id}' for skill '{skill_id}'")
|
||||
reference = skill.references[ref_id]
|
||||
return {
|
||||
"id": ref_id,
|
||||
"skill_id": skill_id,
|
||||
"uri": reference.uri,
|
||||
"format": "markdown",
|
||||
"source_path": f"docs/{reference.relpath}",
|
||||
"content": reference.content,
|
||||
}
|
||||
|
||||
|
||||
def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]:
|
||||
normalized_path = _normalize_docs_path(path)
|
||||
if normalized_path not in registry.docs_markdown_by_path:
|
||||
raise KeyError(f"unknown docs path: {normalized_path}")
|
||||
return {
|
||||
"uri": f"resource://docs/{normalized_path}",
|
||||
"format": "markdown",
|
||||
"source_path": f"docs/{normalized_path}",
|
||||
"content": registry.docs_markdown_by_path[normalized_path],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user