modify template
This commit is contained in:
@@ -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 repo’s 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()
|
||||
Reference in New Issue
Block a user