Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions tests/website/test_generate_skill_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,28 @@ def test_bundled_catalog_explains_missing_local_skills(gen_module):
result = gen_module.build_catalog_md_bundled([])
assert "respects local deletions and user edits" in result
assert "hermes skills reset <name> --restore" in result


def test_prune_stale_generated_pages_preserves_live_and_handwritten(
gen_module, tmp_path, monkeypatch
):
"""Removed skills must not leave published pages behind (#71856)."""
pages = tmp_path / "skills"
live = pages / "bundled" / "agents" / "agents-live.md"
stale = pages / "bundled" / "agents" / "agents-removed.md"
handwritten = pages / "bundled" / "agents" / "notes.md"
for path in (live, stale, handwritten):
path.parent.mkdir(parents=True, exist_ok=True)

marker = gen_module.GENERATED_PAGE_MARKER
live.write_text(f"{marker}\nlive\n", encoding="utf-8")
stale.write_text(f"{marker}\nstale\n", encoding="utf-8")
handwritten.write_text("# Maintained by hand\n", encoding="utf-8")
monkeypatch.setattr(gen_module, "SKILLS_PAGES", pages)

removed = gen_module.prune_stale_generated_pages({live})

assert removed == [stale]
assert live.exists()
assert handwritten.exists()
assert not stale.exists()

This file was deleted.

38 changes: 37 additions & 1 deletion website/scripts/generate-skill-docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
# We leave these alone (they get first-class sidebar treatment separately).
HAND_WRITTEN = {"google-workspace.md"}

GENERATED_PAGE_MARKER = (
"{/* This page is auto-generated from the skill's SKILL.md by "
"website/scripts/generate-skill-docs.py. Edit the source SKILL.md, "
"not this page. */}"
)


_FENCE_RE = re.compile(r"^(?P<indent>\s*)(?P<fence>```+|~~~+)", re.MULTILINE)

Expand Down Expand Up @@ -429,7 +435,7 @@ def render_skill_page(
f'description: "{fm_desc}"\n'
"---\n"
"\n"
"{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}\n"
f"{GENERATED_PAGE_MARKER}\n"
"\n"
f"# {display_name}\n"
"\n"
Expand All @@ -449,6 +455,28 @@ def render_skill_page(
)


def prune_stale_generated_pages(expected_paths: set[Path]) -> list[Path]:
"""Delete generated skill pages whose source SKILL.md no longer exists.

Only files below the generated ``bundled`` / ``optional`` trees carrying
this generator's exact marker are eligible. Hand-written documentation is
never removed, even if it sits beside generated pages.
"""
removed: list[Path] = []
for source_kind, _source_dir in SKILL_SOURCES:
source_pages = SKILLS_PAGES / source_kind
if not source_pages.exists():
continue
for path in sorted(source_pages.rglob("*.md")):
if path in expected_paths:
continue
if GENERATED_PAGE_MARKER not in path.read_text(encoding="utf-8"):
continue
path.unlink()
removed.append(path)
return removed


def discover_skills() -> list[tuple[dict[str, Any], dict[str, Any]]]:
results: list[tuple[dict[str, Any], dict[str, Any]]] = []
for kind, source_dir in SKILL_SOURCES:
Expand Down Expand Up @@ -744,6 +772,14 @@ def main():
if name not in skill_index or meta["source_kind"] == "bundled":
skill_index[name] = meta

# Remove generated pages whose source skill was deleted. Without this,
# the generator only adds/updates pages and removed bundled skills remain
# published indefinitely (#71856).
expected_pages = {page_output_path(meta) for meta, _parsed in entries}
removed = prune_stale_generated_pages(expected_pages)
if removed:
print(f"Removed {len(removed)} stale generated skill page(s)")

# Write per-skill pages
written = 0
for meta, parsed in entries:
Expand Down
Loading