feat: add visual skill overview reports
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
yaml = None
|
||||
|
||||
|
||||
KNOWN_ENTRIES = [
|
||||
("SKILL.md", "Skill entrypoint"),
|
||||
("README.md", "Human-readable usage guide"),
|
||||
("agents/interface.yaml", "Neutral interface metadata"),
|
||||
("manifest.json", "Lifecycle and portability metadata"),
|
||||
("references", "Extended guidance and reusable notes"),
|
||||
("scripts", "Deterministic helpers or local tooling"),
|
||||
("evals", "Trigger and quality checks"),
|
||||
("reports", "Generated evidence and overview artifacts"),
|
||||
]
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return {}, text
|
||||
|
||||
try:
|
||||
end_index = lines[1:].index("---") + 1
|
||||
except ValueError:
|
||||
return {}, text
|
||||
|
||||
frontmatter_text = "\n".join(lines[1:end_index])
|
||||
body = "\n".join(lines[end_index + 1 :]).lstrip()
|
||||
|
||||
if yaml is not None:
|
||||
data = yaml.safe_load(frontmatter_text) or {}
|
||||
return data if isinstance(data, dict) else {}, body
|
||||
|
||||
data = {}
|
||||
for line in frontmatter_text.splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
data[key.strip()] = value.strip().strip('"')
|
||||
return data, body
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
return {}
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if yaml is not None:
|
||||
payload = yaml.safe_load(text) or {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def extract_title(body: str, fallback: str) -> str:
|
||||
for line in body.splitlines():
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def parse_sections(body: str) -> dict[str, str]:
|
||||
sections: dict[str, list[str]] = {}
|
||||
current = "_preamble"
|
||||
sections[current] = []
|
||||
for line in body.splitlines():
|
||||
if line.startswith("## "):
|
||||
current = line[3:].strip()
|
||||
sections[current] = []
|
||||
continue
|
||||
sections[current].append(line)
|
||||
return {name: "\n".join(lines).strip() for name, lines in sections.items()}
|
||||
|
||||
|
||||
def extract_list_items(text: str) -> list[str]:
|
||||
items = []
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
ordered = re.match(r"^\d+\.\s+(.*)$", stripped)
|
||||
bullet = re.match(r"^[-*]\s+(.*)$", stripped)
|
||||
match = ordered or bullet
|
||||
if match:
|
||||
items.append(match.group(1).strip())
|
||||
return items
|
||||
|
||||
|
||||
def summarize_logic(sections: dict[str, str]) -> list[str]:
|
||||
for key in ("Compact Workflow", "Workflow", "How It Works", "Logic", "Quick Start"):
|
||||
if key in sections:
|
||||
items = extract_list_items(sections[key])
|
||||
if items:
|
||||
return items[:5]
|
||||
preamble_items = extract_list_items(sections.get("_preamble", ""))
|
||||
return preamble_items[:5]
|
||||
|
||||
|
||||
def summarize_usage(sections: dict[str, str], default_prompt: str, description: str) -> list[str]:
|
||||
usage = []
|
||||
for key in ("How To Use", "Quick Start", "Usage", "Runbook"):
|
||||
if key in sections:
|
||||
usage = extract_list_items(sections[key])
|
||||
if usage:
|
||||
return usage[:5]
|
||||
if default_prompt:
|
||||
usage.append(default_prompt)
|
||||
usage.append(f"Use this skill when the request matches: {description}")
|
||||
return usage[:4]
|
||||
|
||||
|
||||
def package_entries(skill_dir: Path) -> list[dict]:
|
||||
items = []
|
||||
for rel_path, label in KNOWN_ENTRIES:
|
||||
target = skill_dir / rel_path
|
||||
if target.exists():
|
||||
kind = "folder" if target.is_dir() else "file"
|
||||
items.append({"path": rel_path, "label": label, "kind": kind})
|
||||
return items
|
||||
|
||||
|
||||
def derive_strengths(skill_dir: Path, metadata: dict) -> list[str]:
|
||||
strengths = ["Lean trigger surface anchored in frontmatter description."]
|
||||
if (skill_dir / "agents" / "interface.yaml").exists():
|
||||
strengths.append("Portable interface metadata is already packaged for adapter-based export.")
|
||||
if (skill_dir / "references").exists() and any((skill_dir / "references").iterdir()):
|
||||
strengths.append("Long guidance is separated into references so the entrypoint can stay compact.")
|
||||
if (skill_dir / "scripts").exists() and any((skill_dir / "scripts").iterdir()):
|
||||
strengths.append("Deterministic helpers are packaged with the skill instead of hidden in prompt text.")
|
||||
if (skill_dir / "evals").exists() and any((skill_dir / "evals").iterdir()):
|
||||
strengths.append("The package includes quality gates or trigger checks that can travel with the skill.")
|
||||
if metadata.get("maturity_tier"):
|
||||
strengths.append(f"Lifecycle metadata is explicit, with maturity tier set to `{metadata['maturity_tier']}`.")
|
||||
return strengths[:5]
|
||||
|
||||
|
||||
def card_items(interface_data: dict, logic_steps: list[str], package_map: list[dict], usage_steps: list[str], description: str) -> list[dict]:
|
||||
compatibility = interface_data.get("compatibility", {})
|
||||
execution = compatibility.get("execution", {})
|
||||
activation = compatibility.get("activation", {})
|
||||
return [
|
||||
{
|
||||
"title": "Trigger",
|
||||
"body": description,
|
||||
"meta": [
|
||||
f"Activation: {activation.get('mode', 'manual')}",
|
||||
f"Context: {execution.get('context', 'inline')}",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Logic",
|
||||
"body": logic_steps or ["Understand the request", "Execute the task", "Validate the result"],
|
||||
"meta": [],
|
||||
},
|
||||
{
|
||||
"title": "Usage",
|
||||
"body": usage_steps,
|
||||
"meta": [],
|
||||
},
|
||||
{
|
||||
"title": "Package",
|
||||
"body": [entry["path"] for entry in package_map[:6]],
|
||||
"meta": [f"{len(package_map)} structured entries detected"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_summary(skill_dir: Path) -> dict:
|
||||
skill_text = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
|
||||
frontmatter, body = parse_frontmatter(skill_text)
|
||||
interface_data = load_yaml(skill_dir / "agents" / "interface.yaml")
|
||||
manifest = load_json(skill_dir / "manifest.json")
|
||||
sections = parse_sections(body)
|
||||
|
||||
name = frontmatter.get("name", skill_dir.name)
|
||||
description = frontmatter.get("description", "No description found.")
|
||||
title = extract_title(body, name.replace("-", " ").title())
|
||||
display_name = interface_data.get("interface", {}).get("display_name", title)
|
||||
default_prompt = interface_data.get("interface", {}).get("default_prompt", "")
|
||||
logic_steps = summarize_logic(sections)
|
||||
usage_steps = summarize_usage(sections, default_prompt, description)
|
||||
package_map = package_entries(skill_dir)
|
||||
strengths = derive_strengths(skill_dir, manifest)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"title": title,
|
||||
"display_name": display_name,
|
||||
"description": description,
|
||||
"logic_steps": logic_steps,
|
||||
"usage_steps": usage_steps,
|
||||
"package_map": package_map,
|
||||
"strengths": strengths,
|
||||
"cards": card_items(interface_data, logic_steps, package_map, usage_steps, description),
|
||||
"metadata": {
|
||||
"canonical_format": interface_data.get("compatibility", {}).get("canonical_format", "agent-skills"),
|
||||
"targets": interface_data.get("compatibility", {}).get("adapter_targets", []),
|
||||
"maturity_tier": manifest.get("maturity_tier", "scaffold"),
|
||||
"updated_at": manifest.get("updated_at", str(date.today())),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def render_card_body(card: dict) -> str:
|
||||
body = card["body"]
|
||||
if isinstance(body, list):
|
||||
items = "".join(f"<li>{html.escape(str(item))}</li>" for item in body)
|
||||
body_html = f"<ol>{items}</ol>"
|
||||
else:
|
||||
body_html = f"<p>{html.escape(str(body))}</p>"
|
||||
meta = "".join(f"<span>{html.escape(str(item))}</span>" for item in card.get("meta", []))
|
||||
meta_html = f"<div class='meta'>{meta}</div>" if meta else ""
|
||||
return f"<div class='card'><h3>{html.escape(card['title'])}</h3>{body_html}{meta_html}</div>"
|
||||
|
||||
|
||||
def render_html(summary: dict) -> str:
|
||||
target_badges = "".join(f"<span>{html.escape(str(target))}</span>" for target in summary["metadata"]["targets"])
|
||||
cards_html = "".join(render_card_body(card) for card in summary["cards"])
|
||||
strengths_html = "".join(f"<li>{html.escape(item)}</li>" for item in summary["strengths"])
|
||||
package_rows = "".join(
|
||||
f"<tr><td>{html.escape(item['path'])}</td><td>{html.escape(item['label'])}</td><td>{html.escape(item['kind'])}</td></tr>"
|
||||
for item in summary["package_map"]
|
||||
)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{html.escape(summary['display_name'])} Overview</title>
|
||||
<style>
|
||||
:root {{
|
||||
--text: #101010;
|
||||
--muted: #666666;
|
||||
--line: #e8e6e1;
|
||||
--soft: #f7f6f3;
|
||||
--white: #ffffff;
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
background: var(--white);
|
||||
color: var(--text);
|
||||
font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
.wrap {{
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px 80px;
|
||||
}}
|
||||
.eyebrow {{
|
||||
margin: 0 0 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
h1, h2, h3 {{
|
||||
margin: 0;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}}
|
||||
h1 {{
|
||||
font-size: clamp(2.4rem, 5vw, 4.4rem);
|
||||
line-height: 1.02;
|
||||
max-width: 10ch;
|
||||
}}
|
||||
.lead {{
|
||||
max-width: 760px;
|
||||
margin: 18px 0 0;
|
||||
font-size: 1.05rem;
|
||||
color: #222;
|
||||
}}
|
||||
.hero-meta, .badges {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
}}
|
||||
.hero-meta span, .badges span {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
background: var(--white);
|
||||
font-size: 13px;
|
||||
}}
|
||||
section {{
|
||||
padding-top: 28px;
|
||||
margin-top: 28px;
|
||||
border-top: 1px solid var(--line);
|
||||
}}
|
||||
.section-head {{
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 240px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}}
|
||||
.section-head p {{
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
max-width: 40ch;
|
||||
}}
|
||||
.cards {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}}
|
||||
.card {{
|
||||
min-height: 220px;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: var(--white);
|
||||
}}
|
||||
.card h3 {{
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 14px;
|
||||
}}
|
||||
.card p, .card li {{
|
||||
color: #222;
|
||||
margin: 0;
|
||||
}}
|
||||
.card ol, .card ul {{
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}}
|
||||
.meta {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}}
|
||||
.meta span {{
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--soft);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}}
|
||||
.strengths {{
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}}
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}}
|
||||
th, td {{
|
||||
padding: 14px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}}
|
||||
th {{
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}}
|
||||
@media (max-width: 900px) {{
|
||||
.section-head {{ grid-template-columns: 1fr; }}
|
||||
.cards {{ grid-template-columns: 1fr; }}
|
||||
.card {{ min-height: auto; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<p class="eyebrow">Skill Overview</p>
|
||||
<h1>{html.escape(summary["display_name"])}</h1>
|
||||
<p class="lead">{html.escape(summary["description"])}</p>
|
||||
<div class="hero-meta">
|
||||
<span>Skill name: {html.escape(summary["name"])}</span>
|
||||
<span>Maturity: {html.escape(summary["metadata"]["maturity_tier"])}</span>
|
||||
<span>Format: {html.escape(summary["metadata"]["canonical_format"])}</span>
|
||||
<span>Updated: {html.escape(summary["metadata"]["updated_at"])}</span>
|
||||
</div>
|
||||
<div class="badges">{target_badges}</div>
|
||||
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>Architecture</h2>
|
||||
<p>One clear flow: define the boundary, choose the right structure, run the checks, then ship a reusable package.</p>
|
||||
</div>
|
||||
<div class="cards">{cards_html}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>Why It Works</h2>
|
||||
<p>These are the strengths the package already makes explicit instead of leaving hidden in prompt text.</p>
|
||||
</div>
|
||||
<ul class="strengths">{strengths_html}</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>Package Map</h2>
|
||||
<p>Use this map to understand what lives where before you read the whole package.</p>
|
||||
</div>
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Path</th><th>Role</th><th>Type</th></tr>
|
||||
</thead>
|
||||
<tbody>{package_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def render_skill_overview(skill_dir: Path, output_html: Path | None = None, output_json: Path | None = None) -> dict:
|
||||
skill_dir = skill_dir.resolve()
|
||||
reports_dir = skill_dir / "reports"
|
||||
reports_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_html = output_html or reports_dir / "skill-overview.html"
|
||||
output_json = output_json or reports_dir / "skill-overview.json"
|
||||
|
||||
summary = build_summary(skill_dir)
|
||||
output_html.write_text(render_html(summary), encoding="utf-8")
|
||||
output_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"skill_dir": str(skill_dir),
|
||||
"artifacts": {
|
||||
"html": str(output_html),
|
||||
"json": str(output_json),
|
||||
},
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Render a visual overview report for a skill package.")
|
||||
parser.add_argument("skill_dir", nargs="?", default=".")
|
||||
parser.add_argument("--output-html")
|
||||
parser.add_argument("--output-json")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = render_skill_overview(
|
||||
Path(args.skill_dir),
|
||||
Path(args.output_html).resolve() if args.output_html else None,
|
||||
Path(args.output_json).resolve() if args.output_json else None,
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user