From 81be964525d383acb04ba799595126cd8013b9d8 Mon Sep 17 00:00:00 2001 From: Ashwin Mathews Date: Wed, 13 May 2026 22:40:38 -0700 Subject: [PATCH 1/6] init --- .agents/skills/plugin-creator/SKILL.md | 60 ++ .../scripts/check_plugin_readiness.py | 907 ++++++++++++++++++ .../scripts/create_basic_plugin.py | 21 + 3 files changed, 988 insertions(+) create mode 100644 .agents/skills/plugin-creator/scripts/check_plugin_readiness.py diff --git a/.agents/skills/plugin-creator/SKILL.md b/.agents/skills/plugin-creator/SKILL.md index 4a90165c..68cb25cb 100644 --- a/.agents/skills/plugin-creator/SKILL.md +++ b/.agents/skills/plugin-creator/SKILL.md @@ -48,6 +48,21 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ `` is the directory where the plugin folder `` will be created (for example `~/code/plugins`). +5. Before treating the plugin as finished, run the readiness check after replacing scaffold placeholders and adding real assets/configuration: + +```bash +python3 .agents/skills/plugin-creator/scripts/check_plugin_readiness.py +``` + +For marketplace-backed plugins, include the selected marketplace path: + +```bash +python3 .agents/skills/plugin-creator/scripts/check_plugin_readiness.py \ + --marketplace-path +``` + +Add `--require-marketplace` when the plugin must appear in that marketplace before it is considered done. + ## What this skill creates - Default marketplace-backed scaffolds are personal: `~/plugins//` plus @@ -71,6 +86,7 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ - `assets/` - `.mcp.json` - `.app.json` +- Provides `scripts/check_plugin_readiness.py` to audit a completed scaffold before publishing or sharing. ## Marketplace workflow @@ -140,11 +156,55 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ } ``` +## Readiness checklist + +Run `scripts/check_plugin_readiness.py` before telling the user a plugin is complete. Resolve every +`ERROR`. Treat `WARN` entries as judgment calls; use `--strict-warnings` when the plugin should be +publish-ready with no loose ends. + +The readiness check covers: + +- No scaffold placeholders remain in non-reference plugin files, including `[TODO: ...]`, example author URLs, + scaffold keywords, generic TODO/TBD markers, and marketplace placeholder names. +- `.codex-plugin/plugin.json` exists, is valid JSON, uses the normalized folder name, has semantic + versioning, and keeps required manifest/interface fields in the expected types. +- Manifest paths are relative plugin paths that begin with `./` and point to files or directories + that actually exist. +- `interface.logo` and `interface.composerIcon` are real non-empty image assets. +- `interface.brandColor` is a valid 6-digit hex color and is not blindly left at the scaffold default. +- `interface.defaultPrompt` contains 1-3 real hero prompts, each 128 characters or fewer. +- Referenced screenshots exist as non-empty PNG image files. +- Referenced `.app.json` and `.mcp.json` files are valid JSON with non-empty `apps` or `mcpServers` objects. +- Every skill `SKILL.md` has frontmatter with `name` and `description`. +- Every skill with a `SKILL.md` has `agents/openai.yaml` metadata, unless the user explicitly wants + to allow missing metadata and the checker is run with `--allow-missing-openai-yaml`. +- Existing `agents/openai.yaml` files include `interface.display_name` and + `interface.short_description`; icon paths are checked when present. +- The selected or nearest repo marketplace entry has the correct local source path, policy fields, + and category when a marketplace is part of the workflow. + +Useful options: + +```bash +python3 .agents/skills/plugin-creator/scripts/check_plugin_readiness.py \ + --marketplace-path ./.agents/plugins/marketplace.json \ + --require-marketplace +``` + +```bash +python3 .agents/skills/plugin-creator/scripts/check_plugin_readiness.py \ + --allow-missing-openai-yaml +``` + ## Required behavior - Outer folder name and `plugin.json` `"name"` are always the same normalized plugin name. - Do not remove required structure; keep `.codex-plugin/plugin.json` present. - Keep manifest values as placeholders until a human or follow-up step explicitly fills them. +- After scaffolding, point the user to the readiness checklist and command printed by + `create_basic_plugin.py`. +- Do not call a plugin finished until `scripts/check_plugin_readiness.py` has no `ERROR` findings, or + until the user explicitly accepts the remaining errors. - If creating files inside an existing plugin path, use `--force` only when overwrite is intentional. - Preserve any existing marketplace `interface.displayName`. - When generating marketplace entries, always write `policy.installation`, `policy.authentication`, and `category` even if their values are defaults. diff --git a/.agents/skills/plugin-creator/scripts/check_plugin_readiness.py b/.agents/skills/plugin-creator/scripts/check_plugin_readiness.py new file mode 100644 index 00000000..82b420d1 --- /dev/null +++ b/.agents/skills/plugin-creator/scripts/check_plugin_readiness.py @@ -0,0 +1,907 @@ +#!/usr/bin/env python3 +"""Audit a Codex plugin scaffold before publishing or sharing it.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +MAX_PLUGIN_NAME_LENGTH = 64 +VALID_INSTALL_POLICIES = {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"} +VALID_AUTH_POLICIES = {"ON_INSTALL", "ON_USE"} +SUPPORTED_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".svg", ".webp"} +TEXT_SUFFIXES = { + ".app.json", + ".css", + ".html", + ".js", + ".json", + ".jsx", + ".md", + ".mjs", + ".py", + ".sh", + ".toml", + ".ts", + ".tsx", + ".txt", + ".yaml", + ".yml", +} +TEXT_FILE_NAMES = {"SKILL.md", "openai.yaml", "plugin.json", ".app.json", ".mcp.json"} +IGNORED_DIRS = { + ".git", + ".venv", + "__pycache__", + "build", + "coverage", + "dist", + "node_modules", + "references", + "vendor", +} +ERROR_PLACEHOLDER_PATTERNS = [ + (re.compile(r"\[TODO(?::|\])", re.IGNORECASE), "scaffold [TODO] marker"), + (re.compile(r"\b(?:TODO|FIXME|TBD)\b", re.IGNORECASE), "unfinished work marker"), + ( + re.compile(r"\b(?:author@example\.com|docs\.example\.com|example\.com)\b", re.IGNORECASE), + "example domain or email", + ), + ( + re.compile( + r"\b(?:" + r"Plugin Display Name|Brief plugin description|Short description for subtitle|" + r"Long description for details page|Marketplace Display Name|marketplace-name|" + r"keyword1|keyword2" + r")\b", + re.IGNORECASE, + ), + "scaffold sample text", + ), +] +WARN_PLACEHOLDER_PATTERNS = [ + (re.compile(r"\blorem ipsum\b", re.IGNORECASE), "filler copy"), + (re.compile(r"\bchange me\b", re.IGNORECASE), "unfinished change-me text"), +] +SCAFFOLD_DEFAULT_PROMPTS = { + "Summarize my inbox and draft replies for me.", + "Find open bugs and turn them into tickets.", + "Review today's meetings and flag gaps.", +} +SCAFFOLD_BRAND_COLOR = "#3B82F6" +REQUIRED_TOP_LEVEL_STRINGS = [ + "name", + "version", + "description", + "license", +] +REQUIRED_INTERFACE_STRINGS = [ + "displayName", + "shortDescription", + "longDescription", + "developerName", + "category", +] +RECOMMENDED_INTERFACE_STRINGS = [ + "privacyPolicyURL", + "termsOfServiceURL", +] +OPTIONAL_PATH_FIELDS = { + "skills": "directory", + "hooks": "file", + "mcpServers": "file", + "apps": "file", +} + + +@dataclass(frozen=True) +class Issue: + severity: str + path: Path | None + message: str + + +def add_issue(issues: list[Issue], severity: str, path: Path | None, message: str) -> None: + issues.append(Issue(severity, path, message)) + + +def rel_path(path: Path | None, base: Path) -> str: + if path is None: + return "-" + try: + return str(path.relative_to(base)) + except ValueError: + return str(path) + + +def normalize_plugin_name(plugin_name: str) -> str: + normalized = plugin_name.strip().lower() + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + normalized = normalized.strip("-") + normalized = re.sub(r"-{2,}", "-", normalized) + return normalized + + +def has_error_placeholder(text: str) -> tuple[str, str] | None: + for pattern, label in ERROR_PLACEHOLDER_PATTERNS: + match = pattern.search(text) + if match: + return label, match.group(0) + return None + + +def has_warn_placeholder(text: str) -> tuple[str, str] | None: + for pattern, label in WARN_PLACEHOLDER_PATTERNS: + match = pattern.search(text) + if match: + return label, match.group(0) + return None + + +def strip_scaffold_todo(value: str) -> str: + stripped = value.strip() + match = re.fullmatch(r"\[TODO:\s*(.*?)\s*\]", stripped, flags=re.IGNORECASE) + if match: + return match.group(1).strip() + return stripped + + +def is_non_empty_string(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def is_plugin_relative_path(value: str) -> bool: + return value.startswith("./") and not Path(value).is_absolute() and ".." not in Path(value).parts + + +def load_json(path: Path, issues: list[Issue]) -> Any | None: + try: + with path.open() as handle: + return json.load(handle) + except FileNotFoundError: + add_issue(issues, "ERROR", path, "File does not exist.") + except json.JSONDecodeError as exc: + add_issue(issues, "ERROR", path, f"Invalid JSON: {exc.msg} at line {exc.lineno}.") + return None + + +def find_plugin_root(input_path: Path) -> Path: + path = input_path.expanduser().resolve() + if path.is_file() and path.name == "plugin.json" and path.parent.name == ".codex-plugin": + return path.parent.parent + if path.is_dir() and (path / ".codex-plugin" / "plugin.json").exists(): + return path + raise ValueError( + f"{input_path} is not a plugin root and is not a .codex-plugin/plugin.json file." + ) + + +def resolve_plugin_path(plugin_root: Path, value: str) -> Path: + return (plugin_root / value).resolve() + + +def check_string_field( + payload: dict[str, Any], + field: str, + issues: list[Issue], + manifest_path: Path, + context: str, +) -> None: + value = payload.get(field) + if not is_non_empty_string(value): + add_issue(issues, "ERROR", manifest_path, f"{context}.{field} must be a non-empty string.") + return + placeholder = has_error_placeholder(value) + if placeholder: + label, match = placeholder + add_issue( + issues, + "ERROR", + manifest_path, + f"{context}.{field} contains {label}: {match!r}.", + ) + + +def check_url_field( + payload: dict[str, Any], + field: str, + issues: list[Issue], + manifest_path: Path, + context: str, + required: bool, +) -> None: + value = payload.get(field) + if value is None and not required: + add_issue(issues, "WARN", manifest_path, f"{context}.{field} is recommended.") + return + check_string_field(payload, field, issues, manifest_path, context) + if not is_non_empty_string(value): + return + if has_error_placeholder(value): + return + if not re.match(r"^https?://", value): + add_issue(issues, "ERROR", manifest_path, f"{context}.{field} must be an http(s) URL.") + + +def check_asset_path( + plugin_root: Path, + value: Any, + label: str, + issues: list[Issue], + manifest_path: Path, + supported_suffixes: set[str] | None = None, +) -> None: + supported_suffixes = supported_suffixes or SUPPORTED_IMAGE_SUFFIXES + if not is_non_empty_string(value): + add_issue(issues, "ERROR", manifest_path, f"interface.{label} must be a relative asset path.") + return + placeholder = has_error_placeholder(value) + if placeholder: + label_text, match = placeholder + add_issue( + issues, + "ERROR", + manifest_path, + f"interface.{label} contains {label_text}: {match!r}.", + ) + return + if not is_plugin_relative_path(value): + add_issue( + issues, + "ERROR", + manifest_path, + f"interface.{label} must be a relative plugin path starting with './'.", + ) + return + asset_path = resolve_plugin_path(plugin_root, value) + if asset_path.suffix.lower() not in supported_suffixes: + asset_kind = "PNG image file" if supported_suffixes == {".png"} else "supported image file" + add_issue( + issues, + "ERROR", + manifest_path, + f"interface.{label} must point to a {asset_kind}.", + ) + if not asset_path.exists(): + add_issue(issues, "ERROR", asset_path, f"Missing interface.{label} asset.") + elif asset_path.is_file() and asset_path.stat().st_size == 0: + add_issue(issues, "ERROR", asset_path, f"interface.{label} asset is empty.") + + +def check_prompt_list(prompts: Any, issues: list[Issue], manifest_path: Path) -> None: + if not isinstance(prompts, list) or not prompts: + add_issue(issues, "ERROR", manifest_path, "interface.defaultPrompt must be a non-empty array.") + return + if len(prompts) > 3: + add_issue(issues, "ERROR", manifest_path, "interface.defaultPrompt must contain at most 3 prompts.") + + seen: set[str] = set() + for index, prompt in enumerate(prompts, start=1): + if not is_non_empty_string(prompt): + add_issue( + issues, + "ERROR", + manifest_path, + f"interface.defaultPrompt[{index}] must be a non-empty string.", + ) + continue + cleaned_prompt = strip_scaffold_todo(prompt) + placeholder = has_error_placeholder(prompt) + if placeholder: + label, match = placeholder + add_issue( + issues, + "ERROR", + manifest_path, + f"interface.defaultPrompt[{index}] contains {label}: {match!r}.", + ) + continue + if cleaned_prompt in SCAFFOLD_DEFAULT_PROMPTS: + add_issue( + issues, + "ERROR", + manifest_path, + f"interface.defaultPrompt[{index}] still matches a scaffold sample prompt.", + ) + if len(prompt) > 128: + add_issue( + issues, + "ERROR", + manifest_path, + f"interface.defaultPrompt[{index}] is {len(prompt)} characters; max is 128.", + ) + normalized = " ".join(cleaned_prompt.lower().split()) + if normalized in seen: + add_issue( + issues, + "WARN", + manifest_path, + f"interface.defaultPrompt[{index}] duplicates another starter prompt.", + ) + seen.add(normalized) + + +def check_plugin_json(plugin_root: Path, issues: list[Issue]) -> dict[str, Any] | None: + manifest_path = plugin_root / ".codex-plugin" / "plugin.json" + manifest = load_json(manifest_path, issues) + if manifest is None: + return None + if not isinstance(manifest, dict): + add_issue(issues, "ERROR", manifest_path, "plugin.json must contain a JSON object.") + return None + + for field in REQUIRED_TOP_LEVEL_STRINGS: + check_string_field(manifest, field, issues, manifest_path, "plugin") + + plugin_name = manifest.get("name") + if is_non_empty_string(plugin_name): + if normalize_plugin_name(plugin_name) != plugin_name: + add_issue( + issues, + "ERROR", + manifest_path, + "plugin.name must be lower-case hyphen-case.", + ) + if len(plugin_name) > MAX_PLUGIN_NAME_LENGTH: + add_issue( + issues, + "ERROR", + manifest_path, + f"plugin.name must be {MAX_PLUGIN_NAME_LENGTH} characters or fewer.", + ) + if plugin_root.name != plugin_name: + add_issue( + issues, + "ERROR", + manifest_path, + f"plugin.name must match the plugin folder name '{plugin_root.name}'.", + ) + + version = manifest.get("version") + if ( + is_non_empty_string(version) + and not has_error_placeholder(version) + and not re.match( + r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$", + version, + ) + ): + add_issue(issues, "ERROR", manifest_path, "plugin.version must be a semantic version.") + + author = manifest.get("author") + if not isinstance(author, dict): + add_issue(issues, "ERROR", manifest_path, "plugin.author must be an object.") + else: + for field in ["name", "email"]: + check_string_field(author, field, issues, manifest_path, "plugin.author") + email = author.get("email") + if is_non_empty_string(email) and "@" not in email: + add_issue(issues, "ERROR", manifest_path, "plugin.author.email must be an email address.") + check_url_field(author, "url", issues, manifest_path, "plugin.author", required=True) + + for field in ["homepage", "repository"]: + check_url_field(manifest, field, issues, manifest_path, "plugin", required=True) + + keywords = manifest.get("keywords") + if not isinstance(keywords, list) or not keywords: + add_issue(issues, "ERROR", manifest_path, "plugin.keywords must be a non-empty array.") + else: + for index, keyword in enumerate(keywords, start=1): + if not is_non_empty_string(keyword): + add_issue( + issues, + "ERROR", + manifest_path, + f"plugin.keywords[{index}] must be a non-empty string.", + ) + elif has_error_placeholder(keyword): + add_issue( + issues, + "ERROR", + manifest_path, + f"plugin.keywords[{index}] contains placeholder text.", + ) + + for field, expected_type in OPTIONAL_PATH_FIELDS.items(): + value = manifest.get(field) + if value is None: + continue + if not is_non_empty_string(value): + add_issue(issues, "ERROR", manifest_path, f"plugin.{field} must be a relative path string.") + continue + placeholder = has_error_placeholder(value) + if placeholder: + label, match = placeholder + add_issue( + issues, + "ERROR", + manifest_path, + f"plugin.{field} contains {label}: {match!r}.", + ) + continue + if not is_plugin_relative_path(value): + add_issue( + issues, + "ERROR", + manifest_path, + f"plugin.{field} must be a relative plugin path starting with './'.", + ) + continue + resolved = resolve_plugin_path(plugin_root, value) + if not resolved.exists(): + add_issue(issues, "ERROR", resolved, f"plugin.{field} points to a missing path.") + elif expected_type == "directory" and not resolved.is_dir(): + add_issue(issues, "ERROR", resolved, f"plugin.{field} must point to a directory.") + elif expected_type == "file" and not resolved.is_file(): + add_issue(issues, "ERROR", resolved, f"plugin.{field} must point to a file.") + + interface = manifest.get("interface") + if not isinstance(interface, dict): + add_issue(issues, "ERROR", manifest_path, "plugin.interface must be an object.") + return manifest + + for field in REQUIRED_INTERFACE_STRINGS: + check_string_field(interface, field, issues, manifest_path, "interface") + check_url_field(interface, "websiteURL", issues, manifest_path, "interface", required=True) + for field in RECOMMENDED_INTERFACE_STRINGS: + check_url_field(interface, field, issues, manifest_path, "interface", required=False) + + capabilities = interface.get("capabilities") + if not isinstance(capabilities, list) or not capabilities: + add_issue(issues, "ERROR", manifest_path, "interface.capabilities must be a non-empty array.") + else: + for capability in capabilities: + if not is_non_empty_string(capability): + add_issue(issues, "ERROR", manifest_path, "interface.capabilities must contain strings.") + + brand_color = interface.get("brandColor") + if not is_non_empty_string(brand_color): + add_issue(issues, "ERROR", manifest_path, "interface.brandColor must be a non-empty string.") + else: + placeholder = has_error_placeholder(brand_color) + if placeholder: + label, match = placeholder + add_issue( + issues, + "ERROR", + manifest_path, + f"interface.brandColor contains {label}: {match!r}.", + ) + elif not re.match(r"^#[0-9A-Fa-f]{6}$", brand_color): + add_issue( + issues, + "ERROR", + manifest_path, + "interface.brandColor must be a 6-digit hex color like #3B82F6.", + ) + elif brand_color.upper() == SCAFFOLD_BRAND_COLOR: + add_issue( + issues, + "WARN", + manifest_path, + "interface.brandColor is the scaffold default; confirm it is intentional.", + ) + + check_prompt_list(interface.get("defaultPrompt"), issues, manifest_path) + check_asset_path(plugin_root, interface.get("composerIcon"), "composerIcon", issues, manifest_path) + check_asset_path(plugin_root, interface.get("logo"), "logo", issues, manifest_path) + + screenshots = interface.get("screenshots") + if screenshots is None: + pass + elif not isinstance(screenshots, list): + add_issue(issues, "ERROR", manifest_path, "interface.screenshots must be an array.") + else: + for index, screenshot in enumerate(screenshots, start=1): + check_asset_path( + plugin_root, + screenshot, + f"screenshots[{index}]", + issues, + manifest_path, + supported_suffixes={".png"}, + ) + + developer_name = interface.get("developerName") + if developer_name != "OpenAI" and not ( + isinstance(developer_name, str) and has_error_placeholder(developer_name) + ): + for field in ["privacyPolicyURL", "termsOfServiceURL"]: + value = interface.get(field) + if is_non_empty_string(value) and "openai.com" in value: + add_issue( + issues, + "WARN", + manifest_path, + f"interface.{field} points to OpenAI while developerName is {developer_name!r}.", + ) + + return manifest + + +def check_json_component_file( + plugin_root: Path, + manifest: dict[str, Any], + manifest_field: str, + required_key: str, + issues: list[Issue], +) -> None: + value = manifest.get(manifest_field) + if not is_non_empty_string(value) or not is_plugin_relative_path(value): + return + path = resolve_plugin_path(plugin_root, value) + if not path.exists(): + return + payload = load_json(path, issues) + if payload is None: + return + if not isinstance(payload, dict): + add_issue(issues, "ERROR", path, f"{path.name} must contain a JSON object.") + return + section = payload.get(required_key) + if not isinstance(section, dict): + add_issue(issues, "ERROR", path, f"{path.name} must contain a '{required_key}' object.") + elif not section: + add_issue( + issues, + "ERROR", + path, + f"{path.name} has an empty '{required_key}' object; fill it or remove plugin.{manifest_field}.", + ) + + +def parse_skill_frontmatter(path: Path) -> dict[str, str] | None: + text = path.read_text(errors="replace") + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return None + fields: dict[str, str] = {} + for line in lines[1:]: + if line.strip() == "---": + return fields + match = re.match(r"^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$", line) + if match: + fields[match.group(1)] = match.group(2).strip().strip("\"'") + return None + + +def parse_openai_interface(path: Path) -> dict[str, str] | None: + text = path.read_text(errors="replace") + in_interface = False + fields: dict[str, str] = {} + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + if re.match(r"^interface:\s*$", line): + in_interface = True + continue + if in_interface and not line.startswith((" ", "\t")): + break + if in_interface: + match = re.match(r"^\s+([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$", line) + if match: + value = match.group(2).strip().strip("\"'") + fields[match.group(1)] = value + return fields if in_interface else None + + +def validate_openai_yaml( + openai_path: Path, + base_path: Path, + issues: list[Issue], + default_prompt_required: bool, +) -> None: + interface = parse_openai_interface(openai_path) + if interface is None: + add_issue(issues, "ERROR", openai_path, "openai.yaml must contain an interface block.") + return + + for field in ["display_name", "short_description"]: + if not is_non_empty_string(interface.get(field)): + add_issue(issues, "ERROR", openai_path, f"interface.{field} must be set.") + default_prompt = interface.get("default_prompt") + if not is_non_empty_string(default_prompt): + severity = "ERROR" if default_prompt_required else "WARN" + add_issue(issues, severity, openai_path, "interface.default_prompt should be set.") + + for icon_field in ["icon_small", "icon_large"]: + icon_value = interface.get(icon_field) + if not is_non_empty_string(icon_value): + add_issue(issues, "WARN", openai_path, f"interface.{icon_field} is recommended.") + continue + if not is_plugin_relative_path(icon_value): + add_issue( + issues, + "ERROR", + openai_path, + f"interface.{icon_field} must be a relative path starting with './'.", + ) + continue + icon_path = (base_path / icon_value).resolve() + if not icon_path.exists(): + add_issue(issues, "ERROR", icon_path, f"Missing openai.yaml {icon_field} asset.") + elif icon_path.suffix.lower() not in SUPPORTED_IMAGE_SUFFIXES: + add_issue(issues, "ERROR", icon_path, f"openai.yaml {icon_field} is not a supported image.") + + +def find_skill_dirs(plugin_root: Path, manifest: dict[str, Any] | None) -> list[Path]: + skills_root = plugin_root / "skills" + if manifest and is_non_empty_string(manifest.get("skills")) and is_plugin_relative_path(manifest["skills"]): + skills_root = resolve_plugin_path(plugin_root, manifest["skills"]) + if not skills_root.is_dir(): + return [] + return sorted({path.parent for path in skills_root.rglob("SKILL.md")}) + + +def check_skills_and_openai_yaml( + plugin_root: Path, + manifest: dict[str, Any] | None, + issues: list[Issue], + allow_missing_skill_openai: bool, +) -> None: + checked_openai_paths: set[Path] = set() + skill_dirs = find_skill_dirs(plugin_root, manifest) + + for skill_dir in skill_dirs: + skill_md = skill_dir / "SKILL.md" + frontmatter = parse_skill_frontmatter(skill_md) + if frontmatter is None: + add_issue(issues, "ERROR", skill_md, "SKILL.md must start with YAML frontmatter.") + else: + for field in ["name", "description"]: + if not is_non_empty_string(frontmatter.get(field)): + add_issue(issues, "ERROR", skill_md, f"frontmatter.{field} must be set.") + skill_name = frontmatter.get("name") + if is_non_empty_string(skill_name) and normalize_plugin_name(skill_name) != skill_dir.name: + add_issue( + issues, + "WARN", + skill_md, + f"frontmatter.name {skill_name!r} does not match folder name {skill_dir.name!r}.", + ) + + openai_path = skill_dir / "agents" / "openai.yaml" + if not openai_path.exists(): + severity = "WARN" if allow_missing_skill_openai else "ERROR" + add_issue( + issues, + severity, + openai_path, + "Each skill should have agents/openai.yaml UI metadata.", + ) + else: + validate_openai_yaml( + openai_path, + skill_dir, + issues, + default_prompt_required=False, + ) + checked_openai_paths.add(openai_path.resolve()) + + for openai_path in sorted(plugin_root.rglob("agents/openai.yaml")): + if openai_path.resolve() in checked_openai_paths: + continue + base_path = openai_path.parent.parent + validate_openai_yaml( + openai_path, + base_path, + issues, + default_prompt_required=True, + ) + + if not skill_dirs and not (plugin_root / "agents" / "openai.yaml").exists(): + add_issue( + issues, + "WARN", + plugin_root, + "No skills or root agents/openai.yaml found; confirm the plugin exposes a usable entrypoint.", + ) + + +def iter_text_files(plugin_root: Path) -> list[Path]: + files: list[Path] = [] + for path in plugin_root.rglob("*"): + if not path.is_file(): + continue + if any(part in IGNORED_DIRS for part in path.parts): + continue + if path.stat().st_size > 512_000: + continue + if path.name in TEXT_FILE_NAMES or path.suffix.lower() in TEXT_SUFFIXES: + files.append(path) + return files + + +def check_placeholders(plugin_root: Path, issues: list[Issue]) -> None: + manifest_path = plugin_root / ".codex-plugin" / "plugin.json" + for path in iter_text_files(plugin_root): + if path == manifest_path: + continue + text = path.read_text(errors="replace") + error_placeholder = has_error_placeholder(text) + if error_placeholder: + label, match = error_placeholder + add_issue(issues, "ERROR", path, f"Contains {label}: {match!r}.") + continue + warn_placeholder = has_warn_placeholder(text) + if warn_placeholder: + label, match = warn_placeholder + add_issue(issues, "WARN", path, f"Contains possible {label}: {match!r}.") + + +def discover_repo_marketplace(plugin_root: Path) -> Path | None: + for parent in plugin_root.parents: + candidate = parent / ".agents" / "plugins" / "marketplace.json" + if candidate.exists(): + return candidate + return None + + +def check_marketplace( + marketplace_path: Path, + plugin_name: str, + issues: list[Issue], + require_marketplace: bool, +) -> None: + payload = load_json(marketplace_path, issues) + if payload is None: + return + if not isinstance(payload, dict): + add_issue(issues, "ERROR", marketplace_path, "marketplace.json must contain a JSON object.") + return + plugins = payload.get("plugins") + if not isinstance(plugins, list): + add_issue(issues, "ERROR", marketplace_path, "marketplace.json plugins must be an array.") + return + + entry = next( + (item for item in plugins if isinstance(item, dict) and item.get("name") == plugin_name), + None, + ) + if entry is None: + severity = "ERROR" if require_marketplace else "WARN" + add_issue(issues, severity, marketplace_path, f"No marketplace entry found for {plugin_name!r}.") + return + + source = entry.get("source") + if not isinstance(source, dict): + add_issue(issues, "ERROR", marketplace_path, f"Marketplace entry {plugin_name!r} needs source.") + else: + if source.get("source") != "local": + add_issue( + issues, + "ERROR", + marketplace_path, + f"Marketplace entry {plugin_name!r} source.source must be 'local'.", + ) + expected_path = f"./plugins/{plugin_name}" + if source.get("path") != expected_path: + add_issue( + issues, + "ERROR", + marketplace_path, + f"Marketplace entry {plugin_name!r} source.path must be {expected_path!r}.", + ) + + policy = entry.get("policy") + if not isinstance(policy, dict): + add_issue(issues, "ERROR", marketplace_path, f"Marketplace entry {plugin_name!r} needs policy.") + else: + if policy.get("installation") not in VALID_INSTALL_POLICIES: + add_issue( + issues, + "ERROR", + marketplace_path, + f"Marketplace entry {plugin_name!r} has invalid policy.installation.", + ) + if policy.get("authentication") not in VALID_AUTH_POLICIES: + add_issue( + issues, + "ERROR", + marketplace_path, + f"Marketplace entry {plugin_name!r} has invalid policy.authentication.", + ) + if not is_non_empty_string(entry.get("category")): + add_issue(issues, "ERROR", marketplace_path, f"Marketplace entry {plugin_name!r} needs category.") + + +def print_report(plugin_root: Path, issues: list[Issue]) -> None: + errors = [issue for issue in issues if issue.severity == "ERROR"] + warnings = [issue for issue in issues if issue.severity == "WARN"] + + print(f"Plugin readiness report: {plugin_root}") + if not issues: + print("OK: no blockers or warnings found.") + return + + for severity in ["ERROR", "WARN"]: + matching = [issue for issue in issues if issue.severity == severity] + if not matching: + continue + print(f"\n{severity}S ({len(matching)}):") + for issue in matching: + print(f"- {rel_path(issue.path, plugin_root)}: {issue.message}") + + print(f"\nSummary: {len(errors)} error(s), {len(warnings)} warning(s).") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Check whether a Codex plugin is ready to publish or share." + ) + parser.add_argument("plugin_path", help="Plugin root or .codex-plugin/plugin.json path") + parser.add_argument( + "--marketplace-path", + help="Optional marketplace.json path to validate against this plugin.", + ) + parser.add_argument( + "--require-marketplace", + action="store_true", + help="Fail if the selected or auto-discovered marketplace lacks this plugin entry.", + ) + parser.add_argument( + "--allow-missing-skill-openai", + "--allow-missing-openai-yaml", + dest="allow_missing_skill_openai", + action="store_true", + help="Warn instead of failing when a skill lacks agents/openai.yaml.", + ) + parser.add_argument( + "--strict-warnings", + action="store_true", + help="Exit non-zero when warnings are present.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + issues: list[Issue] = [] + try: + plugin_root = find_plugin_root(Path(args.plugin_path)) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + manifest = check_plugin_json(plugin_root, issues) + if manifest is not None: + check_json_component_file(plugin_root, manifest, "apps", "apps", issues) + check_json_component_file(plugin_root, manifest, "mcpServers", "mcpServers", issues) + + check_skills_and_openai_yaml( + plugin_root, + manifest, + issues, + allow_missing_skill_openai=args.allow_missing_skill_openai, + ) + check_placeholders(plugin_root, issues) + + plugin_name = manifest.get("name") if isinstance(manifest, dict) else None + if is_non_empty_string(plugin_name): + if args.marketplace_path: + marketplace_path = Path(args.marketplace_path).expanduser().resolve() + check_marketplace(marketplace_path, plugin_name, issues, args.require_marketplace) + else: + marketplace_path = discover_repo_marketplace(plugin_root) + if marketplace_path is not None: + check_marketplace( + marketplace_path, + plugin_name, + issues, + require_marketplace=args.require_marketplace, + ) + + print_report(plugin_root, issues) + has_errors = any(issue.severity == "ERROR" for issue in issues) + has_warnings = any(issue.severity == "WARN" for issue in issues) + return 1 if has_errors or (args.strict_warnings and has_warnings) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/plugin-creator/scripts/create_basic_plugin.py b/.agents/skills/plugin-creator/scripts/create_basic_plugin.py index dcb24562..8f3661e7 100755 --- a/.agents/skills/plugin-creator/scripts/create_basic_plugin.py +++ b/.agents/skills/plugin-creator/scripts/create_basic_plugin.py @@ -6,6 +6,7 @@ import argparse import json import re +import shlex from pathlib import Path from typing import Any @@ -183,6 +184,24 @@ def create_stub_file(path: Path, payload: dict, force: bool) -> None: handle.write("\n") +def print_readiness_checklist(plugin_root: Path, marketplace_path: Path | None) -> None: + readiness_script = Path(__file__).with_name("check_plugin_readiness.py") + command = ["python3", str(readiness_script), str(plugin_root)] + if marketplace_path is not None: + command.extend(["--marketplace-path", str(marketplace_path)]) + + print() + print("Before treating this plugin as finished:") + print("- Replace every [TODO: ...] placeholder in plugin.json and companion files.") + print("- Add real logo/composer icon assets and any screenshots referenced by plugin.json.") + print("- Choose an intentional interface.brandColor, not just the scaffold default.") + print("- Rewrite interface.defaultPrompt with 1-3 real hero prompts for this plugin.") + print("- Add agents/openai.yaml metadata for every SKILL.md you create.") + print("- Keep .app.json, .mcp.json, hooks, and skills paths in sync with plugin.json.") + print("Run the readiness check:") + print(" " + " ".join(shlex.quote(part) for part in command)) + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Create a plugin skeleton with placeholder plugin.json." @@ -280,6 +299,7 @@ def main() -> None: args.force, ) + marketplace_path = None if args.with_marketplace: marketplace_path = Path(args.marketplace_path).expanduser().resolve() update_marketplace_json( @@ -295,6 +315,7 @@ def main() -> None: print(f"plugin manifest: {plugin_json_path}") if args.with_marketplace: print(f"marketplace manifest: {marketplace_path}") + print_readiness_checklist(plugin_root, marketplace_path) if __name__ == "__main__": From 55d14baf47c98a54087c0eaa764e9278ff2dd8ca Mon Sep 17 00:00:00 2001 From: Ashwin Mathews Date: Wed, 13 May 2026 22:48:54 -0700 Subject: [PATCH 2/6] default to no flag --- .agents/skills/plugin-creator/SKILL.md | 11 ++++--- .../scripts/check_plugin_readiness.py | 32 +++++++++++-------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/.agents/skills/plugin-creator/SKILL.md b/.agents/skills/plugin-creator/SKILL.md index 68cb25cb..56c3133a 100644 --- a/.agents/skills/plugin-creator/SKILL.md +++ b/.agents/skills/plugin-creator/SKILL.md @@ -61,7 +61,8 @@ python3 .agents/skills/plugin-creator/scripts/check_plugin_readiness.py ``` -Add `--require-marketplace` when the plugin must appear in that marketplace before it is considered done. +Repo plugins under `/plugins/` are checked against `/.agents/plugins/marketplace.json` +automatically when that marketplace exists. ## What this skill creates @@ -180,15 +181,15 @@ The readiness check covers: to allow missing metadata and the checker is run with `--allow-missing-openai-yaml`. - Existing `agents/openai.yaml` files include `interface.display_name` and `interface.short_description`; icon paths are checked when present. -- The selected or nearest repo marketplace entry has the correct local source path, policy fields, - and category when a marketplace is part of the workflow. +- Repo plugins under `/plugins/` are included in `/.agents/plugins/marketplace.json` + with the correct local source path, policy fields, and category. +- A marketplace passed with `--marketplace-path` has a matching, valid entry for the plugin. Useful options: ```bash python3 .agents/skills/plugin-creator/scripts/check_plugin_readiness.py \ - --marketplace-path ./.agents/plugins/marketplace.json \ - --require-marketplace + --marketplace-path ./.agents/plugins/marketplace.json ``` ```bash diff --git a/.agents/skills/plugin-creator/scripts/check_plugin_readiness.py b/.agents/skills/plugin-creator/scripts/check_plugin_readiness.py index 82b420d1..0bb34baa 100644 --- a/.agents/skills/plugin-creator/scripts/check_plugin_readiness.py +++ b/.agents/skills/plugin-creator/scripts/check_plugin_readiness.py @@ -160,6 +160,14 @@ def is_plugin_relative_path(value: str) -> bool: return value.startswith("./") and not Path(value).is_absolute() and ".." not in Path(value).parts +def path_is_relative_to(path: Path, base: Path) -> bool: + try: + path.relative_to(base) + except ValueError: + return False + return True + + def load_json(path: Path, issues: list[Issue]) -> Any | None: try: with path.open() as handle: @@ -735,10 +743,11 @@ def check_placeholders(plugin_root: Path, issues: list[Issue]) -> None: add_issue(issues, "WARN", path, f"Contains possible {label}: {match!r}.") -def discover_repo_marketplace(plugin_root: Path) -> Path | None: +def discover_repo_plugin_marketplace(plugin_root: Path) -> Path | None: for parent in plugin_root.parents: candidate = parent / ".agents" / "plugins" / "marketplace.json" - if candidate.exists(): + plugins_dir = parent / "plugins" + if candidate.exists() and path_is_relative_to(plugin_root, plugins_dir): return candidate return None @@ -747,7 +756,6 @@ def check_marketplace( marketplace_path: Path, plugin_name: str, issues: list[Issue], - require_marketplace: bool, ) -> None: payload = load_json(marketplace_path, issues) if payload is None: @@ -765,8 +773,7 @@ def check_marketplace( None, ) if entry is None: - severity = "ERROR" if require_marketplace else "WARN" - add_issue(issues, severity, marketplace_path, f"No marketplace entry found for {plugin_name!r}.") + add_issue(issues, "ERROR", marketplace_path, f"No marketplace entry found for {plugin_name!r}.") return source = entry.get("source") @@ -838,12 +845,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument("plugin_path", help="Plugin root or .codex-plugin/plugin.json path") parser.add_argument( "--marketplace-path", - help="Optional marketplace.json path to validate against this plugin.", - ) - parser.add_argument( - "--require-marketplace", - action="store_true", - help="Fail if the selected or auto-discovered marketplace lacks this plugin entry.", + help=( + "Optional marketplace.json path to validate against this plugin. " + "Repo plugins under /plugins are checked against /.agents/plugins/marketplace.json automatically." + ), ) parser.add_argument( "--allow-missing-skill-openai", @@ -886,15 +891,14 @@ def main() -> int: if is_non_empty_string(plugin_name): if args.marketplace_path: marketplace_path = Path(args.marketplace_path).expanduser().resolve() - check_marketplace(marketplace_path, plugin_name, issues, args.require_marketplace) + check_marketplace(marketplace_path, plugin_name, issues) else: - marketplace_path = discover_repo_marketplace(plugin_root) + marketplace_path = discover_repo_plugin_marketplace(plugin_root) if marketplace_path is not None: check_marketplace( marketplace_path, plugin_name, issues, - require_marketplace=args.require_marketplace, ) print_report(plugin_root, issues) From c9073b5243dea595c84feaa17f6b9d8fb1ff0ed1 Mon Sep 17 00:00:00 2001 From: Ashwin Mathews Date: Wed, 13 May 2026 23:10:49 -0700 Subject: [PATCH 3/6] docs(plugin-creator): add remaining work checklist guidance --- .agents/skills/plugin-creator/SKILL.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.agents/skills/plugin-creator/SKILL.md b/.agents/skills/plugin-creator/SKILL.md index 56c3133a..611bda48 100644 --- a/.agents/skills/plugin-creator/SKILL.md +++ b/.agents/skills/plugin-creator/SKILL.md @@ -197,6 +197,21 @@ python3 .agents/skills/plugin-creator/scripts/check_plugin_readiness.py Date: Wed, 13 May 2026 23:13:27 -0700 Subject: [PATCH 4/6] docs(plugin-creator): offer interactive readiness completion --- .agents/skills/plugin-creator/SKILL.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.agents/skills/plugin-creator/SKILL.md b/.agents/skills/plugin-creator/SKILL.md index 611bda48..c0e44648 100644 --- a/.agents/skills/plugin-creator/SKILL.md +++ b/.agents/skills/plugin-creator/SKILL.md @@ -212,6 +212,24 @@ Worth reviewing: - [ ] Add `agents/openai.yaml` metadata for each skill, or explicitly accept the warning. ``` +After showing the checklist, offer to help complete it interactively. If the user agrees, work +through the checklist in small batches and rerun the readiness check after each pass. + +Recommended order: + +1. Identity and manifest copy: display name, description, author, homepage, repository, license, + keywords, category, and capabilities. +2. Interface metadata: brand color, starter prompts, website/privacy/terms URLs, and developer name. +3. Assets: logo, composer icon, and screenshots. Ask the user to provide assets or permission to + create placeholders only when placeholders are acceptable for the current test. +4. Integration files: skills, hooks, `.app.json`, `.mcp.json`, and `agents/openai.yaml` metadata. +5. Marketplace: add or update the selected marketplace entry when the plugin should be visible in + Codex. + +Ask for one batch at a time instead of asking every checklist question at once. Use reasonable +defaults only for low-risk implementation details. Do not invent publisher identity, legal URLs, +auth policy, marketplace product gating, or user-provided assets without confirmation. + ## Required behavior - Outer folder name and `plugin.json` `"name"` are always the same normalized plugin name. @@ -223,6 +241,8 @@ Worth reviewing: until the user explicitly accepts the remaining errors. - When readiness issues remain, provide the user a concise checklist of the remaining items to finish, grouped into blocking errors and review-worthy warnings. +- Offer to walk through the remaining checklist interactively; if the user agrees, ask for one batch + of values at a time, apply the answers, and rerun the readiness check after each pass. - If creating files inside an existing plugin path, use `--force` only when overwrite is intentional. - Preserve any existing marketplace `interface.displayName`. - When generating marketplace entries, always write `policy.installation`, `policy.authentication`, and `category` even if their values are defaults. From 09b472a9ada9ec83bcd53c90a6f895d036dd2fb4 Mon Sep 17 00:00:00 2001 From: Ashwin Mathews Date: Wed, 13 May 2026 23:19:25 -0700 Subject: [PATCH 5/6] docs(plugin-creator): forbid dummy readiness values --- .agents/skills/plugin-creator/SKILL.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.agents/skills/plugin-creator/SKILL.md b/.agents/skills/plugin-creator/SKILL.md index c0e44648..cbe6f79f 100644 --- a/.agents/skills/plugin-creator/SKILL.md +++ b/.agents/skills/plugin-creator/SKILL.md @@ -220,8 +220,8 @@ Recommended order: 1. Identity and manifest copy: display name, description, author, homepage, repository, license, keywords, category, and capabilities. 2. Interface metadata: brand color, starter prompts, website/privacy/terms URLs, and developer name. -3. Assets: logo, composer icon, and screenshots. Ask the user to provide assets or permission to - create placeholders only when placeholders are acceptable for the current test. +3. Assets: logo, composer icon, and screenshots. Ask the user to provide real assets or explicitly + approve generating real replacement assets. 4. Integration files: skills, hooks, `.app.json`, `.mcp.json`, and `agents/openai.yaml` metadata. 5. Marketplace: add or update the selected marketplace entry when the plugin should be visible in Codex. @@ -230,6 +230,15 @@ Ask for one batch at a time instead of asking every checklist question at once. defaults only for low-risk implementation details. Do not invent publisher identity, legal URLs, auth policy, marketplace product gating, or user-provided assets without confirmation. +Do not satisfy readiness checks by replacing placeholders with dummy values. This applies to all +manifest copy, prompts, logos, icons, screenshots, marketplace metadata, integration names, and skill +descriptions. If real values are unknown, ask the user for them or leave the checklist item open. For +starter prompts, use confirmed plugin use cases; do not write generic prompts just to remove +`[TODO: ...]`. For logos and icons, use real supplied assets or generated assets the user explicitly +approves as final, not blank files, generic stand-ins, or fake brand marks. Only create dummy values +when the user explicitly says they are building a test fixture or intentionally wants non-final +placeholder content, and label the result as not production-ready. + ## Required behavior - Outer folder name and `plugin.json` `"name"` are always the same normalized plugin name. @@ -243,6 +252,8 @@ auth policy, marketplace product gating, or user-provided assets without confirm grouped into blocking errors and review-worthy warnings. - Offer to walk through the remaining checklist interactively; if the user agrees, ask for one batch of values at a time, apply the answers, and rerun the readiness check after each pass. +- Do not replace placeholders with dummy or generic values to make the checker pass. Ask for real + values/assets, generate final-quality assets only with user approval, or leave the item unresolved. - If creating files inside an existing plugin path, use `--force` only when overwrite is intentional. - Preserve any existing marketplace `interface.displayName`. - When generating marketplace entries, always write `policy.installation`, `policy.authentication`, and `category` even if their values are defaults. From 3d2ed6d1903ff9510380eb9389d7804c7c979347 Mon Sep 17 00:00:00 2001 From: Ashwin Mathews Date: Wed, 13 May 2026 23:30:08 -0700 Subject: [PATCH 6/6] docs(plugin-creator): ask next readiness question --- .agents/skills/plugin-creator/SKILL.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.agents/skills/plugin-creator/SKILL.md b/.agents/skills/plugin-creator/SKILL.md index cbe6f79f..16432722 100644 --- a/.agents/skills/plugin-creator/SKILL.md +++ b/.agents/skills/plugin-creator/SKILL.md @@ -223,13 +223,20 @@ Recommended order: 3. Assets: logo, composer icon, and screenshots. Ask the user to provide real assets or explicitly approve generating real replacement assets. 4. Integration files: skills, hooks, `.app.json`, `.mcp.json`, and `agents/openai.yaml` metadata. -5. Marketplace: add or update the selected marketplace entry when the plugin should be visible in - Codex. +5. Marketplace: infer from location. Repo plugins under `/plugins/` should use + `/.agents/plugins/marketplace.json`; standalone plugins outside the repo should not + require marketplace work unless the user asks for it or passes `--marketplace-path`. Ask for one batch at a time instead of asking every checklist question at once. Use reasonable defaults only for low-risk implementation details. Do not invent publisher identity, legal URLs, auth policy, marketplace product gating, or user-provided assets without confirmation. +When readiness issues remain, always make the next step explicit. After the checklist, ask one clear +question for the highest-priority unresolved non-mechanical item instead of asking a broad question or +starting with marketplace details. Prefer identity/copy issues first, then prompts, assets, +integration metadata, and finally marketplace cleanup inferred from location. Example: `What should +the plugin display name, one-sentence description, and publisher name be?` + Do not satisfy readiness checks by replacing placeholders with dummy values. This applies to all manifest copy, prompts, logos, icons, screenshots, marketplace metadata, integration names, and skill descriptions. If real values are unknown, ask the user for them or leave the checklist item open. For @@ -252,6 +259,9 @@ placeholder content, and label the result as not production-ready. grouped into blocking errors and review-worthy warnings. - Offer to walk through the remaining checklist interactively; if the user agrees, ask for one batch of values at a time, apply the answers, and rerun the readiness check after each pass. +- Always ask the user one clear next question for the highest-priority unfinished readiness item. + Infer marketplace requirements from plugin location instead of making marketplace selection the + first or most important question. - Do not replace placeholders with dummy or generic values to make the checker pass. Ask for real values/assets, generate final-quality assets only with user approval, or leave the item unresolved. - If creating files inside an existing plugin path, use `--force` only when overwrite is intentional.