modify template

This commit is contained in:
김경종
2026-06-10 17:12:23 +09:00
parent 2d59191df2
commit df3cc3e890
186 changed files with 24935 additions and 2 deletions
@@ -0,0 +1,243 @@
---
name: plugin-creator
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, generate or update marketplace entries for plugin ordering and availability metadata, or update an existing local plugin during development with the CLI-driven cachebuster and reinstall flow.
---
# Plugin Creator
## Quick Start
1. Run the scaffold script:
```bash
# Plugin names are normalized to lower-case hyphen-case and must be <= 64 chars.
# The generated folder and plugin.json name are always the same.
# Run from the skill root (the directory containing this `SKILL.md`).
# By default creates in `~/plugins/<plugin-name>`.
python3 scripts/create_basic_plugin.py <plugin-name>
```
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:
```bash
# Personal marketplace entries default to `~/.agents/plugins/marketplace.json`.
python3 scripts/create_basic_plugin.py my-plugin --with-marketplace
```
Only specify `--marketplace-name <name>` when the default `personal` marketplace name is already
taken or installed and you need to seed a different new marketplace file:
```bash
python3 scripts/create_basic_plugin.py my-plugin \
--with-marketplace \
--marketplace-name team-local
```
Only use a repo/team marketplace when the user specifically asks for that destination:
```bash
python3 scripts/create_basic_plugin.py my-plugin \
--path <repo-root>/plugins \
--marketplace-path <repo-root>/.agents/plugins/marketplace.json \
--with-marketplace
```
When the user specifies a marketplace path, make sure that marketplace is actually installed before
telling the user to reinstall from it. The default personal marketplace file at
`~/.agents/plugins/marketplace.json` is discovered implicitly, but other marketplace paths are not.
On Windows, use the equivalent path under the user profile.
4. Generate/adjust optional companion folders as needed:
```bash
python3 scripts/create_basic_plugin.py my-plugin \
--path <parent-plugin-directory> \
--marketplace-path <marketplace-json-path> \
--with-skills --with-hooks --with-scripts --with-assets --with-mcp --with-apps --with-marketplace
```
`<parent-plugin-directory>` is the directory where the plugin folder `<plugin-name>` will be
created (for example `~/plugins`).
5. Before handing back a generated plugin, run:
```bash
python3 scripts/validate_plugin.py <plugin-path>
```
For updates to an existing local plugin during development, keep the scaffold flow as-is and use the
reference instead of hand-editing marketplace files:
```bash
python3 scripts/update_plugin_cachebuster.py <plugin-path>
```
Prefer the helper default cachebuster unless the user explicitly asks for a specific override.
See `references/installing-and-updating.md` for the expected cachebuster and reinstall flow while iterating on an existing local plugin.
## What this skill creates
- Default marketplace-backed scaffolds use the personal marketplace file at
`~/.agents/plugins/marketplace.json`, with plugins generally being stored in
`~/plugins/<plugin-name>/`.
- 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 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`
- underscores, spaces, and punctuation are converted to `-`
- result is lower-case hyphen-delimited with consecutive hyphens collapsed
- Supports optional creation of:
- `skills/`
- `hooks/`
- `scripts/`
- `assets/`
- `.mcp.json`
- `.app.json`
## Marketplace workflow
- Personal-marketplace creation defaults to `~/.agents/plugins/marketplace.json`. Here,
"personal marketplace" means the marketplace whose file is at that path.
- Repo/team marketplace creation is opt-in through both `--path` and `--marketplace-path`, only
when the user specifically requests it.
- `--marketplace-name` is an exception path. Use it only when the default `personal` marketplace
name is already taken and you need to seed a different new marketplace file.
- Do not use `--marketplace-name` to rename an existing marketplace file in place. If the file
already exists, its top-level `name` must already match.
- If the user specifies a different marketplace path, treat that marketplace as needing explicit installation via `codex plugin marketplace add`.
- Prefer `scripts/read_marketplace_name.py` when you need the marketplace name from any
`marketplace.json` file. With no argument it reads the default personal marketplace; with an
explicit path it works for repo/team marketplaces too.
- 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.
- Each generated marketplace entry must include all of:
- `policy.installation`
- `policy.authentication`
- `category`
- Default new entries to:
- `policy.installation: "AVAILABLE"`
- `policy.authentication: "ON_INSTALL"`
- Override defaults only when the user explicitly specifies another allowed value.
- Allowed `policy.installation` values:
- `NOT_AVAILABLE`
- `AVAILABLE`
- `INSTALLED_BY_DEFAULT`
- Allowed `policy.authentication` values:
- `ON_INSTALL`
- `ON_USE`
- Treat `policy.products` as an override. Omit it unless the user explicitly requests product gating.
- The generated plugin entry shape is:
```json
{
"name": "plugin-name",
"source": {
"source": "local",
"path": "./plugins/plugin-name"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
```
- Use `--force` only when intentionally replacing an existing marketplace entry for the same plugin name.
- 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": "personal",
"interface": {
"displayName": "Personal"
},
"plugins": [
{
"name": "plugin-name",
"source": {
"source": "local",
"path": "./plugins/plugin-name"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
```
## 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.
- 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.
- Add `policy.products` only when the user explicitly asks for that override.
- Keep marketplace `source.path` relative to the selected marketplace root as `./plugins/<plugin-name>`.
- Only use `--marketplace-name` when creating a new marketplace file whose name should not be
`personal` because that name is already taken or installed elsewhere.
- If Codex would need approval to write the marketplace file, ask for that approval before
proceeding. If the user prefers to run the write themselves, provide the exact scaffold command
and then continue from validation or subsequent plugin edits instead of leaving the workflow
vague.
- For updates to an existing local plugin during development, do not hand-edit marketplace config
or `marketplace.json`. Use the update flow documented in
`references/installing-and-updating.md` and `scripts/update_plugin_cachebuster.py`.
- Do not tell the user to run `codex plugin marketplace add` for the default personal-marketplace
flow. That command is for explicit non-default marketplace configuration, not for the standard
`~/.agents/plugins/marketplace.json` path.
- If the user provided a non-default `--marketplace-path`, make sure that marketplace is installed
before giving reinstall instructions. Use `codex plugin marketplace add <path-to-marketplace-root>`
when that explicit marketplace has not been configured yet.
- When the workflow created or updated a marketplace-backed plugin, end the final user-facing
response with a short Codex app handoff. Say `To view this in the Codex app:` and write
`View <normalized plugin name>` and `Share <normalized plugin name>` as Markdown links, not raw
URLs or code spans.
- The View deeplink uses `codex://plugins/<normalized plugin name>?marketplacePath=<absolute marketplace.json path>`.
The Share deeplink uses the same URL with `&mode=share`.
- Replace the placeholders with the real normalized plugin name and absolute `marketplace.json`
path from the scaffolded plugin. URL-encode the path segment and query value when needed.
- Do not add `pluginName` or `hostId` query parameters to these deeplinks. Codex derives both after
the user clicks the link.
- Do not emit the `View <normalized plugin name>` or `Share <normalized plugin name>` links when no marketplace entry was
created or updated.
## Reference to exact spec sample
For the exact canonical sample JSON for both plugin manifests and marketplace entries, use:
- `references/plugin-json-spec.md`
- `references/installing-and-updating.md` for update/reinstall guidance while
iterating on an existing local plugin, plus the new-thread pickup behavior after reinstall
## Validation
After editing `SKILL.md`, run:
```bash
python3 ../skill-creator/scripts/quick_validate.py .
```
Before handing back a generated plugin, run:
```bash
python3 scripts/validate_plugin.py <plugin-path>
```
@@ -0,0 +1,6 @@
interface:
display_name: "Plugin Creator"
short_description: "Scaffold plugins and marketplace entries"
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"
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
<path fill="#0D0D0D" d="M12.03 4.113a3.612 3.612 0 0 1 5.108 5.108l-6.292 6.29c-.324.324-.56.561-.791.752l-.235.176c-.205.14-.422.261-.65.36l-.229.093a4.136 4.136 0 0 1-.586.16l-.764.134-2.394.4c-.142.024-.294.05-.423.06-.098.007-.232.01-.378-.026l-.149-.05a1.081 1.081 0 0 1-.521-.474l-.046-.093a1.104 1.104 0 0 1-.075-.527c.01-.129.035-.28.06-.422l.398-2.394c.1-.602.162-.987.295-1.35l.093-.23c.1-.228.22-.445.36-.65l.176-.235c.19-.232.428-.467.751-.79l6.292-6.292Zm-5.35 7.232c-.35.35-.534.535-.66.688l-.11.147a2.67 2.67 0 0 0-.24.433l-.062.154c-.08.22-.124.462-.232 1.112l-.398 2.394-.001.001h.003l2.393-.399.717-.126a2.63 2.63 0 0 0 .394-.105l.154-.063a2.65 2.65 0 0 0 .433-.24l.147-.11c.153-.126.339-.31.688-.66l4.988-4.988-3.227-3.226-4.987 4.988Zm9.517-6.291a2.281 2.281 0 0 0-3.225 0l-.364.362 3.226 3.227.363-.364c.89-.89.89-2.334 0-3.225ZM4.583 1.783a.3.3 0 0 1 .294.241c.117.585.347 1.092.707 1.48.357.385.859.668 1.549.783a.3.3 0 0 1 0 .592c-.69.115-1.192.398-1.549.783-.315.34-.53.77-.657 1.265l-.05.215a.3.3 0 0 1-.588 0c-.117-.585-.347-1.092-.707-1.48-.357-.384-.859-.668-1.549-.783a.3.3 0 0 1 0-.592c.69-.115 1.192-.398 1.549-.783.36-.388.59-.895.707-1.48l.015-.05a.3.3 0 0 1 .279-.19Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,143 @@
# Updating Existing Local Plugins
Use this reference when a plugin already exists and the request is about updating the plugin during
local development.
All scripts here are specified relative to the skill root. Update the path for running the scripts
depending on your current working directory.
## When To Use This Flow
Use this flow when all of the following are true:
- the plugin already exists locally
- the marketplace entry already points at the plugin source you are editing
- the user wants Codex to see the updated plugin without manually editing marketplace files
If the user still needs the initial plugin entry or marketplace structure created, use the scaffold
flow first and only then switch to this reinstall flow.
## Update Loop
1. Update the plugin manifest to a single Codex cachebuster suffix:
```bash
python3 scripts/update_plugin_cachebuster.py \
<plugin-path>
```
Prefer the default helper behavior here. If you omit `--cachebuster`, the helper uses a UTC
timestamp down to seconds, which is the recommended path for routine local iteration.
Only use a manual cachebuster override when the user explicitly asks for one or when a workflow
outside Codex depends on a specific token:
```bash
python3 scripts/update_plugin_cachebuster.py \
<plugin-path> \
--cachebuster local-20260519-184516
```
2. For the default scaffolded flow, read the marketplace name from the personal marketplace file:
```bash
python3 scripts/read_marketplace_name.py
```
Here, "personal marketplace" means the marketplace whose file is at
`~/.agents/plugins/marketplace.json`. On Windows, use the equivalent path under the user profile.
The helper uses Python's home-directory resolution and prints the marketplace name to use when
constructing the install command.
To read the name from a different marketplace file, pass the path directly:
```bash
python3 scripts/read_marketplace_name.py --marketplace-path <path-to-marketplace.json>
```
3. Reinstall from that marketplace name:
```bash
codex plugin add <plugin-name>@<marketplace-name-from-marketplace-json>
```
The default personal marketplace is discovered implicitly from
`~/.agents/plugins/marketplace.json`. You do not need `codex plugin marketplace add` for that
path, and `codex plugin marketplace list` is not the right check for whether that default
marketplace exists.
4. If the plugin is not using the personal marketplace file, check which configured local
marketplace is actually surfacing that plugin:
```bash
codex plugin list
```
If the plugin is not in the personal marketplace file, confirm which marketplace entry points at
the plugin source you are editing and make sure that marketplace is still local. If it is a
different local marketplace, reinstall from that marketplace name instead of forcing the personal
marketplace flow. If it is not local, stop and help the user resolve the mismatch before
continuing.
5. If the plugin lives in a different confirmed local marketplace, substitute that marketplace
name:
```bash
codex plugin add <plugin-name>@<local-marketplace>
```
6. Prompt the user to use a new thread to try the updated plugin, so that Codex picks up new skills
and tools.
## Cachebuster Policy
- Preserve the existing version prefix and replace only the suffix.
- Treat the preserved prefix as everything before `+`.
- Use the format:
```text
<base-version>+codex.<cachebuster>
```
Examples:
- `0.1.0``0.1.0+codex.local-20260519-184516`
- `0.1.0+codex.old-token``0.1.0+codex.local-20260519-184516`
- `1.2.3-beta.1+codex.prev``1.2.3-beta.1+codex.local-20260519-184516`
- `dev-build+other-tag``dev-build+codex.local-20260519-184516`
Replace the existing Codex cachebuster instead of appending another one. Do not keep incrementing
numeric version components just to trigger reinstall behavior.
## Marketplace Rules
- Marketplace manipulation should happen through commands, not by hand-editing `marketplace.json`
or `config.toml` during this update/reinstall flow.
- Prefer the personal marketplace file for the default scaffolded flow.
- Read the personal marketplace name with
`python3 scripts/read_marketplace_name.py` and use the printed value when constructing
`codex plugin add <plugin-name>@<marketplace-name>`.
- For non-default marketplace files, use
`python3 scripts/read_marketplace_name.py --marketplace-path <path-to-marketplace.json>` to read
the name before constructing reinstall commands.
- Do not tell the user to run `codex plugin marketplace add` for the default personal-marketplace
flow. That marketplace is discovered implicitly by Codex.
- If the user specified a different marketplace path, make sure that marketplace is installed
before giving install or reinstall instructions. Non-default marketplace paths are not
discovered implicitly.
- Use `codex plugin list` when the plugin lives in a different configured marketplace and you need
to confirm which marketplace is surfacing that plugin.
- If a non-default local marketplace has not been configured yet, install it with
`codex plugin marketplace add <path-to-marketplace-root>` before telling the user to run
`codex plugin add <plugin-name>@<marketplace-name>`.
- If the plugin is not in the personal marketplace file, confirm that the selected marketplace is
local before telling the user to reinstall from it.
- If the selected marketplace is not local, stop and help the user resolve that mismatch rather
than pretending the normal local reinstall flow applies.
- If the plugin source is not already the source referenced by the chosen marketplace entry, stop
and fix that first. This update flow does not rewrite marketplace entries.
## After Reinstall
After reinstalling, prompt the user to start a new thread for testing. That is the safe boundary for
picking up the updated plugin and its MCP tools.
@@ -0,0 +1,194 @@
# Plugin JSON sample spec
```json
{
"name": "plugin-name",
"version": "1.2.0",
"description": "Brief plugin description",
"author": {
"name": "Author Name",
"email": "author@example.com",
"url": "https://github.com/author"
},
"homepage": "https://docs.example.com/plugin",
"repository": "https://github.com/author/plugin",
"license": "MIT",
"keywords": ["keyword1", "keyword2"],
"skills": "./skills/",
"hooks": "./hooks.json",
"mcpServers": "./.mcp.json",
"apps": "./.app.json",
"interface": {
"displayName": "Plugin Display Name",
"shortDescription": "Short description for subtitle",
"longDescription": "Long description for details page",
"developerName": "OpenAI",
"category": "Productivity",
"capabilities": ["Interactive", "Write"],
"websiteURL": "https://openai.com/",
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
"defaultPrompt": [
"Summarize my inbox and draft replies for me.",
"Find open bugs and turn them into Linear tickets.",
"Review today's meetings and flag scheduling gaps."
],
"brandColor": "#3B82F6",
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png",
"screenshots": [
"./assets/screenshot1.png",
"./assets/screenshot2.png",
"./assets/screenshot3.png"
]
}
}
```
## Field guide
### Top-level fields
- `name` (`string`): Plugin identifier (kebab-case, no spaces). Required if `plugin.json` is provided and used as manifest name and component namespace.
- `version` (`string`): Plugin semantic version.
- `description` (`string`): Short purpose summary.
- `author` (`object`): Publisher identity.
- `name` (`string`): Author or team name.
- `email` (`string`): Contact email.
- `url` (`string`): Author/team homepage or profile URL.
- `homepage` (`string`): Documentation URL for plugin usage.
- `repository` (`string`): Source code URL.
- `license` (`string`): License identifier (for example `MIT`, `Apache-2.0`).
- `keywords` (`array` of `string`): Search/discovery tags.
- `skills` (`string`): Relative path to skill directories/files.
- `hooks` (`string`): Hook config path.
- `mcpServers` (`string`): MCP config path.
- `apps` (`string`): App manifest path for plugin integrations.
- `interface` (`object`): Interface/UX metadata block for plugin presentation.
### `interface` fields
- `displayName` (`string`): User-facing title shown for the plugin.
- `shortDescription` (`string`): Brief subtitle used in compact views.
- `longDescription` (`string`): Longer description used on details screens.
- `developerName` (`string`): Human-readable publisher name.
- `category` (`string`): Plugin category bucket.
- `capabilities` (`array` of `string`): Capability list from implementation.
- `websiteURL` (`string`): Public website for the plugin.
- `privacyPolicyURL` (`string`): Privacy policy URL.
- `termsOfServiceURL` (`string`): Terms of service URL.
- `defaultPrompt` (`array` of `string`): Starter prompts shown in composer/UX context.
- Include at most 3 strings. Entries after the first 3 are ignored and will not be included.
- Each string is capped at 128 characters. Longer entries are truncated.
- Prefer short starter prompts around 50 characters so they scan well in the UI.
- `brandColor` (`string`): Theme color for the plugin card.
- `composerIcon` (`string`): Path to icon asset.
- `logo` (`string`): Path to logo asset.
- `screenshots` (`array` of `string`): List of screenshot asset paths.
- Screenshot entries must be PNG filenames and stored under `./assets/`.
- Keep file paths relative to plugin root.
### Path conventions and defaults
- Path values should be relative and begin with `./`.
- `skills`, `hooks`, and `mcpServers` are supplemented on top of default component discovery; they do not replace defaults.
- Custom path values must follow the plugin root convention and naming/namespacing rules.
- This repos scaffold writes `.codex-plugin/plugin.json`; treat that as the manifest location this skill generates.
# Marketplace JSON sample spec
`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`
```json
{
"name": "openai-curated",
"interface": {
"displayName": "ChatGPT Official"
},
"plugins": [
{
"name": "linear",
"source": {
"source": "local",
"path": "./plugins/linear"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
```
## Marketplace field guide
### Top-level fields
- `name` (`string`): Marketplace identifier or catalog name.
- `interface` (`object`, optional): Marketplace presentation metadata.
- `plugins` (`array`): Ordered plugin entries. This order determines how Codex renders plugins.
### `interface` fields
- `displayName` (`string`, optional): User-facing marketplace title.
### Plugin entry fields
- `name` (`string`): Plugin identifier. Match the plugin folder name and `plugin.json` `name`.
- `source` (`object`): Plugin source descriptor.
- `source` (`string`): Use `local` for this repo workflow.
- `path` (`string`): Relative plugin path based on the marketplace root.
- Personal plugin in `~/.agents/plugins/marketplace.json`: `./plugins/<plugin-name>`
- Repo/team plugin: `./plugins/<plugin-name>`
- The same relative path convention is used for both personal and repo/team marketplaces.
- Example: with `~/.agents/plugins/marketplace.json`, `./plugins/<plugin-name>` resolves to
`~/plugins/<plugin-name>`.
- `policy` (`object`): Marketplace policy block. Always include it.
- `installation` (`string`): Availability policy.
- Allowed values: `NOT_AVAILABLE`, `AVAILABLE`, `INSTALLED_BY_DEFAULT`
- Default for new entries: `AVAILABLE`
- `authentication` (`string`): Authentication timing policy.
- Allowed values: `ON_INSTALL`, `ON_USE`
- Default for new entries: `ON_INSTALL`
- `products` (`array` of `string`, optional): Product override for this plugin entry. Omit it unless product gating is explicitly requested.
- `category` (`string`): Display category bucket. Always include it.
### Marketplace generation rules
- `displayName` belongs under the top-level `interface` object, not individual plugin entries.
- When creating a new marketplace file from scratch, seed `interface.displayName` alongside top-level `name`.
- Always include `policy.installation`, `policy.authentication`, and `category` on every generated or updated plugin entry.
- Treat `policy.products` as an override and omit it unless explicitly requested.
- 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.
- Use a repo/team marketplace only when the user specifically requests that destination.
- Only override the marketplace `name` when the default `personal` name is already taken or
installed and you need to seed a different new marketplace file.
- 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.
@@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""Scaffold a plugin directory and optionally update marketplace.json."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any
MAX_PLUGIN_NAME_LENGTH = 64
DEFAULT_INSTALL_POLICY = "AVAILABLE"
DEFAULT_AUTH_POLICY = "ON_INSTALL"
DEFAULT_CATEGORY = "Productivity"
DEFAULT_MARKETPLACE_NAME = "personal"
VALID_INSTALL_POLICIES = {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"}
VALID_AUTH_POLICIES = {"ON_INSTALL", "ON_USE"}
DEFAULT_PLUGIN_PARENT = Path.home() / "plugins"
DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.json"
def normalize_plugin_name(plugin_name: str) -> str:
"""Normalize a plugin name to lowercase hyphen-case."""
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 validate_plugin_name(plugin_name: str) -> None:
if not plugin_name:
raise ValueError("Plugin name must include at least one letter or digit.")
if len(plugin_name) > MAX_PLUGIN_NAME_LENGTH:
raise ValueError(
f"Plugin name '{plugin_name}' is too long ({len(plugin_name)} characters). "
f"Maximum is {MAX_PLUGIN_NAME_LENGTH} characters."
)
def validate_marketplace_name(marketplace_name: str) -> None:
if not marketplace_name:
raise ValueError("Marketplace name must include at least one letter or digit.")
if re.fullmatch(r"[A-Za-z0-9_-]+", marketplace_name) is None:
raise ValueError(
"Marketplace name may only contain ASCII letters, digits, `_`, and `-`."
)
def display_name_from_plugin_name(plugin_name: str) -> str:
return " ".join(part.capitalize() for part in re.split(r"[-_]+", plugin_name))
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": "0.1.0",
"description": f"{display_name} plugin",
"author": {
"name": "Local developer",
},
"skills": "./skills/",
"interface": {
"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(
plugin_name: str,
install_policy: str,
auth_policy: str,
category: str,
) -> dict[str, Any]:
return {
"name": plugin_name,
"source": {
"source": "local",
"path": f"./plugins/{plugin_name}",
},
"policy": {
"installation": install_policy,
"authentication": auth_policy,
},
"category": category,
}
def load_json(path: Path) -> dict[str, Any]:
with path.open() as handle:
return json.load(handle)
def build_default_marketplace(marketplace_name: str) -> dict[str, Any]:
return {
"name": marketplace_name,
"interface": {
"displayName": display_name_from_plugin_name(marketplace_name),
},
"plugins": [],
}
def validate_marketplace_interface(payload: dict[str, Any]) -> None:
interface = payload.get("interface")
if interface is not None and not isinstance(interface, dict):
raise ValueError("marketplace.json field 'interface' must be an object.")
def update_marketplace_json(
marketplace_path: Path,
marketplace_name: str | None,
plugin_name: str,
install_policy: str,
auth_policy: str,
category: str,
force: bool,
) -> None:
if marketplace_path.exists():
payload = load_json(marketplace_path)
else:
payload = build_default_marketplace(marketplace_name or DEFAULT_MARKETPLACE_NAME)
if not isinstance(payload, dict):
raise ValueError(f"{marketplace_path} must contain a JSON object.")
validate_marketplace_interface(payload)
existing_marketplace_name = payload.get("name")
if marketplace_name is not None:
if not isinstance(existing_marketplace_name, str) or not existing_marketplace_name.strip():
raise ValueError(f"{marketplace_path} must contain a non-empty string 'name'.")
if existing_marketplace_name != marketplace_name:
raise ValueError(
f"{marketplace_path} already uses marketplace name "
f"'{existing_marketplace_name}'. Create a new marketplace file to use "
f"'{marketplace_name}' instead."
)
plugins = payload.setdefault("plugins", [])
if not isinstance(plugins, list):
raise ValueError(f"{marketplace_path} field 'plugins' must be an array.")
new_entry = build_marketplace_entry(plugin_name, install_policy, auth_policy, category)
for index, entry in enumerate(plugins):
if isinstance(entry, dict) and entry.get("name") == plugin_name:
if not force:
raise FileExistsError(
f"Marketplace entry '{plugin_name}' already exists in {marketplace_path}. "
"Use --force to overwrite that entry."
)
plugins[index] = new_entry
break
else:
plugins.append(new_entry)
write_json(marketplace_path, payload, force=True)
def write_json(path: Path, data: dict, force: bool) -> None:
if path.exists() and not force:
raise FileExistsError(f"{path} already exists. Use --force to overwrite.")
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w") as handle:
json.dump(data, handle, indent=2)
handle.write("\n")
def create_stub_file(path: Path, payload: dict, force: bool) -> None:
if path.exists() and not force:
return
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w") as handle:
json.dump(payload, handle, indent=2)
handle.write("\n")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Create a plugin skeleton with a validation-ready plugin.json."
)
parser.add_argument("plugin_name")
parser.add_argument(
"--path",
default=str(DEFAULT_PLUGIN_PARENT),
help=(
"Parent directory for plugin creation (defaults to <home>/plugins). "
"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")
parser.add_argument("--with-hooks", action="store_true", help="Create hooks/ directory")
parser.add_argument("--with-scripts", action="store_true", help="Create scripts/ directory")
parser.add_argument("--with-assets", action="store_true", help="Create assets/ directory")
parser.add_argument("--with-mcp", action="store_true", help="Create .mcp.json placeholder")
parser.add_argument("--with-apps", action="store_true", help="Create .app.json placeholder")
parser.add_argument(
"--with-marketplace",
action="store_true",
help=(
"Create or update <home>/.agents/plugins/marketplace.json by default. "
"Marketplace entries always point to ./plugins/<plugin-name> relative to the "
"marketplace root."
),
)
parser.add_argument(
"--marketplace-path",
default=str(DEFAULT_MARKETPLACE_PATH),
help=(
"Path to marketplace.json (defaults to <home>/.agents/plugins/marketplace.json). "
"Pass a repo-rooted marketplace path only when a repo/team plugin is intended."
),
)
parser.add_argument(
"--marketplace-name",
help=(
"Marketplace name to seed into a new marketplace.json. Use this only when the default "
"'personal' marketplace name is already taken and you need a different new marketplace."
),
)
parser.add_argument(
"--install-policy",
default=DEFAULT_INSTALL_POLICY,
choices=sorted(VALID_INSTALL_POLICIES),
help="Marketplace policy.installation value",
)
parser.add_argument(
"--auth-policy",
default=DEFAULT_AUTH_POLICY,
choices=sorted(VALID_AUTH_POLICIES),
help="Marketplace policy.authentication value",
)
parser.add_argument(
"--category",
default=DEFAULT_CATEGORY,
help="Marketplace category value",
)
parser.add_argument("--force", action="store_true", help="Overwrite existing files")
return parser.parse_args()
def main() -> None:
args = parse_args()
raw_plugin_name = args.plugin_name
plugin_name = normalize_plugin_name(raw_plugin_name)
if plugin_name != raw_plugin_name:
print(f"Note: Normalized plugin name from '{raw_plugin_name}' to '{plugin_name}'.")
validate_plugin_name(plugin_name)
marketplace_name = None
if args.marketplace_name is not None:
marketplace_name = args.marketplace_name.strip()
validate_marketplace_name(marketplace_name)
plugin_root = (Path(args.path).expanduser().resolve() / plugin_name)
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, with_mcp=args.with_mcp, with_apps=args.with_apps),
args.force,
)
optional_directories = {
"skills": args.with_skills,
"hooks": args.with_hooks,
"scripts": args.with_scripts,
"assets": args.with_assets,
}
for folder, enabled in optional_directories.items():
if enabled:
(plugin_root / folder).mkdir(parents=True, exist_ok=True)
if args.with_mcp:
create_stub_file(
plugin_root / ".mcp.json",
{"mcpServers": {}},
args.force,
)
if args.with_apps:
create_stub_file(
plugin_root / ".app.json",
{
"apps": {},
},
args.force,
)
if args.with_marketplace:
marketplace_path = Path(args.marketplace_path).expanduser().resolve()
update_marketplace_json(
marketplace_path,
marketplace_name,
plugin_name,
args.install_policy,
args.auth_policy,
args.category,
args.force,
)
print(f"Created plugin scaffold: {plugin_root}")
print(f"plugin manifest: {plugin_json_path}")
if args.with_marketplace:
print(f"marketplace manifest: {marketplace_path}")
if __name__ == "__main__":
main()
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Print the top-level marketplace name from any marketplace.json file."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def default_marketplace_path() -> Path:
return Path.home() / ".agents" / "plugins" / "marketplace.json"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Print the top-level marketplace name from marketplace.json. Defaults to the personal "
"marketplace path under the current home directory."
)
)
parser.add_argument(
"--marketplace-path",
default=str(default_marketplace_path()),
help="Path to marketplace.json",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
marketplace_path = Path(args.marketplace_path).expanduser().resolve()
payload = json.loads(marketplace_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"{marketplace_path} must contain a JSON object.")
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError(f"{marketplace_path} must contain a non-empty string 'name'.")
print(name.strip())
if __name__ == "__main__":
try:
main()
except Exception as err: # noqa: BLE001 - CLI should surface a single clear message.
print(str(err), file=sys.stderr)
raise SystemExit(1) from err
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Rewrite a local plugin version to a single Codex cachebuster suffix."""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
CACHEBUSTER_PREFIX = "codex"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Rewrite a local plugin's version so it preserves everything before '+' and uses "
"a single +codex.<cachebuster> suffix."
)
)
parser.add_argument("plugin_path", help="Path to the plugin root directory")
parser.add_argument(
"--cachebuster",
help="Optional cachebuster token to embed in the plugin version",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
plugin_root = Path(args.plugin_path).expanduser().resolve()
manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
manifest = load_manifest(manifest_path)
version = manifest.get("version")
if not isinstance(version, str) or not version.strip():
raise ValueError(f"{manifest_path} must contain a non-empty string 'version'.")
cachebuster = sanitize_cachebuster(args.cachebuster or default_cachebuster())
next_version = with_cachebuster(version, cachebuster)
manifest["version"] = next_version
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
print(f"Updated plugin version: {version} -> {next_version}")
def load_manifest(manifest_path: Path) -> dict[str, object]:
if not manifest_path.is_file():
raise FileNotFoundError(f"missing manifest: {manifest_path}")
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"{manifest_path} must contain a JSON object.")
return payload
def sanitize_cachebuster(value: str) -> str:
sanitized = re.sub(r"[^a-z0-9-]+", "-", value.strip().lower())
sanitized = re.sub(r"-{2,}", "-", sanitized).strip("-")
if not sanitized:
raise ValueError("Cachebuster must contain at least one letter or digit.")
return sanitized
def default_cachebuster() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
def with_cachebuster(version: str, cachebuster: str) -> str:
version_prefix = version.split("+", 1)[0]
return f"{version_prefix}+{CACHEBUSTER_PREFIX}.{cachebuster}"
if __name__ == "__main__":
try:
main()
except Exception as err: # noqa: BLE001 - CLI should surface a single clear message.
print(str(err), file=sys.stderr)
raise SystemExit(1) from err
@@ -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()