feat(bundles): add editorial bundle plugins
This commit is contained in:
@@ -8,6 +8,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from _project_paths import find_repo_root
|
||||
from sync_editorial_bundles import load_editorial_bundles, render_bundles_doc
|
||||
import sync_repo_metadata
|
||||
from update_readme import configure_utf8_output, load_metadata, apply_metadata
|
||||
|
||||
@@ -31,8 +32,22 @@ def _expected_getting_started(content: str, metadata: dict) -> str:
|
||||
return sync_repo_metadata.sync_getting_started(content, metadata)
|
||||
|
||||
|
||||
def _expected_bundles(content: str, metadata: dict) -> str:
|
||||
return sync_repo_metadata.sync_bundles_doc(content, metadata)
|
||||
def _expected_bundles(content: str, metadata: dict, root: Path) -> str:
|
||||
manifest_path = root / "data" / "editorial-bundles.json"
|
||||
template_path = root / "tools" / "templates" / "editorial-bundles.md.tmpl"
|
||||
if manifest_path.is_file() and template_path.is_file():
|
||||
bundles = load_editorial_bundles(root)
|
||||
return render_bundles_doc(root, metadata, bundles)
|
||||
|
||||
bundle_count = sync_repo_metadata.count_documented_bundles(content)
|
||||
if bundle_count == 0:
|
||||
bundle_count = 36
|
||||
expected, _ = sync_repo_metadata.replace_if_present(
|
||||
content,
|
||||
sync_repo_metadata.BUNDLES_FOOTER_RE,
|
||||
f"_Last updated: March 2026 | Total Skills: {metadata['total_skills_label']} | Total Bundles: {bundle_count}_",
|
||||
)
|
||||
return expected
|
||||
|
||||
|
||||
def _expected_regex_sync(content: str, replacements: list[tuple[str, str]]) -> str:
|
||||
@@ -55,7 +70,7 @@ def find_local_consistency_issues(base_dir: str | Path) -> list[str]:
|
||||
file_checks = [
|
||||
("README.md", _expected_readme),
|
||||
("docs/users/getting-started.md", _expected_getting_started),
|
||||
("docs/users/bundles.md", _expected_bundles),
|
||||
("docs/users/bundles.md", lambda content, current_metadata: _expected_bundles(content, current_metadata, root)),
|
||||
("docs/integrations/jetski-cortex.md", _expected_jetski_cortex),
|
||||
(
|
||||
"docs/users/claude-code-skills.md",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SAFE_SKILL_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
SAFE_SKILL_ID_PATTERN = re.compile(
|
||||
r"^(?!.*(?:^|/)\.{1,2}(?:/|$))[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$"
|
||||
)
|
||||
|
||||
|
||||
def is_safe_skill_id(skill_id):
|
||||
@@ -22,39 +25,84 @@ def format_skills_for_batch(skill_ids):
|
||||
return "\n".join(safe_skill_ids) + "\n"
|
||||
|
||||
|
||||
def get_bundle_skills(bundle_queries, bundles_path=None):
|
||||
def _manifest_bundles_path():
|
||||
return Path(__file__).parent.parent.parent / "data" / "editorial-bundles.json"
|
||||
|
||||
|
||||
def _normalize_bundle_query(query):
|
||||
normalized = re.sub(r"[^a-z0-9]+", "-", (query or "").lower()).strip("-")
|
||||
return re.sub(r"-{2,}", "-", normalized)
|
||||
|
||||
|
||||
def _read_editorial_manifest(manifest_path):
|
||||
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
bundles = payload.get("bundles", [])
|
||||
return bundles if isinstance(bundles, list) else []
|
||||
|
||||
|
||||
def _read_legacy_markdown_bundles(markdown_path):
|
||||
content = markdown_path.read_text(encoding="utf-8")
|
||||
sections = re.split(r"\n### ", content)
|
||||
bundles = []
|
||||
for section in sections:
|
||||
header_line = section.split("\n")[0].strip()
|
||||
if not header_line:
|
||||
continue
|
||||
name_match = re.search(r'"([^"]+)"', header_line)
|
||||
bundle_name = name_match.group(1) if name_match else header_line
|
||||
skills = re.findall(r"- \[`([^`]+)`\]", section)
|
||||
bundles.append(
|
||||
{
|
||||
"id": _normalize_bundle_query(bundle_name),
|
||||
"name": bundle_name,
|
||||
"skills": [{"id": skill_id} for skill_id in skills],
|
||||
}
|
||||
)
|
||||
return bundles
|
||||
|
||||
|
||||
def _load_bundles_data(bundles_path=None):
|
||||
if bundles_path is None:
|
||||
bundles_path = Path(__file__).parent.parent.parent / "docs" / "users" / "bundles.md"
|
||||
bundles_path = _manifest_bundles_path()
|
||||
else:
|
||||
bundles_path = Path(bundles_path)
|
||||
|
||||
|
||||
if not bundles_path.exists():
|
||||
print(f"Error: {bundles_path} not found", file=sys.stderr)
|
||||
return []
|
||||
|
||||
content = bundles_path.read_text(encoding="utf-8")
|
||||
|
||||
# Split by bundle headers
|
||||
sections = re.split(r'\n### ', content)
|
||||
|
||||
if bundles_path.suffix.lower() == ".json":
|
||||
return _read_editorial_manifest(bundles_path)
|
||||
|
||||
return _read_legacy_markdown_bundles(bundles_path)
|
||||
|
||||
|
||||
def get_bundle_skills(bundle_queries, bundles_path=None):
|
||||
selected_skills = set()
|
||||
|
||||
bundles = _load_bundles_data(bundles_path)
|
||||
|
||||
for query in bundle_queries:
|
||||
query = query.lower().strip('"\'')
|
||||
raw_query = query.lower().strip('"\'')
|
||||
query_slug = _normalize_bundle_query(raw_query)
|
||||
found = False
|
||||
for section in sections:
|
||||
header_line = section.split('\n')[0].lower()
|
||||
if query in header_line:
|
||||
for bundle in bundles:
|
||||
bundle_name = str(bundle.get("name", "")).lower()
|
||||
bundle_id = str(bundle.get("id", ""))
|
||||
if raw_query in bundle_name or query_slug == bundle_id:
|
||||
found = True
|
||||
# Extract skill names from bullet points: - [`skill-name`](../../skills/skill-name/)
|
||||
skills = re.findall(r'- \[`([^`]+)`\]', section)
|
||||
skills = []
|
||||
for skill in bundle.get("skills", []):
|
||||
if isinstance(skill, str):
|
||||
skills.append(skill)
|
||||
elif isinstance(skill, dict) and "id" in skill:
|
||||
skills.append(skill["id"])
|
||||
selected_skills.update(filter_safe_skill_ids(skills))
|
||||
|
||||
|
||||
if not found:
|
||||
# If query not found in any header, check if it's a skill name itself
|
||||
# (Just in case the user passed a skill name instead of a bundle)
|
||||
if is_safe_skill_id(query):
|
||||
selected_skills.add(query)
|
||||
if is_safe_skill_id(raw_query):
|
||||
selected_skills.add(raw_query)
|
||||
|
||||
return sorted(list(selected_skills))
|
||||
|
||||
|
||||
636
tools/scripts/sync_editorial_bundles.py
Normal file
636
tools/scripts/sync_editorial_bundles.py
Normal file
@@ -0,0 +1,636 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from _project_paths import find_repo_root
|
||||
from update_readme import configure_utf8_output, load_metadata
|
||||
|
||||
|
||||
SAFE_SKILL_ID_RE = re.compile(
|
||||
r"^(?!.*(?:^|/)\.{1,2}(?:/|$))[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$"
|
||||
)
|
||||
REPO_URL = "https://github.com/sickn33/antigravity-awesome-skills"
|
||||
AUTHOR = {
|
||||
"name": "sickn33 and contributors",
|
||||
"url": REPO_URL,
|
||||
}
|
||||
ROOT_CLAUDE_PLUGIN_NAME = "antigravity-awesome-skills"
|
||||
ROOT_CODEX_PLUGIN_NAME = "antigravity-awesome-skills"
|
||||
EDITORIAL_BUNDLES_PATH = Path("data") / "editorial-bundles.json"
|
||||
EDITORIAL_TEMPLATE_PATH = Path("tools") / "templates" / "editorial-bundles.md.tmpl"
|
||||
CLAUDE_MARKETPLACE_PATH = Path(".claude-plugin") / "marketplace.json"
|
||||
CLAUDE_PLUGIN_PATH = Path(".claude-plugin") / "plugin.json"
|
||||
CODEX_MARKETPLACE_PATH = Path(".agents") / "plugins" / "marketplace.json"
|
||||
CODEX_ROOT_PLUGIN_PATH = Path("plugins") / ROOT_CODEX_PLUGIN_NAME / ".codex-plugin" / "plugin.json"
|
||||
ACRONYM_TOKENS = {
|
||||
"ab": "A/B",
|
||||
"adb": "ADB",
|
||||
"adr": "ADR",
|
||||
"ads": "ADS",
|
||||
"ai": "AI",
|
||||
"api": "API",
|
||||
"apis": "APIs",
|
||||
"app": "App",
|
||||
"apps": "Apps",
|
||||
"aso": "ASO",
|
||||
"aws": "AWS",
|
||||
"bat": "BAT",
|
||||
"ci": "CI",
|
||||
"cli": "CLI",
|
||||
"cms": "CMS",
|
||||
"crm": "CRM",
|
||||
"cro": "CRO",
|
||||
"css": "CSS",
|
||||
"csv": "CSV",
|
||||
"dag": "DAG",
|
||||
"dbt": "dbt",
|
||||
"ddd": "DDD",
|
||||
"devops": "DevOps",
|
||||
"docx": "DOCX",
|
||||
"dx": "DX",
|
||||
"e2e": "E2E",
|
||||
"expo": "Expo",
|
||||
"fastapi": "FastAPI",
|
||||
"github": "GitHub",
|
||||
"gitlab": "GitLab",
|
||||
"grafana": "Grafana",
|
||||
"html": "HTML",
|
||||
"ios": "iOS",
|
||||
"jwt": "JWT",
|
||||
"k8s": "K8s",
|
||||
"kpi": "KPI",
|
||||
"langfuse": "Langfuse",
|
||||
"langgraph": "LangGraph",
|
||||
"linux": "Linux",
|
||||
"llm": "LLM",
|
||||
"llms": "LLMs",
|
||||
"mcp": "MCP",
|
||||
"nextjs": "Next.js",
|
||||
"nodejs": "Node.js",
|
||||
"oauth2": "OAuth2",
|
||||
"odoo": "Odoo",
|
||||
"openai": "OpenAI",
|
||||
"owasp": "OWASP",
|
||||
"pdf": "PDF",
|
||||
"php": "PHP",
|
||||
"postgres": "Postgres",
|
||||
"pr": "PR",
|
||||
"prd": "PRD",
|
||||
"pwa": "PWA",
|
||||
"python": "Python",
|
||||
"rag": "RAG",
|
||||
"rails": "Rails",
|
||||
"react": "React",
|
||||
"rest": "REST",
|
||||
"rpc": "RPC",
|
||||
"saas": "SaaS",
|
||||
"seo": "SEO",
|
||||
"shopify": "Shopify",
|
||||
"slack": "Slack",
|
||||
"slo": "SLO",
|
||||
"sre": "SRE",
|
||||
"sql": "SQL",
|
||||
"sso": "SSO",
|
||||
"stripe": "Stripe",
|
||||
"svg": "SVG",
|
||||
"swiftui": "SwiftUI",
|
||||
"tailwind": "Tailwind",
|
||||
"tdd": "TDD",
|
||||
"ts": "TS",
|
||||
"tsx": "TSX",
|
||||
"ui": "UI",
|
||||
"ux": "UX",
|
||||
"uv": "uv",
|
||||
"webgl": "WebGL",
|
||||
"xcode": "Xcode",
|
||||
"xml": "XML",
|
||||
"yaml": "YAML",
|
||||
"zod": "Zod",
|
||||
}
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _write_text(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def _clean_group_label(group: str) -> str:
|
||||
return re.sub(r"^[^A-Za-z0-9]+", "", group).strip()
|
||||
|
||||
|
||||
def _bundle_plugin_name(bundle_id: str) -> str:
|
||||
return f"antigravity-bundle-{bundle_id}"
|
||||
|
||||
|
||||
def _humanize_skill_label(skill_id: str) -> str:
|
||||
tokens = re.split(r"[-_]+", skill_id.split("/")[-1])
|
||||
words = [ACRONYM_TOKENS.get(token.lower(), token.capitalize()) for token in tokens if token]
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
def _bundle_codex_long_description(bundle: dict[str, Any]) -> str:
|
||||
audience = bundle.get("audience") or bundle["description"]
|
||||
highlights = [
|
||||
_humanize_skill_label(skill["id"])
|
||||
for skill in bundle["skills"][:2]
|
||||
if skill.get("id")
|
||||
]
|
||||
remaining = len(bundle["skills"]) - len(highlights)
|
||||
|
||||
if not highlights:
|
||||
return f'{audience} Includes {len(bundle["skills"])} curated skills from Antigravity Awesome Skills.'
|
||||
|
||||
if remaining > 0:
|
||||
return f"{audience} Covers {', '.join(highlights)}, and {remaining} more skills."
|
||||
|
||||
if len(highlights) == 1:
|
||||
return f"{audience} Covers {highlights[0]}."
|
||||
|
||||
return f"{audience} Covers {' and '.join(highlights)}."
|
||||
|
||||
|
||||
def _validate_bundle_skill_id(skill_id: str) -> None:
|
||||
if not SAFE_SKILL_ID_RE.fullmatch(skill_id):
|
||||
raise ValueError(f"Invalid skill id in editorial bundles manifest: {skill_id!r}")
|
||||
|
||||
|
||||
def _validate_editorial_bundles(root: Path, payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
bundles = payload.get("bundles")
|
||||
if not isinstance(bundles, list) or not bundles:
|
||||
raise ValueError("data/editorial-bundles.json must contain a non-empty 'bundles' array.")
|
||||
|
||||
seen_bundle_ids: set[str] = set()
|
||||
seen_bundle_names: set[str] = set()
|
||||
skills_root = root / "skills"
|
||||
|
||||
for bundle in bundles:
|
||||
if not isinstance(bundle, dict):
|
||||
raise ValueError("Each editorial bundle must be an object.")
|
||||
|
||||
bundle_id = str(bundle.get("id", "")).strip()
|
||||
bundle_name = str(bundle.get("name", "")).strip()
|
||||
if not bundle_id or not bundle_name:
|
||||
raise ValueError("Each editorial bundle requires non-empty 'id' and 'name'.")
|
||||
if bundle_id in seen_bundle_ids:
|
||||
raise ValueError(f"Duplicate editorial bundle id: {bundle_id}")
|
||||
if bundle_name in seen_bundle_names:
|
||||
raise ValueError(f"Duplicate editorial bundle name: {bundle_name}")
|
||||
|
||||
seen_bundle_ids.add(bundle_id)
|
||||
seen_bundle_names.add(bundle_name)
|
||||
|
||||
plugin_name = _bundle_plugin_name(bundle_id)
|
||||
if len(plugin_name) > 64:
|
||||
raise ValueError(f"Bundle plugin name exceeds 64 characters: {plugin_name}")
|
||||
|
||||
for key in ("group", "emoji", "tagline", "audience", "description"):
|
||||
if not str(bundle.get(key, "")).strip():
|
||||
raise ValueError(f"Editorial bundle '{bundle_id}' is missing required field '{key}'.")
|
||||
|
||||
skills = bundle.get("skills")
|
||||
if not isinstance(skills, list) or not skills:
|
||||
raise ValueError(f"Editorial bundle '{bundle_id}' must include a non-empty 'skills' array.")
|
||||
|
||||
seen_skill_ids: set[str] = set()
|
||||
for skill in skills:
|
||||
if not isinstance(skill, dict):
|
||||
raise ValueError(f"Editorial bundle '{bundle_id}' contains a non-object skill entry.")
|
||||
skill_id = str(skill.get("id", "")).strip()
|
||||
summary = str(skill.get("summary", "")).strip()
|
||||
_validate_bundle_skill_id(skill_id)
|
||||
if skill_id in seen_skill_ids:
|
||||
raise ValueError(f"Editorial bundle '{bundle_id}' contains duplicate skill '{skill_id}'.")
|
||||
if not summary:
|
||||
raise ValueError(f"Editorial bundle '{bundle_id}' skill '{skill_id}' is missing summary.")
|
||||
skill_path = (skills_root / skill_id).resolve(strict=False)
|
||||
if not skill_path.exists():
|
||||
raise ValueError(f"Editorial bundle '{bundle_id}' references missing skill '{skill_id}'.")
|
||||
seen_skill_ids.add(skill_id)
|
||||
|
||||
return bundles
|
||||
|
||||
|
||||
def _render_bundle_sections(bundles: list[dict[str, Any]]) -> str:
|
||||
lines: list[str] = []
|
||||
current_group: str | None = None
|
||||
|
||||
for bundle in bundles:
|
||||
group = bundle["group"]
|
||||
if group != current_group:
|
||||
if lines:
|
||||
lines.extend(["", "---", ""])
|
||||
lines.append(f"## {group}")
|
||||
lines.append("")
|
||||
current_group = group
|
||||
|
||||
lines.append(f'### {bundle["emoji"]} {bundle["tagline"]}')
|
||||
lines.append("")
|
||||
lines.append(f'_{bundle["audience"]}_')
|
||||
lines.append("")
|
||||
for skill in bundle["skills"]:
|
||||
lines.append(
|
||||
f'- [`{skill["id"]}`](../../skills/{skill["id"]}/): {skill["summary"]}'
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def render_bundles_doc(root: Path, metadata: dict[str, Any], bundles: list[dict[str, Any]]) -> str:
|
||||
template = (root / EDITORIAL_TEMPLATE_PATH).read_text(encoding="utf-8")
|
||||
return (
|
||||
template.replace("{{bundle_sections}}", _render_bundle_sections(bundles).rstrip())
|
||||
.replace("{{total_skills_label}}", metadata["total_skills_label"])
|
||||
.replace("{{bundle_count}}", str(len(bundles)))
|
||||
)
|
||||
|
||||
|
||||
def _copy_file_contents(src: Path, dest: Path, allowed_root: Path) -> None:
|
||||
resolved_src = src.resolve(strict=True)
|
||||
resolved_src.relative_to(allowed_root.resolve())
|
||||
|
||||
if resolved_src.is_dir():
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for child in resolved_src.iterdir():
|
||||
_copy_file_contents(child, dest / child.name, allowed_root)
|
||||
return
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(resolved_src, dest)
|
||||
|
||||
|
||||
def _copy_skill_directory(root: Path, skill_id: str, destination_root: Path) -> None:
|
||||
skills_root = root / "skills"
|
||||
source = (skills_root / skill_id).resolve(strict=True)
|
||||
source.relative_to(skills_root.resolve())
|
||||
if not source.is_dir():
|
||||
raise ValueError(f"Editorial bundle skill '{skill_id}' is not a directory.")
|
||||
|
||||
skill_dest = destination_root / skill_id
|
||||
if skill_dest.exists():
|
||||
shutil.rmtree(skill_dest)
|
||||
|
||||
for child in source.iterdir():
|
||||
_copy_file_contents(child, skill_dest / child.name, skills_root)
|
||||
|
||||
if not (skill_dest / "SKILL.md").is_file():
|
||||
raise ValueError(f"Copied bundle skill '{skill_id}' is missing SKILL.md in {skill_dest}")
|
||||
|
||||
|
||||
def _root_claude_plugin_manifest(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"name": ROOT_CLAUDE_PLUGIN_NAME,
|
||||
"version": metadata["version"],
|
||||
"description": (
|
||||
f"Universal agentic skill library for Claude Code with "
|
||||
f"{metadata['total_skills_label']} reusable skills across coding, security, "
|
||||
"design, product, and operations workflows."
|
||||
),
|
||||
"author": AUTHOR,
|
||||
"homepage": REPO_URL,
|
||||
"repository": REPO_URL,
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
"skills",
|
||||
"agentic-skills",
|
||||
"ai-coding",
|
||||
"productivity",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _root_codex_plugin_manifest(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"name": ROOT_CODEX_PLUGIN_NAME,
|
||||
"version": metadata["version"],
|
||||
"description": "Repository-backed Codex plugin for the Antigravity Awesome Skills library.",
|
||||
"author": AUTHOR,
|
||||
"homepage": REPO_URL,
|
||||
"repository": REPO_URL,
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"codex",
|
||||
"skills",
|
||||
"agentic-skills",
|
||||
"developer-tools",
|
||||
"productivity",
|
||||
],
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "Antigravity Awesome Skills",
|
||||
"shortDescription": (
|
||||
f'{metadata["total_skills_label"]} reusable skills for coding, security, '
|
||||
"product, and ops workflows."
|
||||
),
|
||||
"longDescription": (
|
||||
"Install the Antigravity Awesome Skills catalog as a Codex plugin and expose "
|
||||
"the repository's curated skills library through a single marketplace entry."
|
||||
),
|
||||
"developerName": AUTHOR["name"],
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Interactive", "Write"],
|
||||
"websiteURL": REPO_URL,
|
||||
"defaultPrompt": [
|
||||
"Use @brainstorming to plan a new feature.",
|
||||
"Use @test-driven-development to fix a bug safely.",
|
||||
"Use @lint-and-validate to verify this branch.",
|
||||
],
|
||||
"brandColor": "#111827",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _bundle_claude_plugin_manifest(metadata: dict[str, Any], bundle: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"name": _bundle_plugin_name(bundle["id"]),
|
||||
"version": metadata["version"],
|
||||
"description": (
|
||||
f'Editorial "{bundle["name"]}" bundle for Claude Code from Antigravity Awesome Skills.'
|
||||
),
|
||||
"author": AUTHOR,
|
||||
"homepage": REPO_URL,
|
||||
"repository": REPO_URL,
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
"skills",
|
||||
"bundle",
|
||||
bundle["id"],
|
||||
"antigravity-awesome-skills",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _bundle_codex_plugin_manifest(metadata: dict[str, Any], bundle: dict[str, Any]) -> dict[str, Any]:
|
||||
category = _clean_group_label(bundle["group"])
|
||||
plugin_name = _bundle_plugin_name(bundle["id"])
|
||||
skill_count = len(bundle["skills"])
|
||||
return {
|
||||
"name": plugin_name,
|
||||
"version": metadata["version"],
|
||||
"description": (
|
||||
f'Install the "{bundle["name"]}" editorial skill bundle from Antigravity Awesome Skills.'
|
||||
),
|
||||
"author": AUTHOR,
|
||||
"homepage": REPO_URL,
|
||||
"repository": REPO_URL,
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"codex",
|
||||
"skills",
|
||||
"bundle",
|
||||
bundle["id"],
|
||||
"productivity",
|
||||
],
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": bundle["name"],
|
||||
"shortDescription": f"{category} · {skill_count} curated skills",
|
||||
"longDescription": _bundle_codex_long_description(bundle),
|
||||
"developerName": AUTHOR["name"],
|
||||
"category": category,
|
||||
"capabilities": ["Interactive", "Write"],
|
||||
"websiteURL": REPO_URL,
|
||||
"brandColor": "#111827",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _root_codex_plugin_manifest(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"name": ROOT_CODEX_PLUGIN_NAME,
|
||||
"version": metadata["version"],
|
||||
"description": "Repository-backed Codex plugin for the Antigravity Awesome Skills library.",
|
||||
"author": AUTHOR,
|
||||
"homepage": REPO_URL,
|
||||
"repository": REPO_URL,
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"codex",
|
||||
"skills",
|
||||
"agentic-skills",
|
||||
"developer-tools",
|
||||
"productivity",
|
||||
],
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "Antigravity Awesome Skills",
|
||||
"shortDescription": (
|
||||
f'{metadata["total_skills_label"]} reusable skills for coding, security, product, and ops workflows.'
|
||||
),
|
||||
"longDescription": (
|
||||
"Install the Antigravity Awesome Skills catalog as a Codex plugin and expose "
|
||||
"the repository's curated skills library through a single marketplace entry."
|
||||
),
|
||||
"developerName": AUTHOR["name"],
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Interactive", "Write"],
|
||||
"websiteURL": REPO_URL,
|
||||
"defaultPrompt": [
|
||||
"Use @brainstorming to plan a new feature.",
|
||||
"Use @test-driven-development to fix a bug safely.",
|
||||
"Use @lint-and-validate to verify this branch.",
|
||||
],
|
||||
"brandColor": "#111827",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _bundle_claude_marketplace_entry(metadata: dict[str, Any], bundle: dict[str, Any]) -> dict[str, Any]:
|
||||
plugin_name = _bundle_plugin_name(bundle["id"])
|
||||
return {
|
||||
"name": plugin_name,
|
||||
"version": metadata["version"],
|
||||
"description": (
|
||||
f'Install the "{bundle["name"]}" editorial skill bundle for Claude Code.'
|
||||
),
|
||||
"author": AUTHOR,
|
||||
"homepage": REPO_URL,
|
||||
"repository": REPO_URL,
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
"skills",
|
||||
"bundle",
|
||||
bundle["id"],
|
||||
"marketplace",
|
||||
],
|
||||
"source": f"./plugins/{plugin_name}",
|
||||
}
|
||||
|
||||
|
||||
def _render_claude_marketplace(metadata: dict[str, Any], bundles: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
plugins = [
|
||||
{
|
||||
"name": ROOT_CLAUDE_PLUGIN_NAME,
|
||||
"version": metadata["version"],
|
||||
"description": (
|
||||
"Expose the full repository `skills/` tree to Claude Code through a "
|
||||
"single marketplace entry."
|
||||
),
|
||||
"author": AUTHOR,
|
||||
"homepage": REPO_URL,
|
||||
"repository": REPO_URL,
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
"skills",
|
||||
"agentic-skills",
|
||||
"plugin",
|
||||
"marketplace",
|
||||
],
|
||||
"source": "./",
|
||||
}
|
||||
]
|
||||
plugins.extend(_bundle_claude_marketplace_entry(metadata, bundle) for bundle in bundles)
|
||||
return {
|
||||
"name": ROOT_CLAUDE_PLUGIN_NAME,
|
||||
"owner": AUTHOR,
|
||||
"metadata": {
|
||||
"description": (
|
||||
"Claude Code marketplace entries for the full Antigravity Awesome Skills "
|
||||
"library and its editorial bundles."
|
||||
),
|
||||
"version": metadata["version"],
|
||||
},
|
||||
"plugins": plugins,
|
||||
}
|
||||
|
||||
|
||||
def _render_codex_marketplace(bundles: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
plugins: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": ROOT_CODEX_PLUGIN_NAME,
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": f"./plugins/{ROOT_CODEX_PLUGIN_NAME}",
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL",
|
||||
},
|
||||
"category": "Productivity",
|
||||
}
|
||||
]
|
||||
|
||||
for bundle in bundles:
|
||||
plugins.append(
|
||||
{
|
||||
"name": _bundle_plugin_name(bundle["id"]),
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": f'./plugins/{_bundle_plugin_name(bundle["id"])}',
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL",
|
||||
},
|
||||
"category": _clean_group_label(bundle["group"]),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"name": ROOT_CODEX_PLUGIN_NAME,
|
||||
"interface": {
|
||||
"displayName": "Antigravity Awesome Skills",
|
||||
},
|
||||
"plugins": plugins,
|
||||
}
|
||||
|
||||
|
||||
def _sync_bundle_plugin_directory(root: Path, metadata: dict[str, Any], bundle: dict[str, Any]) -> None:
|
||||
plugin_name = _bundle_plugin_name(bundle["id"])
|
||||
plugin_root = root / "plugins" / plugin_name
|
||||
if plugin_root.exists():
|
||||
shutil.rmtree(plugin_root)
|
||||
|
||||
bundle_skills_root = plugin_root / "skills"
|
||||
bundle_skills_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for skill in bundle["skills"]:
|
||||
_copy_skill_directory(root, skill["id"], bundle_skills_root)
|
||||
|
||||
_write_json(
|
||||
plugin_root / ".claude-plugin" / "plugin.json",
|
||||
_bundle_claude_plugin_manifest(metadata, bundle),
|
||||
)
|
||||
_write_json(
|
||||
plugin_root / ".codex-plugin" / "plugin.json",
|
||||
_bundle_codex_plugin_manifest(metadata, bundle),
|
||||
)
|
||||
|
||||
|
||||
def sync_editorial_bundle_plugins(root: Path, metadata: dict[str, Any], bundles: list[dict[str, Any]]) -> None:
|
||||
plugins_root = root / "plugins"
|
||||
for candidate in plugins_root.glob("antigravity-bundle-*"):
|
||||
if candidate.is_dir():
|
||||
shutil.rmtree(candidate)
|
||||
|
||||
for bundle in bundles:
|
||||
_sync_bundle_plugin_directory(root, metadata, bundle)
|
||||
|
||||
|
||||
def load_editorial_bundles(root: Path) -> list[dict[str, Any]]:
|
||||
root = Path(root)
|
||||
payload = _read_json(root / EDITORIAL_BUNDLES_PATH)
|
||||
return _validate_editorial_bundles(root, payload)
|
||||
|
||||
|
||||
def sync_editorial_bundles(root: Path) -> None:
|
||||
metadata = load_metadata(str(root))
|
||||
bundles = load_editorial_bundles(root)
|
||||
|
||||
_write_text(root / "docs" / "users" / "bundles.md", render_bundles_doc(root, metadata, bundles))
|
||||
_write_json(root / CLAUDE_PLUGIN_PATH, _root_claude_plugin_manifest(metadata))
|
||||
_write_json(root / CLAUDE_MARKETPLACE_PATH, _render_claude_marketplace(metadata, bundles))
|
||||
_write_json(root / CODEX_ROOT_PLUGIN_PATH, _root_codex_plugin_manifest(metadata))
|
||||
_write_json(root / CODEX_MARKETPLACE_PATH, _render_codex_marketplace(bundles))
|
||||
sync_editorial_bundle_plugins(root, metadata, bundles)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Sync editorial bundle docs and plugin marketplaces.")
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Validate the editorial bundles manifest and exit without writing files.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
root = find_repo_root(__file__)
|
||||
if args.check:
|
||||
metadata = load_metadata(str(root))
|
||||
bundles = load_editorial_bundles(root)
|
||||
expected_doc = render_bundles_doc(root, metadata, bundles)
|
||||
current_doc = (root / "docs" / "users" / "bundles.md").read_text(encoding="utf-8")
|
||||
if current_doc != expected_doc:
|
||||
raise SystemExit("docs/users/bundles.md is out of sync with data/editorial-bundles.json")
|
||||
print("✅ Editorial bundles manifest and generated doc are in sync.")
|
||||
return 0
|
||||
sync_editorial_bundles(root)
|
||||
print("✅ Editorial bundles synced.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
configure_utf8_output()
|
||||
raise SystemExit(main())
|
||||
@@ -7,6 +7,7 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sync_editorial_bundles import load_editorial_bundles, render_bundles_doc
|
||||
from update_readme import configure_utf8_output, find_repo_root, load_metadata, update_readme
|
||||
|
||||
|
||||
@@ -181,7 +182,14 @@ def sync_getting_started(content: str, metadata: dict) -> str:
|
||||
return content
|
||||
|
||||
|
||||
def sync_bundles_doc(content: str, metadata: dict) -> str:
|
||||
def sync_bundles_doc(content: str, metadata: dict, base_dir: str | Path | None = None) -> str:
|
||||
root = Path(base_dir) if base_dir is not None else Path(find_repo_root(__file__))
|
||||
manifest_path = root / "data" / "editorial-bundles.json"
|
||||
template_path = root / "tools" / "templates" / "editorial-bundles.md.tmpl"
|
||||
if manifest_path.is_file() and template_path.is_file():
|
||||
bundles = load_editorial_bundles(root)
|
||||
return render_bundles_doc(root, metadata, bundles)
|
||||
|
||||
bundle_count = count_documented_bundles(content)
|
||||
if bundle_count == 0:
|
||||
bundle_count = 36
|
||||
@@ -298,7 +306,14 @@ def sync_curated_docs(base_dir: str, metadata: dict, dry_run: bool) -> int:
|
||||
updated_files = 0
|
||||
updated_files += int(update_text_file(root / "README.md", sync_readme_copy, metadata, dry_run))
|
||||
updated_files += int(update_text_file(root / "docs" / "users" / "getting-started.md", sync_getting_started, metadata, dry_run))
|
||||
updated_files += int(update_text_file(root / "docs" / "users" / "bundles.md", sync_bundles_doc, metadata, dry_run))
|
||||
updated_files += int(
|
||||
update_text_file(
|
||||
root / "docs" / "users" / "bundles.md",
|
||||
lambda content, current_metadata: sync_bundles_doc(content, current_metadata, root),
|
||||
metadata,
|
||||
dry_run,
|
||||
)
|
||||
)
|
||||
updated_files += int(update_text_file(root / "docs" / "integrations" / "jetski-cortex.md", sync_jetski_cortex, metadata, dry_run))
|
||||
|
||||
for path, replacements in regex_text_replacements:
|
||||
|
||||
@@ -5,10 +5,25 @@ const { findProjectRoot } = require("../../lib/project-root");
|
||||
|
||||
const projectRoot = findProjectRoot(__dirname);
|
||||
const marketplacePath = path.join(projectRoot, ".claude-plugin", "marketplace.json");
|
||||
const editorialBundlesPath = path.join(projectRoot, "data", "editorial-bundles.json");
|
||||
const marketplace = JSON.parse(fs.readFileSync(marketplacePath, "utf8"));
|
||||
const editorialBundles = JSON.parse(fs.readFileSync(editorialBundlesPath, "utf8")).bundles || [];
|
||||
|
||||
assert.ok(Array.isArray(marketplace.plugins), "marketplace.json must define a plugins array");
|
||||
assert.ok(marketplace.plugins.length > 0, "marketplace.json must contain at least one plugin");
|
||||
assert.strictEqual(
|
||||
marketplace.plugins[0]?.name,
|
||||
"antigravity-awesome-skills",
|
||||
"full library Claude plugin should remain the first marketplace entry",
|
||||
);
|
||||
|
||||
const expectedBundlePluginNames = editorialBundles.map((bundle) => `antigravity-bundle-${bundle.id}`);
|
||||
for (const pluginName of expectedBundlePluginNames) {
|
||||
assert.ok(
|
||||
marketplace.plugins.some((plugin) => plugin.name === pluginName),
|
||||
`marketplace.json must contain bundle plugin ${pluginName}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const plugin of marketplace.plugins) {
|
||||
assert.strictEqual(
|
||||
@@ -18,7 +33,7 @@ for (const plugin of marketplace.plugins) {
|
||||
);
|
||||
assert.ok(
|
||||
plugin.source.startsWith("./"),
|
||||
`plugin ${plugin.name || "<unnamed>"} source must be a relative path starting with ./`,
|
||||
`plugin ${plugin.name || "<unnamed>"} source must be a repo-relative path starting with ./`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ const { findProjectRoot } = require("../../lib/project-root");
|
||||
|
||||
const projectRoot = findProjectRoot(__dirname);
|
||||
const marketplacePath = path.join(projectRoot, ".agents", "plugins", "marketplace.json");
|
||||
const editorialBundlesPath = path.join(projectRoot, "data", "editorial-bundles.json");
|
||||
const marketplace = JSON.parse(fs.readFileSync(marketplacePath, "utf8"));
|
||||
const editorialBundles = JSON.parse(fs.readFileSync(editorialBundlesPath, "utf8")).bundles || [];
|
||||
|
||||
assert.strictEqual(
|
||||
marketplace.name,
|
||||
@@ -19,6 +21,11 @@ assert.strictEqual(
|
||||
);
|
||||
assert.ok(Array.isArray(marketplace.plugins), "marketplace.json must define a plugins array");
|
||||
assert.ok(marketplace.plugins.length > 0, "marketplace.json must contain at least one plugin");
|
||||
assert.strictEqual(
|
||||
marketplace.plugins[0]?.name,
|
||||
"antigravity-awesome-skills",
|
||||
"full library Codex plugin should remain the first marketplace entry",
|
||||
);
|
||||
|
||||
const pluginEntry = marketplace.plugins.find((plugin) => plugin.name === "antigravity-awesome-skills");
|
||||
assert.ok(pluginEntry, "marketplace.json must include the antigravity-awesome-skills plugin entry");
|
||||
@@ -58,4 +65,23 @@ const pluginSkillsPath = path.join(pluginRoot, "skills");
|
||||
assert.ok(fs.existsSync(pluginSkillsPath), "Codex plugin skills path must exist");
|
||||
assert.ok(fs.statSync(pluginSkillsPath).isDirectory(), "Codex plugin skills path must be a directory");
|
||||
|
||||
for (const bundle of editorialBundles) {
|
||||
const bundlePluginName = `antigravity-bundle-${bundle.id}`;
|
||||
const bundleEntry = marketplace.plugins.find((plugin) => plugin.name === bundlePluginName);
|
||||
assert.ok(bundleEntry, `marketplace.json must include bundle plugin ${bundlePluginName}`);
|
||||
assert.deepStrictEqual(
|
||||
bundleEntry.source,
|
||||
{
|
||||
source: "local",
|
||||
path: `./plugins/${bundlePluginName}`,
|
||||
},
|
||||
`bundle plugin ${bundlePluginName} should resolve to the expected repo-local directory`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
bundleEntry.category,
|
||||
bundle.group.replace(/^[^A-Za-z0-9]+/, "").trim(),
|
||||
`bundle plugin ${bundlePluginName} should derive its category from the bundle group`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("ok");
|
||||
|
||||
@@ -15,6 +15,7 @@ const LOCAL_TEST_COMMANDS = [
|
||||
[path.join(TOOL_TESTS, "build_catalog_bundles.test.js")],
|
||||
[path.join(TOOL_TESTS, "claude_plugin_marketplace.test.js")],
|
||||
[path.join(TOOL_TESTS, "codex_plugin_marketplace.test.js")],
|
||||
[path.join(TOOL_SCRIPTS, "run-python.js"), path.join(TOOL_TESTS, "test_editorial_bundles.py")],
|
||||
[path.join(TOOL_TESTS, "installer_antigravity_guidance.test.js")],
|
||||
[path.join(TOOL_TESTS, "jetski_gemini_loader.test.cjs")],
|
||||
[path.join(TOOL_TESTS, "npm_package_contents.test.js")],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -27,23 +28,31 @@ class BundleActivationSecurityTests(unittest.TestCase):
|
||||
formatted = get_bundle_skills.format_skills_for_batch([
|
||||
"safe-skill",
|
||||
"nested.skill_2",
|
||||
"game-development/game-design",
|
||||
"unsafe&calc",
|
||||
"another|bad",
|
||||
])
|
||||
|
||||
self.assertEqual(formatted, "safe-skill\nnested.skill_2\n")
|
||||
self.assertEqual(formatted, "safe-skill\nnested.skill_2\ngame-development/game-design\n")
|
||||
|
||||
def test_get_bundle_skills_rejects_unsafe_bundle_entries(self):
|
||||
def test_get_bundle_skills_rejects_unsafe_bundle_entries_from_manifest(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
bundles_path = pathlib.Path(temp_dir) / "bundles.md"
|
||||
bundles_path = pathlib.Path(temp_dir) / "editorial-bundles.json"
|
||||
bundles_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"### Essentials",
|
||||
"- [`safe-skill`](../../skills/safe-skill/)",
|
||||
"- [`unsafe&calc`](../../skills/unsafe/)",
|
||||
"- [`safe_two`](../../skills/safe_two/)",
|
||||
]
|
||||
json.dumps(
|
||||
{
|
||||
"bundles": [
|
||||
{
|
||||
"id": "essentials",
|
||||
"name": "Essentials",
|
||||
"skills": [
|
||||
{"id": "safe-skill"},
|
||||
{"id": "unsafe&calc"},
|
||||
{"id": "safe_two"},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -55,6 +64,11 @@ class BundleActivationSecurityTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(skills, ["safe-skill", "safe_two"])
|
||||
|
||||
def test_nested_skill_ids_are_allowed_when_safe(self):
|
||||
self.assertTrue(get_bundle_skills.is_safe_skill_id("game-development/game-design"))
|
||||
self.assertFalse(get_bundle_skills.is_safe_skill_id("../escape"))
|
||||
self.assertFalse(get_bundle_skills.is_safe_skill_id("game-development/../escape"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
104
tools/scripts/tests/test_editorial_bundles.py
Normal file
104
tools/scripts/tests/test_editorial_bundles.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
TOOLS_SCRIPTS = REPO_ROOT / "tools" / "scripts"
|
||||
|
||||
|
||||
def load_module(module_path: pathlib.Path, module_name: str):
|
||||
sys.path.insert(0, str(module_path.parent))
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
editorial_bundles = load_module(
|
||||
TOOLS_SCRIPTS / "sync_editorial_bundles.py",
|
||||
"sync_editorial_bundles",
|
||||
)
|
||||
get_bundle_skills = load_module(
|
||||
TOOLS_SCRIPTS / "get-bundle-skills.py",
|
||||
"get_bundle_skills_json",
|
||||
)
|
||||
|
||||
|
||||
class EditorialBundlesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.manifest_bundles = editorial_bundles.load_editorial_bundles(REPO_ROOT)
|
||||
|
||||
def test_manifest_has_unique_ids_and_existing_skills(self):
|
||||
bundle_ids = [bundle["id"] for bundle in self.manifest_bundles]
|
||||
self.assertEqual(len(bundle_ids), len(set(bundle_ids)))
|
||||
|
||||
for bundle in self.manifest_bundles:
|
||||
self.assertEqual(bundle["id"], get_bundle_skills._normalize_bundle_query(bundle["name"]))
|
||||
self.assertTrue(bundle["skills"], f'bundle "{bundle["id"]}" should not be empty')
|
||||
for skill in bundle["skills"]:
|
||||
self.assertTrue((REPO_ROOT / "skills" / skill["id"]).exists())
|
||||
|
||||
def test_bundles_doc_matches_renderer(self):
|
||||
metadata = editorial_bundles.load_metadata(str(REPO_ROOT))
|
||||
expected = editorial_bundles.render_bundles_doc(REPO_ROOT, metadata, self.manifest_bundles)
|
||||
actual = (REPO_ROOT / "docs" / "users" / "bundles.md").read_text(encoding="utf-8")
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_get_bundle_skills_reads_json_manifest_by_name_and_id(self):
|
||||
expected = ["concise-planning", "git-pushing", "kaizen", "lint-and-validate", "systematic-debugging"]
|
||||
self.assertEqual(get_bundle_skills.get_bundle_skills(["Essentials"]), expected)
|
||||
self.assertEqual(get_bundle_skills.get_bundle_skills(["essentials"]), expected)
|
||||
web_wizard_skills = get_bundle_skills.get_bundle_skills(["web-wizard"])
|
||||
self.assertIn("form-cro", web_wizard_skills)
|
||||
self.assertIn("react-best-practices", web_wizard_skills)
|
||||
self.assertIn(
|
||||
"game-development/game-design",
|
||||
get_bundle_skills.get_bundle_skills(["indie-game-dev"]),
|
||||
)
|
||||
|
||||
def test_generated_bundle_plugin_contains_expected_skills(self):
|
||||
essentials_plugin = REPO_ROOT / "plugins" / "antigravity-bundle-essentials" / "skills"
|
||||
expected_ids = {
|
||||
skill["id"]
|
||||
for skill in next(bundle for bundle in self.manifest_bundles if bundle["id"] == "essentials")["skills"]
|
||||
}
|
||||
actual_ids = {
|
||||
str(path.relative_to(essentials_plugin))
|
||||
for path in essentials_plugin.rglob("SKILL.md")
|
||||
}
|
||||
self.assertEqual(actual_ids, {f"{skill_id}/SKILL.md" for skill_id in expected_ids})
|
||||
|
||||
sample_skill_dir = essentials_plugin / "concise-planning"
|
||||
self.assertTrue((sample_skill_dir / "SKILL.md").is_file())
|
||||
|
||||
def test_generated_plugin_count_matches_manifest(self):
|
||||
generated_plugins = sorted(path.name for path in (REPO_ROOT / "plugins").iterdir() if path.is_dir() and path.name.startswith("antigravity-bundle-"))
|
||||
expected_plugins = sorted(f'antigravity-bundle-{bundle["id"]}' for bundle in self.manifest_bundles)
|
||||
self.assertEqual(generated_plugins, expected_plugins)
|
||||
|
||||
def test_sample_bundle_copy_matches_source_file_inventory(self):
|
||||
sample_bundle = next(bundle for bundle in self.manifest_bundles if bundle["id"] == "documents-presentations")
|
||||
plugin_skills_root = REPO_ROOT / "plugins" / "antigravity-bundle-documents-presentations" / "skills"
|
||||
|
||||
for skill in sample_bundle["skills"]:
|
||||
source_dir = REPO_ROOT / "skills" / skill["id"]
|
||||
copied_dir = plugin_skills_root / skill["id"]
|
||||
self.assertTrue(copied_dir.is_dir(), f'copied skill dir missing for {skill["id"]}')
|
||||
|
||||
source_files = sorted(
|
||||
str(path.relative_to(source_dir))
|
||||
for path in source_dir.rglob("*")
|
||||
if path.is_file()
|
||||
)
|
||||
copied_files = sorted(
|
||||
str(path.relative_to(copied_dir))
|
||||
for path in copied_dir.rglob("*")
|
||||
if path.is_file()
|
||||
)
|
||||
self.assertEqual(copied_files, source_files, f'copied bundle skill should match source inventory for {skill["id"]}')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user