Compare commits

...

2 Commits

Author SHA1 Message Date
Edward Frazer
10f245ea07 docs: frame plugin validation as general guidance 2026-05-15 14:26:54 -07:00
Edward Frazer
80d9c99d9e fix: harden plugin creator sharing validation 2026-05-15 13:40:09 -07:00
5 changed files with 673 additions and 65 deletions

View File

@@ -1,6 +1,6 @@
---
name: plugin-creator
description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, and baseline placeholders you can edit before publishing or testing. Use when Codex needs to create a new personal plugin, add optional plugin structure, or generate or update personal or repo-root `.agents/plugins/marketplace.json` entries for plugin ordering and availability metadata.
description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, or generate or update marketplace entries for plugin ordering and availability metadata.
---
# Plugin Creator
@@ -17,7 +17,8 @@ description: Create and scaffold plugin directories for Codex with a required `.
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py <plugin-name>
```
2. Open `<plugin-path>/.codex-plugin/plugin.json` and replace `[TODO: ...]` placeholders.
2. Edit `<plugin-path>/.codex-plugin/plugin.json` when the request gives specific metadata.
The scaffold starts with valid defaults and must not contain `[TODO: ...]` placeholders.
3. Generate or update the personal marketplace entry when the plugin should appear in Codex UI ordering:
@@ -26,14 +27,12 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py <plugin-nam
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin --with-marketplace
```
If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said
whether the plugin is personal or shared with their team, ask before generating a marketplace entry.
When they choose the repo marketplace, use:
Only use a repo/team marketplace when the user specifically asks for that destination:
```bash
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
--path ./plugins \
--marketplace-path ./.agents/plugins/marketplace.json \
--path <repo-root>/plugins \
--marketplace-path <repo-root>/.agents/plugins/marketplace.json \
--with-marketplace
```
@@ -48,17 +47,21 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
`<parent-plugin-directory>` is the directory where the plugin folder `<plugin-name>` will be created (for example `~/code/plugins`).
5. Before handing back a generated plugin, run:
```bash
python3 .agents/skills/plugin-creator/scripts/validate_plugin.py <plugin-path>
```
## What this skill creates
- Default marketplace-backed scaffolds are personal: `~/plugins/<plugin-name>/` plus
`~/.agents/plugins/marketplace.json`.
- If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said
personal vs team, ask which marketplace to update before generating a marketplace entry.
- Creates plugin root at `/<parent-plugin-directory>/<plugin-name>/`.
- Always creates `/<parent-plugin-directory>/<plugin-name>/.codex-plugin/plugin.json`.
- Fills the manifest with the full schema shape, placeholder values, and the complete `interface` section.
- Creates or updates the selected marketplace when `--with-marketplace` is set.
- If the marketplace file does not exist yet, seed top-level `name` plus `interface.displayName` placeholders before adding the first plugin entry.
- Fills the manifest with the validated schema shape that the ingestion path accepts.
- Creates or updates `~/.agents/plugins/marketplace.json` when `--with-marketplace` is set.
- If the marketplace file does not exist yet, seed a personal marketplace root before adding the first plugin entry.
- `<plugin-name>` is normalized using skill-creator naming rules:
- `My Plugin``my-plugin`
- `My--Plugin``my-plugin`
@@ -74,8 +77,10 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
## Marketplace workflow
- Personal plugins use `~/.agents/plugins/marketplace.json`.
- Repo/team plugins use `<repo-root>/.agents/plugins/marketplace.json`.
- Personal creation defaults to `~/.agents/plugins/marketplace.json`.
- Repo/team marketplace creation is opt-in through both `--path` and `--marketplace-path`, only
when the user specifically requests it.
- In either location, the generated source path remains `./plugins/<plugin-name>`.
- Marketplace root metadata supports top-level `name` plus optional `interface.displayName`.
- Treat plugin order in `plugins[]` as render order in Codex. Append new entries unless a user explicitly asks to reorder the list.
- `displayName` belongs inside the marketplace `interface` object, not individual `plugins[]` entries.
@@ -113,15 +118,15 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
```
- Use `--force` only when intentionally replacing an existing marketplace entry for the same plugin name.
- If the selected marketplace file does not exist yet, create it with top-level `"name"`, an `"interface"` object containing `"displayName"`, and a `plugins` array, then add the new entry.
- If the target marketplace file does not exist yet, create it with top-level `"name"`, an `"interface"` object containing `"displayName"`, and a `plugins` array, then add the new entry.
- For a brand-new marketplace file, the root object should look like:
```json
{
"name": "[TODO: marketplace-name]",
"name": "personal",
"interface": {
"displayName": "[TODO: Marketplace Display Name]"
"displayName": "Personal"
},
"plugins": [
{
@@ -144,7 +149,9 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
- 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.
- Do not leave `[TODO: ...]` placeholders in plugin manifests.
- Keep `apps` and `mcpServers` out of `plugin.json` unless their companion files are actually created.
- Omit unsupported plugin manifest fields that validation rejects, including `hooks`.
- 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.
@@ -176,3 +183,9 @@ After editing `SKILL.md`, run:
```bash
python3 <path-to-skill-creator>/scripts/quick_validate.py .agents/skills/plugin-creator
```
Before handing back a generated plugin, run:
```bash
python3 .agents/skills/plugin-creator/scripts/validate_plugin.py <plugin-path>
```

View File

@@ -1,6 +1,6 @@
interface:
display_name: "Plugin Creator"
short_description: "Scaffold plugins and marketplace entries"
default_prompt: "Use $plugin-creator to scaffold a plugin with placeholder plugin.json, optional structure, and a marketplace.json entry."
default_prompt: "Use $plugin-creator to scaffold a valid plugin in the personal marketplace, then validate it before handing it back."
icon_small: "./assets/plugin-creator-small.svg"
icon_large: "./assets/plugin-creator.png"

View File

@@ -97,7 +97,8 @@
# Marketplace JSON sample spec
`marketplace.json` depends on where the plugin should live:
`marketplace.json` depends on where the plugin should live. New plugin creation defaults to the
personal marketplace unless the caller explicitly requests a repo-local destination:
- Personal plugin: `~/.agents/plugins/marketplace.json`
- Repo/team plugin: `<repo-root>/.agents/plugins/marketplace.json`
@@ -166,8 +167,25 @@
- Append new entries unless the user explicitly requests reordering.
- Replace an existing entry for the same plugin only when overwrite is intentional.
- Default new plugin creation to the personal marketplace.
- If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said
personal or team, ask which marketplace to update before creating the entry.
- Use a repo/team marketplace only when the user specifically requests that destination.
- Choose marketplace location to match the selected destination:
- Personal plugin: `~/.agents/plugins/marketplace.json`
- Repo/team plugin: `<repo-root>/.agents/plugins/marketplace.json`
### Plugin validation notes
- The validator mirrors the workspace plugin ingestion schema so generated plugins follow the same
manifest contract from the start.
- Plugin manifests must include real values for `name`, `version`, `description`,
`author.name`, and the required `interface` fields.
- `version` must use strict semver.
- `websiteURL`, `privacyPolicyURL`, and `termsOfServiceURL` must be absolute `https://` URLs when
present.
- `composerIcon`, `logo`, and `screenshots` must point to real files inside the plugin archive when
present.
- `apps` and `mcpServers` should appear in `plugin.json` only when `.app.json` and `.mcp.json`
actually exist.
- Validation rejects unsupported manifest fields such as `hooks`, so the scaffold keeps them out of
generated manifests.
- Run `scripts/validate_plugin.py <plugin-path>` before handing back a generated plugin. It adds one
intentional preflight check that rejects leftover `[TODO: ...]` placeholders.

View File

@@ -16,7 +16,8 @@ DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.js
DEFAULT_INSTALL_POLICY = "AVAILABLE"
DEFAULT_AUTH_POLICY = "ON_INSTALL"
DEFAULT_CATEGORY = "Productivity"
DEFAULT_MARKETPLACE_DISPLAY_NAME = "[TODO: Marketplace Display Name]"
DEFAULT_MARKETPLACE_NAME = "personal"
DEFAULT_MARKETPLACE_DISPLAY_NAME = "Personal"
VALID_INSTALL_POLICIES = {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"}
VALID_AUTH_POLICIES = {"ON_INSTALL", "ON_USE"}
@@ -40,49 +41,35 @@ def validate_plugin_name(plugin_name: str) -> None:
)
def build_plugin_json(plugin_name: str) -> dict:
return {
def display_name_from_plugin_name(plugin_name: str) -> str:
return " ".join(part.capitalize() for part in plugin_name.split("-"))
def build_plugin_json(plugin_name: str, *, with_mcp: bool, with_apps: bool) -> dict[str, Any]:
display_name = display_name_from_plugin_name(plugin_name)
payload: dict[str, Any] = {
"name": plugin_name,
"version": "[TODO: 1.2.0]",
"description": "[TODO: Brief plugin description]",
"version": "0.1.0",
"description": f"{display_name} plugin",
"author": {
"name": "[TODO: Author Name]",
"email": "[TODO: author@example.com]",
"url": "[TODO: https://github.com/author]",
"name": "Local developer",
},
"homepage": "[TODO: https://docs.example.com/plugin]",
"repository": "[TODO: https://github.com/author/plugin]",
"license": "[TODO: MIT]",
"keywords": ["[TODO: keyword1]", "[TODO: keyword2]"],
"skills": "[TODO: ./skills/]",
"hooks": "[TODO: ./hooks.json]",
"mcpServers": "[TODO: ./.mcp.json]",
"apps": "[TODO: ./.app.json]",
"skills": "./skills/",
"interface": {
"displayName": "[TODO: Plugin Display Name]",
"shortDescription": "[TODO: Short description for subtitle]",
"longDescription": "[TODO: Long description for details page]",
"developerName": "[TODO: OpenAI]",
"category": "[TODO: Productivity]",
"capabilities": ["[TODO: Interactive]", "[TODO: Write]"],
"websiteURL": "[TODO: https://openai.com/]",
"privacyPolicyURL": "[TODO: https://openai.com/policies/row-privacy-policy/]",
"termsOfServiceURL": "[TODO: https://openai.com/policies/row-terms-of-use/]",
"defaultPrompt": [
"[TODO: Summarize my inbox and draft replies for me.]",
"[TODO: Find open bugs and turn them into tickets.]",
"[TODO: Review today's meetings and flag gaps.]",
],
"brandColor": "[TODO: #3B82F6]",
"composerIcon": "[TODO: ./assets/icon.png]",
"logo": "[TODO: ./assets/logo.png]",
"screenshots": [
"[TODO: ./assets/screenshot1.png]",
"[TODO: ./assets/screenshot2.png]",
"[TODO: ./assets/screenshot3.png]",
],
"displayName": display_name,
"shortDescription": f"Use {display_name} in Codex.",
"longDescription": f"{display_name} adds a local Codex plugin scaffold.",
"developerName": "Local developer",
"category": DEFAULT_CATEGORY,
"capabilities": [],
"defaultPrompt": f"Help me use {display_name}.",
},
}
if with_mcp:
payload["mcpServers"] = "./.mcp.json"
if with_apps:
payload["apps"] = "./.app.json"
return payload
def build_marketplace_entry(
@@ -112,7 +99,7 @@ def load_json(path: Path) -> dict[str, Any]:
def build_default_marketplace() -> dict[str, Any]:
return {
"name": "[TODO: marketplace-name]",
"name": DEFAULT_MARKETPLACE_NAME,
"interface": {
"displayName": DEFAULT_MARKETPLACE_DISPLAY_NAME,
},
@@ -185,7 +172,7 @@ def create_stub_file(path: Path, payload: dict, force: bool) -> None:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Create a plugin skeleton with placeholder plugin.json."
description="Create a plugin skeleton with a validation-ready plugin.json."
)
parser.add_argument("plugin_name")
parser.add_argument(
@@ -193,7 +180,7 @@ def parse_args() -> argparse.Namespace:
default=str(DEFAULT_PLUGIN_PARENT),
help=(
"Parent directory for plugin creation (defaults to <home>/plugins). "
"Use <repo>/plugins when creating a repo/team plugin."
"Pass an explicit repo path only when a repo/team plugin is intended."
),
)
parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory")
@@ -216,7 +203,7 @@ def parse_args() -> argparse.Namespace:
default=str(DEFAULT_MARKETPLACE_PATH),
help=(
"Path to marketplace.json (defaults to <home>/.agents/plugins/marketplace.json). "
"Use <repo>/.agents/plugins/marketplace.json for a repo/team plugin."
"Pass a repo-rooted marketplace path only when a repo/team plugin is intended."
),
)
parser.add_argument(
@@ -252,7 +239,11 @@ def main() -> None:
plugin_root.mkdir(parents=True, exist_ok=True)
plugin_json_path = plugin_root / ".codex-plugin" / "plugin.json"
write_json(plugin_json_path, build_plugin_json(plugin_name), args.force)
write_json(
plugin_json_path,
build_plugin_json(plugin_name, with_mcp=args.with_mcp, with_apps=args.with_apps),
args.force,
)
optional_directories = {
"skills": args.with_skills,

View File

@@ -0,0 +1,586 @@
#!/usr/bin/env python3
"""Validate a generated plugin against the plugin ingestion contract."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import urlparse
import yaml
TODO_MARKER = "[TODO:"
SEMVER_RE = re.compile(
r"^(0|[1-9]\d*)\."
r"(0|[1-9]\d*)\."
r"(0|[1-9]\d*)"
r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\."
r"(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
HEX_COLOR_RE = re.compile(r"^#[0-9A-F]{6}$", re.IGNORECASE)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Validate a local Codex plugin.")
parser.add_argument("plugin_path", help="Path to the plugin root directory")
return parser.parse_args()
def main() -> None:
args = parse_args()
plugin_root = Path(args.plugin_path).expanduser().resolve()
errors = validate_plugin(plugin_root)
if errors:
print("Plugin validation failed:")
for error in errors:
print(f"- {error}")
raise SystemExit(1)
print(f"Plugin validation passed: {plugin_root}")
def validate_plugin(plugin_root: Path) -> list[str]:
errors: list[str] = []
manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
manifest = load_json_object(manifest_path, errors)
if manifest is None:
return errors
reject_todo_markers(manifest, "$", errors)
validate_manifest_shape(plugin_root, manifest, errors)
return errors
def load_json_object(path: Path, errors: list[str]) -> dict[str, Any] | None:
if not path.is_file():
errors.append("missing `.codex-plugin/plugin.json`")
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except OSError:
errors.append("unable to read `.codex-plugin/plugin.json`")
return None
except json.JSONDecodeError:
errors.append("`.codex-plugin/plugin.json` must be valid JSON")
return None
if not isinstance(payload, dict):
errors.append("`.codex-plugin/plugin.json` must contain a JSON object")
return None
return payload
def reject_todo_markers(value: Any, path: str, errors: list[str]) -> None:
if isinstance(value, str):
if TODO_MARKER in value:
errors.append(f"{path} still contains a `[TODO: ...]` placeholder")
return
if isinstance(value, list):
for index, item in enumerate(value):
reject_todo_markers(item, f"{path}[{index}]", errors)
return
if isinstance(value, dict):
for key, item in value.items():
reject_todo_markers(item, f"{path}.{key}", errors)
def validate_manifest_shape(
plugin_root: Path,
manifest: dict[str, Any],
errors: list[str],
) -> None:
allowed_keys = {
"id",
"name",
"version",
"description",
"skills",
"apps",
"mcpServers",
"interface",
"author",
"homepage",
"repository",
"license",
"keywords",
}
for key in sorted(set(manifest) - allowed_keys):
errors.append(f"plugin.json field `{key}` is not accepted by plugin validation")
validate_optional_non_empty_string(manifest, "id", errors)
require_non_empty_string(manifest, "name", errors)
version = require_non_empty_string(manifest, "version", errors)
if version is not None and SEMVER_RE.fullmatch(version) is None:
errors.append("plugin.json field `version` must be strict semver")
require_non_empty_string(manifest, "description", errors)
author = require_object(manifest, "author", errors)
if author is not None:
reject_unknown_fields(author, {"name", "email", "url"}, "author", errors)
require_non_empty_string(author, "name", errors, prefix="author")
validate_optional_non_empty_string(author, "email", errors, prefix="author")
validate_optional_https_url(author, "url", errors, prefix="author")
validate_optional_contract_path(manifest, "skills", "skills", errors)
validate_optional_contract_path(manifest, "apps", ".app.json", errors)
validate_optional_contract_path(manifest, "mcpServers", ".mcp.json", errors)
if manifest.get("apps") is not None:
validate_app_manifest(
plugin_root / ".app.json",
errors,
)
if manifest.get("mcpServers") is not None:
validate_mcp_manifest(
plugin_root / ".mcp.json",
errors,
)
validate_skill_manifests(plugin_root, errors)
interface = require_object(manifest, "interface", errors)
if interface is None:
return
reject_unknown_fields(
interface,
{
"displayName",
"shortDescription",
"longDescription",
"developerName",
"category",
"capabilities",
"websiteURL",
"privacyPolicyURL",
"termsOfServiceURL",
"brandColor",
"composerIcon",
"logo",
"screenshots",
"defaultPrompt",
"default_prompt",
},
"interface",
errors,
)
for field in (
"displayName",
"shortDescription",
"longDescription",
"developerName",
"category",
):
require_non_empty_string(interface, field, errors, prefix="interface")
if "defaultPrompt" not in interface and "default_prompt" not in interface:
errors.append(
"plugin.json field `interface.defaultPrompt` or `interface.default_prompt` is required"
)
capabilities = interface.get("capabilities")
if not isinstance(capabilities, list) or not all(
isinstance(value, str) and value.strip() for value in capabilities
):
errors.append("plugin.json field `interface.capabilities` must be an array of strings")
for field in ("websiteURL", "privacyPolicyURL", "termsOfServiceURL"):
validate_optional_https_url(interface, field, errors, prefix="interface")
brand_color = interface.get("brandColor")
if brand_color is not None and (
not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None
):
errors.append("plugin.json field `interface.brandColor` must use `#RRGGBB`")
for field in ("composerIcon", "logo"):
validate_optional_asset_path(plugin_root, plugin_root, interface, field, errors)
screenshots = interface.get("screenshots", [])
if not isinstance(screenshots, list):
errors.append("plugin.json field `interface.screenshots` must be an array")
else:
for index, raw_path in enumerate(screenshots):
validate_asset_path(
plugin_root,
plugin_root,
raw_path,
f"interface.screenshots[{index}]",
errors,
)
def require_object(
payload: dict[str, Any],
key: str,
errors: list[str],
) -> dict[str, Any] | None:
value = payload.get(key)
if not isinstance(value, dict):
errors.append(f"plugin.json field `{key}` must be an object")
return None
return value
def require_non_empty_string(
payload: dict[str, Any],
key: str,
errors: list[str],
*,
prefix: str | None = None,
) -> str | None:
value = payload.get(key)
field = f"{prefix}.{key}" if prefix is not None else key
if not isinstance(value, str) or not value.strip():
errors.append(f"plugin.json field `{field}` must be a non-empty string")
return None
return value
def validate_optional_non_empty_string(
payload: dict[str, Any],
key: str,
errors: list[str],
*,
prefix: str | None = None,
) -> None:
value = payload.get(key)
if value is None:
return
field = f"{prefix}.{key}" if prefix is not None else key
if not isinstance(value, str) or not value.strip():
errors.append(f"plugin.json field `{field}` must be a non-empty string")
def reject_unknown_fields(
payload: dict[str, Any],
allowed_keys: set[str],
prefix: str,
errors: list[str],
) -> None:
for key in sorted(set(payload) - allowed_keys):
errors.append(f"plugin.json field `{prefix}.{key}` is not accepted by plugin validation")
def validate_optional_https_url(
payload: dict[str, Any],
key: str,
errors: list[str],
*,
prefix: str,
) -> None:
value = payload.get(key)
if value is None:
return
parsed = urlparse(value) if isinstance(value, str) else None
if parsed is None or parsed.scheme != "https" or not parsed.netloc:
errors.append(f"plugin.json field `{prefix}.{key}` must be an absolute `https://` URL")
def validate_optional_contract_path(
payload: dict[str, Any],
key: str,
expected: str,
errors: list[str],
) -> None:
value = payload.get(key)
if value is None:
return
normalized = normalize_contract_path(value) if isinstance(value, str) else None
if normalized != expected:
errors.append(f"plugin.json field `{key}` must resolve to `{expected}`")
def normalize_contract_path(raw_path: str) -> str | None:
path = Path(raw_path)
if path.is_absolute():
return None
normalized = path.as_posix().rstrip("/")
return normalized or None
def validate_app_manifest(path: Path, errors: list[str]) -> None:
payload = load_companion_json_object(path, "`.app.json`", errors)
if payload is None:
return
reject_companion_unknown_fields(payload, {"apps"}, "`.app.json`", errors)
apps = payload.get("apps")
if not isinstance(apps, dict):
errors.append("`.app.json` field `apps` must be an object")
return
for key, value in apps.items():
if not isinstance(value, dict):
errors.append(f"`.app.json` app `{key}` must be an object")
continue
reject_companion_unknown_fields(value, {"id"}, f"`.app.json` app `{key}`", errors)
app_id = value.get("id")
if not isinstance(app_id, str) or not app_id.strip():
errors.append(f"`.app.json` app `{key}` field `id` must be a non-empty string")
def validate_mcp_manifest(path: Path, errors: list[str]) -> None:
payload = load_companion_json_object(path, "`.mcp.json`", errors)
if payload is None:
return
reject_companion_unknown_fields(payload, {"mcpServers"}, "`.mcp.json`", errors)
servers = payload.get("mcpServers")
if not isinstance(servers, dict):
errors.append("`.mcp.json` field `mcpServers` must be an object")
return
for key, value in servers.items():
if not isinstance(key, str) or not key.strip():
errors.append("`.mcp.json` server names must be non-empty strings")
if not isinstance(value, dict):
errors.append(f"`.mcp.json` server `{key}` must be an object")
def load_companion_json_object(
path: Path,
label: str,
errors: list[str],
) -> dict[str, Any] | None:
if not path.is_file():
errors.append(f"{label} is required when its plugin.json field is present")
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
errors.append(f"{label} must contain valid JSON")
return None
if not isinstance(payload, dict):
errors.append(f"{label} must contain a JSON object")
return None
return payload
def reject_companion_unknown_fields(
payload: dict[str, Any],
allowed_keys: set[str],
prefix: str,
errors: list[str],
) -> None:
for key in sorted(set(payload) - allowed_keys):
errors.append(f"{prefix} field `{key}` is not accepted by plugin validation")
def validate_skill_manifests(plugin_root: Path, errors: list[str]) -> None:
skills_root = plugin_root / "skills"
if not skills_root.is_dir():
return
for skill_root in sorted(skills_root.iterdir(), key=lambda path: path.name):
if skill_root.name.startswith(".") or not skill_root.is_dir():
continue
validate_skill_manifest(skill_root, errors)
def validate_skill_manifest(skill_root: Path, errors: list[str]) -> None:
skill_md_path = skill_root / "SKILL.md"
if not skill_md_path.is_file():
errors.append(f"skill `{skill_root.name}` is missing `SKILL.md`")
return
try:
contents = skill_md_path.read_text(encoding="utf-8")
except OSError:
errors.append(f"unable to read skill `{skill_root.name}`")
return
if not contents.startswith("---\n"):
errors.append(f"skill `{skill_root.name}` must start with YAML frontmatter")
return
frontmatter_end = contents.find("\n---", 4)
if frontmatter_end == -1:
errors.append(f"skill `{skill_root.name}` frontmatter is not closed")
return
try:
frontmatter = yaml.safe_load(contents[4:frontmatter_end])
except yaml.YAMLError:
errors.append(f"skill `{skill_root.name}` frontmatter must be valid YAML")
return
if not isinstance(frontmatter, dict):
errors.append(f"skill `{skill_root.name}` frontmatter must be an object")
return
skill_name = frontmatter.get("name")
if not isinstance(skill_name, str) or not skill_name.strip():
errors.append(f"skill `{skill_root.name}` frontmatter field `name` must be non-empty")
description = frontmatter.get("description")
if not isinstance(description, str) or not description.strip():
errors.append(
f"skill `{skill_root.name}` frontmatter field `description` must be non-empty"
)
disable_model_invocation = frontmatter.get("disable-model-invocation")
if disable_model_invocation is None:
disable_model_invocation = frontmatter.get("disable_model_invocation")
if disable_model_invocation not in (None, False):
errors.append(
f"skill `{skill_root.name}` frontmatter field `disable-model-invocation` must be false"
)
agent_yaml_path = skill_root / "agents" / "openai.yaml"
if agent_yaml_path.is_file():
validate_skill_agent_manifest(
plugin_root=skill_root.parent.parent,
skill_root=skill_root,
agent_yaml_path=agent_yaml_path,
errors=errors,
)
def validate_skill_agent_manifest(
*,
plugin_root: Path,
skill_root: Path,
agent_yaml_path: Path,
errors: list[str],
) -> None:
try:
payload = yaml.safe_load(agent_yaml_path.read_text(encoding="utf-8"))
except OSError:
errors.append(f"unable to read skill `{skill_root.name}` agent YAML")
return
except yaml.YAMLError:
errors.append(f"skill `{skill_root.name}` agent YAML must be valid YAML")
return
if not isinstance(payload, dict):
errors.append(f"skill `{skill_root.name}` agent YAML must be an object")
return
reject_skill_agent_unknown_fields(
payload,
{"interface", "policy", "dependencies"},
skill_root,
errors,
)
interface = payload.get("interface")
if not isinstance(interface, dict):
errors.append(f"skill `{skill_root.name}` agent field `interface` must be an object")
return
reject_skill_agent_unknown_fields(
interface,
{
"display_name",
"short_description",
"icon_small",
"icon_large",
"brand_color",
"default_prompt",
},
skill_root,
errors,
prefix="interface",
)
for field in ("display_name", "short_description"):
value = interface.get(field)
if not isinstance(value, str) or not value.strip():
errors.append(
f"skill `{skill_root.name}` agent field `interface.{field}` must be non-empty"
)
for field in ("icon_small", "icon_large"):
validate_optional_asset_path(
skill_root,
plugin_root,
interface,
field,
errors,
prefix=f"skill `{skill_root.name}` agent field `interface",
)
brand_color = interface.get("brand_color")
if brand_color is not None and (
not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None
):
errors.append(
f"skill `{skill_root.name}` agent field `interface.brand_color` must use `#RRGGBB`"
)
default_prompt = interface.get("default_prompt")
if default_prompt is not None and (
not isinstance(default_prompt, str) or not default_prompt.strip()
):
errors.append(
f"skill `{skill_root.name}` agent field `interface.default_prompt` must be non-empty"
)
policy = payload.get("policy")
if policy is not None:
if not isinstance(policy, dict):
errors.append(f"skill `{skill_root.name}` agent field `policy` must be an object")
else:
reject_skill_agent_unknown_fields(
policy,
{"allow_implicit_invocation"},
skill_root,
errors,
prefix="policy",
)
allow_implicit_invocation = policy.get("allow_implicit_invocation")
if allow_implicit_invocation is not None and not isinstance(
allow_implicit_invocation,
bool,
):
errors.append(
f"skill `{skill_root.name}` agent field "
"`policy.allow_implicit_invocation` must be a boolean"
)
dependencies = payload.get("dependencies")
if dependencies is not None:
if not isinstance(dependencies, dict):
errors.append(
f"skill `{skill_root.name}` agent field `dependencies` must be an object"
)
else:
reject_skill_agent_unknown_fields(
dependencies,
{"tools"},
skill_root,
errors,
prefix="dependencies",
)
def reject_skill_agent_unknown_fields(
payload: dict[str, Any],
allowed_keys: set[str],
skill_root: Path,
errors: list[str],
*,
prefix: str | None = None,
) -> None:
for key in sorted(set(payload) - allowed_keys):
field = f"{prefix}.{key}" if prefix is not None else key
errors.append(
f"skill `{skill_root.name}` agent field `{field}` is not accepted by plugin validation"
)
def validate_optional_asset_path(
base_dir: Path,
allowed_root: Path,
payload: dict[str, Any],
key: str,
errors: list[str],
*,
prefix: str = "interface",
) -> None:
raw_path = payload.get(key)
if raw_path is None:
return
validate_asset_path(base_dir, allowed_root, raw_path, f"{prefix}.{key}", errors)
def validate_asset_path(
base_dir: Path,
allowed_root: Path,
raw_path: Any,
field: str,
errors: list[str],
) -> None:
label = field if field.startswith("skill `") else f"plugin.json field `{field}`"
if not isinstance(raw_path, str) or not raw_path.strip():
errors.append(f"{label} must be a non-empty relative path")
return
candidate = PurePosixPath(raw_path.replace("\\", "/"))
if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts):
errors.append(f"{label} must stay inside the plugin archive")
return
resolved_path = (base_dir / candidate.as_posix()).resolve()
if not resolved_path.is_relative_to(allowed_root.resolve()):
errors.append(f"{label} must stay inside the plugin archive")
return
if not resolved_path.is_file():
errors.append(f"{label} points to a missing file")
if __name__ == "__main__":
main()