Files
rendercv/scripts/rendercv_skill/generate.py
2026-03-20 21:16:54 +03:00

333 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate the distributable AI agent skill SKILL.md from a Jinja2 template.
Why:
The SKILL.md file contains dynamic data (themes, locales, Pydantic model
source code, etc.) that must stay in sync with the rendercv package. This
script extracts that data and renders the Jinja2 template to produce an
always-current skill file.
"""
import ast
import io
import pathlib
import re
import zipfile
import jinja2
import ruamel.yaml
from rendercv import __version__
from rendercv.schema.models.locale.locale import available_locales
from rendercv.schema.sample_generator import (
create_sample_cv_file,
create_sample_design_file,
)
# Only include a subset of themes in the skill to keep context size manageable.
SKILL_THEMES: list[str] = [
"classic",
"harvard",
"engineeringresumes",
"engineeringclassic",
"sb2nov",
"moderncv",
]
repository_root = pathlib.Path(__file__).parent.parent.parent
script_directory = pathlib.Path(__file__).parent
template_path = script_directory / "skill_template.j2.md"
output_path = (
repository_root
/ ".claude"
/ "skills"
/ "rendercv-skill"
/ "skills"
/ "rendercv"
/ "SKILL.md"
)
llms_txt_path = repository_root / "docs" / "llms.txt"
skill_zip_path = repository_root / "docs" / "assets" / "rendercv_skill.zip"
# Paths to key model source files for dynamic inclusion.
models_dir = repository_root / "src" / "rendercv" / "schema" / "models"
# These source files are included in the skill so agents can see the
# type-safe Pydantic schema for core models.
MODEL_SOURCE_FILES: dict[str, pathlib.Path] = {
"rendercv_model": models_dir / "rendercv_model.py",
"cv": models_dir / "cv" / "cv.py",
"social_network": models_dir / "cv" / "social_network.py",
"custom_connection": models_dir / "cv" / "custom_connection.py",
}
def is_type_adapter_assignment(node: ast.Assign) -> bool:
"""Check if an assignment creates a pydantic.TypeAdapter instance.
Why:
Type adapter instances (e.g., `email_validator = pydantic.TypeAdapter[...]`)
are internal validation plumbing, not part of the user-facing schema.
Args:
node: An AST Assign node.
Returns:
True if the assignment is a TypeAdapter instantiation.
"""
source = ast.unparse(node)
return "TypeAdapter" in source
def is_private_attr(node: ast.AnnAssign) -> bool:
"""Check if an annotated assignment is a private attribute.
Why:
Private attributes (e.g., `_key_order: list[str] = pydantic.PrivateAttr()`)
are internal state, not user-facing YAML fields.
Args:
node: An AST AnnAssign node.
Returns:
True if the field name starts with underscore.
"""
target = ast.unparse(node.target)
return target.startswith("_")
def is_model_config(node: ast.Assign) -> bool:
"""Check if an assignment is a model_config declaration.
Args:
node: An AST Assign node.
Returns:
True if assigning to model_config.
"""
return any(ast.unparse(t) == "model_config" for t in node.targets)
def is_url_dictionary(node: ast.AnnAssign) -> bool:
"""Check if an annotated assignment is the social network URL dictionary.
Why:
The url_dictionary mapping (network name → URL prefix) is internal
plumbing. Agents only need the SocialNetworkName type alias to know
which networks are supported.
Args:
node: An AST AnnAssign node.
Returns:
True if the target is url_dictionary.
"""
return ast.unparse(node.target) == "url_dictionary"
def strip_class_body(class_node: ast.ClassDef) -> ast.ClassDef:
"""Strip a class definition down to just field definitions.
Why:
Agents need field names, types, and defaults — not validators,
serializers, or cached properties. Stripping methods removes
~50% of tokens from model-heavy files.
Args:
class_node: An AST ClassDef node.
Returns:
A new ClassDef with only field annotations (no methods).
"""
new_body: list[ast.stmt] = []
for child in class_node.body:
if (isinstance(child, ast.AnnAssign) and not is_private_attr(child)) or (
isinstance(child, ast.Assign) and not is_model_config(child)
):
new_body.append(child)
# If the body would be empty, add a `pass` statement
if not new_body:
new_body.append(ast.Pass())
new_class = ast.ClassDef(
name=class_node.name,
bases=class_node.bases,
keywords=class_node.keywords,
body=new_body,
decorator_list=[],
)
return ast.fix_missing_locations(new_class)
def strip_to_schema(source: str) -> str:
"""Strip a Python source file to only schema-relevant declarations.
Why:
Full Pydantic model source files contain imports, validators,
serializers, helper functions, type adapters, and private
attributes that are implementation internals. Agents only need
type aliases, constants, and class field definitions to understand
the YAML schema. AST-based stripping removes ~40% of tokens.
Args:
source: Complete Python source code.
Returns:
Stripped source containing only schema-relevant code.
"""
tree = ast.parse(source)
kept_nodes: list[ast.stmt] = []
for node in ast.iter_child_nodes(tree):
# Keep type aliases, module-level annotated assignments, and plain
# assignments that are not TypeAdapter instances. Skip url_dictionary
# (agents only need the SocialNetworkName type alias).
if (
isinstance(node, ast.TypeAlias)
or (isinstance(node, ast.AnnAssign) and not is_url_dictionary(node))
or (isinstance(node, ast.Assign) and not is_type_adapter_assignment(node))
):
kept_nodes.append(node)
# Keep class definitions (stripped to fields only)
elif isinstance(node, ast.ClassDef):
kept_nodes.append(strip_class_body(node))
# Skip everything else: imports, functions, expressions
# Reconstruct source from kept nodes
lines: list[str] = []
for node in kept_nodes:
unparsed = ast.unparse(node)
lines.append(unparsed)
lines.append("") # blank line between declarations
result = "\n".join(lines)
# Strip redundant "The default value is `X`." from descriptions
result = re.sub(
r"\s*The default value is `[^`]*`\.?",
"",
result,
)
# Clean up leftover empty string concatenations (e.g., `+ ''` or `+ ""`)
result = re.sub(r"\s*\+\s*['\"]'?['\"]", "", result)
# Strip verbose description= strings (info is covered in Important Patterns)
result = re.sub(r", description='[^']{40,}'", "", result)
result = re.sub(r', description="[^"]{40,}"', "", result)
# Clean up multiple blank lines
result = re.sub(r"\n{3,}", "\n\n", result)
return result.strip() + "\n"
def trim_sample_cv_sections(cv_yaml: str, max_entries: int = 2) -> str:
"""Trim CV sections to at most max_entries entries each.
Why:
The sample CV has 5 experience entries, 4 publications, etc. For the
skill file, showing structure matters more than volume. Trimming to
2 entries per section saves ~2000 tokens.
Args:
cv_yaml: YAML string containing the cv section.
max_entries: Maximum entries per section.
Returns:
Trimmed YAML string.
"""
yaml = ruamel.yaml.YAML()
yaml.preserve_quotes = True
data = yaml.load(cv_yaml)
sections = data.get("cv", {}).get("sections")
if sections:
for section_name in sections:
entries = sections[section_name]
if isinstance(entries, list) and len(entries) > max_entries:
sections[section_name] = entries[:max_entries]
stream = io.StringIO()
yaml.dump(data, stream)
return stream.getvalue()
def build_template_context() -> dict:
"""Assemble all dynamic data for the Jinja2 template.
Returns:
Template context dictionary.
"""
# Generate sample CV
sample_cv = create_sample_cv_file(file_path=None)
sample_cv = trim_sample_cv_sections(sample_cv)
# Generate full classic sample design
sample_classic_design = create_sample_design_file(file_path=None, theme="classic")
# Read other theme override YAMLs (only the fields that differ from classic)
# Strip the templates: section since it's mostly the same across themes
# (noted once in the skill template instead).
other_themes_dir = models_dir / "design" / "other_themes"
theme_overrides: dict[str, str] = {}
for theme in SKILL_THEMES:
if theme == "classic":
continue
yaml_path = other_themes_dir / f"{theme}.yaml"
if yaml_path.exists():
yaml = ruamel.yaml.YAML()
data = yaml.load(yaml_path.read_text(encoding="utf-8"))
if "design" in data and "templates" in data["design"]:
del data["design"]["templates"]
stream = io.StringIO()
yaml.dump(data, stream)
theme_overrides[theme] = stream.getvalue().strip()
# Read and strip core model source files
model_sources: dict[str, str] = {}
for name, path in MODEL_SOURCE_FILES.items():
raw_source = path.read_text(encoding="utf-8")
model_sources[name] = strip_to_schema(raw_source)
return {
"version": __version__,
"available_themes": SKILL_THEMES,
"available_locales": available_locales,
"model_sources": model_sources,
"sample_cv": sample_cv,
"sample_classic_design": sample_classic_design,
"theme_overrides": theme_overrides,
}
def generate_skill_file() -> None:
"""Render the Jinja2 template and write SKILL.md, docs/llms.txt, and ZIP."""
env = jinja2.Environment(
loader=jinja2.FileSystemLoader(script_directory),
keep_trailing_newline=True,
trim_blocks=True,
lstrip_blocks=True,
)
template = env.get_template(template_path.name)
context = build_template_context()
rendered = template.render(context)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(rendered, encoding="utf-8")
llms_txt_path.write_text(rendered, encoding="utf-8")
# Generate ZIP for Claude Desktop skill upload
skill_zip_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(skill_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("rendercv/SKILL.md", rendered)
if __name__ == "__main__":
generate_skill_file()
print("Skill generated successfully.") # NOQA: T201