Skip to content
61 changes: 21 additions & 40 deletions scripts/generate_plugins_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@
from pathlib import Path, PurePosixPath
from urllib.parse import quote, unquote

# Both direct script execution and importlib-based tests load this helper.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from plugin_output_utils import prune_unlisted_bundles, write_json

README = Path(__file__).parent.parent / "README.md"
OUTPUT = Path(__file__).parent.parent / "plugins.json"
MARKETPLACE_OUTPUT = Path(__file__).parent.parent / ".agents" / "plugins" / "marketplace.json"
PLUGINS_ROOT = Path(__file__).parent.parent / "plugins"
RETIRED_BUNDLE_IDENTITIES = {("samuelbushi", "uizze"), ("waynehamadi", "datacircle-plugin")}
REQUEST_TIMEOUT_SECONDS = 60
MAX_RETRIES = 3
USER_AGENT = "awesome-codex-plugins-generator"
Expand Down Expand Up @@ -59,6 +62,10 @@
"mturac/everything-openai-codex",
}
EXTRA_MIRROR_PATHS = {
# Context Guard declares Skills in its manifest; its lifecycle hooks,
# script entry points and state schema live at separate top-level paths.
# Mirror only these published runtime paths, not arbitrary source files.
"GreenLv/codex-context-guard": ("hooks", "scripts", "assets/state.schema.json"),
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
# Workflow MCP bootstrap loads the sibling control-plane runtime; its MCP
# App also reads assets/sidebar-icon.svg and Skills reference helper scripts.
"TohmaN233/codex-agents-workflow": ("control-plane", "scripts", "assets"),
Expand Down Expand Up @@ -319,6 +326,19 @@ def collect_selected_paths(
for extra_path in EXTRA_MIRROR_PATHS.get(f"{plugin['owner']}/{plugin['repo']}", ()):
add_recursive_selection(selected, all_names, plugin_root, extra_path)

# Context Guard's published .codexignore excludes transient build/cache
# artifacts. Never ship them merely because its hook runtime uses scripts/.
if f"{plugin['owner']}/{plugin['repo']}" == "GreenLv/codex-context-guard":
ignored_dirs = {
".codex", ".git", ".venv", "__pycache__",
".pytest_cache", ".ruff_cache", "build", "dist",
}
selected = {
path for path in selected
if not ignored_dirs.intersection(PurePosixPath(path).parts)
and not path.endswith(".pyc")
}

return selected


Expand Down Expand Up @@ -469,45 +489,6 @@ def build_marketplace_entry(
return entry


def write_json(path: Path, data: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def prune_unlisted_bundles(root: Path, plugins: list[dict[str, str]]) -> list[str]:
"""Remove only explicitly reviewed retirements that are absent from README.

Omission from the parsed catalog never authorizes another retirement.
Keep listed repositories even if this run cannot fetch them, and preflight
the two-level tree for symlinks before deleting any approved bundle.
"""
if not plugins:
raise ValueError("Refusing bundle cleanup from an empty parsed README; explicit maintainer retirement is required")
retained = {(plugin["owner"].casefold(), plugin["repo"].casefold()) for plugin in plugins}
removed: list[str] = []
if root.is_symlink():
raise ValueError("Generated plugins root must not be a symlink")
if not root.exists():
return removed
# Preflight the entire tree before retiring anything. A later invalid
# directory must not leave an earlier bundle partially retired.
retired: list[Path] = []
for owner in sorted(root.iterdir()):
if owner.is_symlink():
raise ValueError(f"Generated owner directory must not be a symlink: {owner}")
if not owner.is_dir():
continue
for repo in sorted(owner.iterdir()):
if repo.is_symlink():
raise ValueError(f"Generated repo directory must not be a symlink: {repo}")
identity = (owner.name.casefold(), repo.name.casefold())
if repo.is_dir() and identity in RETIRED_BUNDLE_IDENTITIES and identity not in retained:
retired.append(repo)
for repo in retired:
shutil.rmtree(repo)
removed.append(f"{repo.parent.name}/{repo.name}")
return removed


def main() -> None:
plugins = parse_plugins(README)
Expand Down
49 changes: 49 additions & 0 deletions scripts/plugin_output_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""JSON artifact writing and explicit retired bundle cleanup helpers.

Kept separate from the marketplace generator to preserve the 500-line source limit.
"""

from pathlib import Path
import json
import shutil

RETIRED_BUNDLE_IDENTITIES = {("samuelbushi", "uizze"), ("waynehamadi", "datacircle-plugin")}

def write_json(path: Path, data: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def prune_unlisted_bundles(root: Path, plugins: list[dict[str, str]]) -> list[str]:
"""Remove only explicitly reviewed retirements that are absent from README.

Omission from the parsed catalog never authorizes another retirement.
Keep listed repositories even if this run cannot fetch them, and preflight
the two-level tree for symlinks before deleting any approved bundle.
"""
if not plugins:
raise ValueError("Refusing bundle cleanup from an empty parsed README; explicit maintainer retirement is required")
retained = {(plugin["owner"].casefold(), plugin["repo"].casefold()) for plugin in plugins}
removed: list[str] = []
if root.is_symlink():
raise ValueError("Generated plugins root must not be a symlink")
if not root.exists():
return removed
# Preflight the entire tree before retiring anything. A later invalid
# directory must not leave an earlier bundle partially retired.
retired: list[Path] = []
for owner in sorted(root.iterdir()):
if owner.is_symlink():
raise ValueError(f"Generated owner directory must not be a symlink: {owner}")
if not owner.is_dir():
continue
for repo in sorted(owner.iterdir()):
if repo.is_symlink():
raise ValueError(f"Generated repo directory must not be a symlink: {repo}")
identity = (owner.name.casefold(), repo.name.casefold())
if repo.is_dir() and identity in RETIRED_BUNDLE_IDENTITIES and identity not in retained:
retired.append(repo)
for repo in retired:
shutil.rmtree(repo)
removed.append(f"{repo.parent.name}/{repo.name}")
return removed
52 changes: 51 additions & 1 deletion tests/test_generate_plugins_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import tempfile
import unittest
from unittest.mock import patch
from pathlib import Path
from pathlib import Path, PurePosixPath

SPEC = importlib.util.spec_from_file_location(
"generate_plugins_json", Path(__file__).resolve().parents[1] / "scripts/generate_plugins_json.py"
Expand All @@ -12,6 +12,56 @@


class GeneratedBundleCleanupTests(unittest.TestCase):
def test_context_guard_mirror_includes_undeclared_hook_runtime(self):
root = PurePosixPath("codex-context-guard-main")
names = {
"codex-context-guard-main/.codex-plugin/plugin.json",
"codex-context-guard-main/hooks/hooks.json",
"codex-context-guard-main/scripts/context_guard.py",
"codex-context-guard-main/scripts/manage_plugin.py",
"codex-context-guard-main/scripts/smoke_installed.py",
"codex-context-guard-main/scripts/cg_instruction.py",
"codex-context-guard-main/scripts/run_context_guard.sh",
"codex-context-guard-main/scripts/run-context-guard.ps1",
"codex-context-guard-main/scripts/__pycache__/context_guard.cpython-314.pyc",
"codex-context-guard-main/scripts/build/temp.json",
"codex-context-guard-main/hooks/.pytest_cache/state",
"codex-context-guard-main/scripts/dist/bundle.js",
"codex-context-guard-main/assets/icon.svg",
"codex-context-guard-main/assets/state.schema.json",
"codex-context-guard-main/internal/not-for-distribution.txt",
}
manifest = {
"name": "context-guard",
"skills": "./skills/",
"interface": {"composerIcon": "./assets/icon.svg"},
}
plugin = {"owner": "GreenLv", "repo": "codex-context-guard"}

paths = MODULE.collect_selected_paths(manifest, names, root, plugin)

self.assertTrue({
".codex-plugin/plugin.json",
"hooks/hooks.json",
"scripts/context_guard.py",
"scripts/manage_plugin.py",
"scripts/smoke_installed.py",
"scripts/cg_instruction.py",
"scripts/run_context_guard.sh",
"scripts/run-context-guard.ps1",
"assets/icon.svg",
"assets/state.schema.json",
}.issubset(paths))
self.assertNotIn("internal/not-for-distribution.txt", paths)
self.assertTrue(all(
name not in paths for name in (
"scripts/__pycache__/context_guard.cpython-314.pyc",
"scripts/build/temp.json",
"hooks/.pytest_cache/state",
"scripts/dist/bundle.js",
)
))

def test_branch_qualified_readme_entry_uses_source_branch_metadata(self):
with tempfile.TemporaryDirectory() as scratch:
readme = Path(scratch) / "README.md"
Expand Down
Loading