From 9db8162d658100bbf0a5a151996f9c3396ff1791 Mon Sep 17 00:00:00 2001 From: Martin Au-Yeung Date: Sun, 27 Sep 2026 03:17:57 +0000 Subject: [PATCH] Remove the bundled `plugin-creator` skill (#48604) ## What changed - Delete the `plugin-creator` skill, its assets, reference docs, helper scripts, and associated Python tests. - Update the app-server skills context budget warning test to expect six omitted skills instead of seven. GitOrigin-RevId: 005b1ab7f2f7c2933e6c297d01541746cc489f68 --- .../app-server/tests/suite/v2/turn_start.rs | 2 +- .../assets/samples/plugin-creator/SKILL.md | 249 ------- .../samples/plugin-creator/agents/openai.yaml | 6 - .../assets/plugin-creator-small.svg | 3 - .../plugin-creator/assets/plugin-creator.png | Bin 1563 -> 0 bytes .../references/installing-and-updating.md | 144 ---- .../references/plugin-json-spec.md | 218 ------ .../scripts/create_basic_plugin.py | 369 ---------- .../scripts/identifier_validation.py | 20 - .../scripts/read_marketplace_name.py | 53 -- .../scripts/update_plugin_cachebuster.py | 89 --- .../plugin-creator/scripts/validate_plugin.py | 657 ------------------ codex-rs/skills/tests/test_plugin_creator.py | 338 --------- 13 files changed, 1 insertion(+), 2147 deletions(-) delete mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md delete mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml delete mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator-small.svg delete mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator.png delete mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md delete mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md delete mode 100755 codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py delete mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/scripts/identifier_validation.py delete mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/scripts/read_marketplace_name.py delete mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/scripts/update_plugin_cachebuster.py delete mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py delete mode 100644 codex-rs/skills/tests/test_plugin_creator.py diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 13e5e96d6d..da6605b6d4 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1056,7 +1056,7 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( assert_eq!(warning.thread_id.as_deref(), Some(thread.id.as_str())); assert_eq!( warning.message, - "Exceeded skills context budget. All skill descriptions were removed and 7 additional skills were not included in the model-visible skills list." + "Exceeded skills context budget. All skill descriptions were removed and 6 additional skills were not included in the model-visible skills list." ); timeout( diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md deleted file mode 100644 index a1a6504d67..0000000000 --- a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md +++ /dev/null @@ -1,249 +0,0 @@ ---- -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/`. -python3 scripts/create_basic_plugin.py -``` - -2. Edit `/.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 ` 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 /plugins \ - --marketplace-path /.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 \ - --marketplace-path \ - --with-skills --with-hooks --with-scripts --with-assets --with-mcp --with-apps --with-marketplace -``` - -`` is the directory where the plugin folder `` will be -created (for example `~/plugins`). - -5. Before handing back a generated plugin, run: - -```bash -python3 scripts/validate_plugin.py -``` - -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/read_marketplace_name.py -python3 scripts/update_plugin_cachebuster.py -``` - -For a repo/team marketplace, pass `--marketplace-path ` to the first command. -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//`. -- Creates plugin root at `///`. -- Always creates `///.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. -- `` 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`. -- Plugin names must match `[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*`. -- Marketplace names must match `[A-Za-z0-9_-]+`. -- For existing marketplaces, always validate names with `scripts/read_marketplace_name.py`; stop if - validation fails. With no argument it reads the default personal marketplace; with an explicit - path it works for repo/team marketplaces too. The scaffold validates new marketplaces itself. -- Before updating existing plugins, validate both identifiers before changing files or constructing - install commands. -- In either location, the generated source path remains `./plugins/`. -- 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/`. -- 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 ` - 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 ` and `Share ` as Markdown links, not raw - URLs or code spans. -- The View deeplink uses `codex://plugins/?marketplacePath=`. - 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 ` or `Share ` 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 -``` diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml b/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml deleted file mode 100644 index 19a9a6fc5b..0000000000 --- a/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -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" diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator-small.svg b/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator-small.svg deleted file mode 100644 index c6e4f67c62..0000000000 --- a/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator-small.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator.png b/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator.png deleted file mode 100644 index 4f3d6d82fa78fbdce97af3c17f6a25c683aa3290..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1563 zcmV+$2ITpPP)1#i4F!Dtb@FnH%p6OalPi4TDy zMcTdqNqhk%K`*Qbx>_X{K4hvjHd<=hH0fq{Gsiib9J_g*IrFfyGygA?&1Rpl>Iw2qhkb ze|)YB#{A1DHe~H{=}oVwFPPA*37~O^y*^+fpb`thKY1$)zGE1LrVQMsu}jdkKwuIF z!Y>Y|aerZBkl8tR2W{Vc1+7>_fXyPMKjmfh7264^#1Q8f8yfM)Gsj@1QV6g#maH@I zP%I~8Ek1&*Sz<4`O%n=C#xm?`ka z@f*eCnG*Imx=o;)FU8GPEG9%?fE(jGa4*wlOu)drquT{LDa8yFs(BEJAz$>R*gPVE zqU#+{sHQ>IV#p$cqEO9(Y=$ALItt^-S@v#+A*(J*LO`5i$f|}?GICI;ra;_c$ihcq zX6#?7t76DPMwx(OH38xkLlzp!uBppVEb<_JVaNhUVVGdAj!ZzYNQ3x|AuFRSya%H+ z?dbZhQ0jjifQKRr5)?-HzW@Ca#_cg2y7h{`C>$TMzDr80D_JB#g2uq?7v-;0HimyF z%!H*6!ecrjR#+G^-I_yvejNAeo`a<|YBMrB2#6*u;OYX9V;b-SyY zmSuIfx3`<{v8qoM4|80YXzgSg7L69b@21RF+`z))FOtR$uxAc?!1W*3JruXoD~WPVK^^v5W;X* zpdf_dzQ8~TL#;qS2tz&JK?uWJzP@9~zdK179`pAt+{P25gyAjUK={;^Ves}2U?==6 zNf;tu_96V2E4^TR<-qwx`(A%qKJ|LP3BwTiGa(EWhXUIZ!jHo1q$CkT~d#9UdUR zH40%o$QuZQi$i}mMj?#VDT6HUKah5JtJNrku|D+)DV1De$d*y~ogWBegVa9;v@4x` z{E^m^dv>Y3)-b^IHelgvz;bpZlf!BnL!=DS*wpgU)oZ_Yc0Tbalu)#Ku-%=0qSQQw zNPQo|`ICR&zKgduf@(L>gD6zp1DQ&W{*=*XftU-Bx z{{AAaUi%GI*nGtbL!>kzrBZ3#>yIi-O6)L1N+5D`wYCsZVu>MA1<7o7SqLbx#Skfh zh!xgWmj{RvYYdUEo2<$LK#4tu$n`2~?FwtF{91jn{I@yA@4gUs43X;z*=U8eRh}nB zZ@!E}7h*pdGS?vX=F0UC71dDKEG$029y`&?4tIz02f3n9Xus>kIZ7E~2%2`79eMr( zBrMmT%M$w#UDy9A6bf}v=-|G+IQzzn<BbTJjk&~1^x!BXwGidKmHbOD6^2FPh=j>?` zzdy -``` - -2. Update the plugin manifest to a single Codex cachebuster suffix: - -```bash -python3 scripts/update_plugin_cachebuster.py \ - -``` - -The helper validates the plugin name before changing the manifest. If validation fails, stop. -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 \ - \ - --cachebuster local-20260519-184516 -``` - -3. Reinstall from that marketplace name: - -```bash -codex plugin add @ -``` - -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 @ -``` - -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 -+codex. -``` - -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 @`. -- For non-default marketplace files, use - `python3 scripts/read_marketplace_name.py --marketplace-path ` 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 ` before telling the user to run - `codex plugin add @`. -- 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. diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md deleted file mode 100644 index 40d3ae6b54..0000000000 --- a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md +++ /dev/null @@ -1,218 +0,0 @@ -# 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", - "logoDark": "./assets/logo-dark.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` or `object`): MCP config path, or an object whose keys are MCP server names and whose values are MCP server config objects. -- `apps` (`string`): App manifest path for plugin integrations. -- `interface` (`object`): Interface/UX metadata block for plugin presentation. - -`mcpServers` may be declared as a companion file path: - -```json -{ - "mcpServers": "./.mcp.json" -} -``` - -Or as an object directly in `plugin.json`: - -```json -{ - "mcpServers": { - "counter": { - "type": "http", - "url": "https://sample.example/counter/mcp" - } - } -} -``` - -### `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. -- `logoDark` (`string`): Optional path to the logo asset used in dark mode. -- `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 string-valued `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: `/.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/` - - Repo/team plugin: `./plugins/` - - The same relative path convention is used for both personal and repo/team marketplaces. - - Example: with `~/.agents/plugins/marketplace.json`, `./plugins/` resolves to - `~/plugins/`. -- `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: `/.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`, `logoDark`, and `screenshots` must point to real files inside the plugin archive when - present. -- `apps` should appear in `plugin.json` only when `.app.json` actually exists. -- `mcpServers` may point to `.mcp.json` or contain the MCP server object directly in - `plugin.json`. -- Validation rejects unsupported manifest fields such as `hooks`, so the scaffold keeps them out of - generated manifests. -- Run `scripts/validate_plugin.py ` before handing back a generated plugin. It adds one - intentional preflight check that rejects leftover `[TODO: ...]` placeholders. diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py deleted file mode 100755 index 40edaa8600..0000000000 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py +++ /dev/null @@ -1,369 +0,0 @@ -#!/usr/bin/env python3 -"""Scaffold a plugin directory and optionally update marketplace.json.""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from pathlib import Path -from typing import Any - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from identifier_validation import validate_marketplace_name - - -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 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 load_validated_marketplace( - marketplace_path: Path, - marketplace_name: str | None, - plugin_name: str, - force: bool, -) -> dict[str, Any]: - 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 ( - not isinstance(existing_marketplace_name, str) - or not existing_marketplace_name.strip() - ): - raise ValueError(f"{marketplace_path} must contain a non-empty string 'name'.") - validate_marketplace_name(existing_marketplace_name) - - if marketplace_name is not None: - 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.") - if not force and any( - isinstance(entry, dict) and entry.get("name") == plugin_name - for entry in plugins - ): - raise FileExistsError( - f"Marketplace entry '{plugin_name}' already exists in {marketplace_path}. " - "Use --force to overwrite that entry." - ) - - return payload - - -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: - payload = load_validated_marketplace( - marketplace_path, marketplace_name, plugin_name, force - ) - plugins = payload["plugins"] - - 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: - 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 /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 /.agents/plugins/marketplace.json by default. " - "Marketplace entries always point to ./plugins/ relative to the " - "marketplace root." - ), - ) - parser.add_argument( - "--marketplace-path", - default=str(DEFAULT_MARKETPLACE_PATH), - help=( - "Path to marketplace.json (defaults to /.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) - - if args.with_marketplace: - marketplace_path = Path(args.marketplace_path).expanduser().resolve() - load_validated_marketplace( - marketplace_path, marketplace_name, plugin_name, args.force - ) - - 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: - 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() diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/identifier_validation.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/identifier_validation.py deleted file mode 100644 index 41a1a2f1b5..0000000000 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/identifier_validation.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Validate plugin and marketplace identifiers before using them in commands.""" - -import re - - -def validate_plugin_identifier(plugin_name: str) -> None: - if re.fullmatch(r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*", plugin_name) is None: - raise ValueError( - "Plugin name may only contain ASCII letters, digits, `.`, `_`, and `-`, " - "with dots separating non-empty name segments." - ) - - -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 `-`." - ) diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/read_marketplace_name.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/read_marketplace_name.py deleted file mode 100644 index 249b564e38..0000000000 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/read_marketplace_name.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/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 - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from identifier_validation import validate_marketplace_name - - -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'.") - validate_marketplace_name(name) - print(name) - - -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 diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/update_plugin_cachebuster.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/update_plugin_cachebuster.py deleted file mode 100644 index 7818f15b5c..0000000000 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/update_plugin_cachebuster.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/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 - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from identifier_validation import validate_plugin_identifier - - -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. 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) - - plugin_name = manifest.get("name") - if not isinstance(plugin_name, str) or not plugin_name.strip(): - raise ValueError(f"{manifest_path} must contain a non-empty string 'name'.") - validate_plugin_identifier(plugin_name) - - 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 diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py deleted file mode 100644 index b5be462c3b..0000000000 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py +++ /dev/null @@ -1,657 +0,0 @@ -#!/usr/bin/env python3 -"""Validate a generated plugin against the plugin ingestion contract.""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from pathlib import Path, PurePosixPath -from typing import Any -from urllib.parse import urlparse - -import yaml - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from identifier_validation import validate_plugin_identifier - - -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) - plugin_name = require_non_empty_string(manifest, "name", errors) - if plugin_name is not None: - try: - validate_plugin_identifier(plugin_name) - except ValueError as error: - errors.append(f"plugin.json field `name` is invalid: {error}") - 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_manifest_mcp_servers(plugin_root, manifest, errors) - - if manifest.get("apps") is not None: - validate_app_manifest( - plugin_root / ".app.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", - "logoDark", - "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", "logoDark"): - 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 validate_manifest_mcp_servers( - plugin_root: Path, - manifest: dict[str, Any], - errors: list[str], -) -> None: - value = manifest.get("mcpServers") - if value is None: - return - if isinstance(value, str): - validate_optional_contract_path(manifest, "mcpServers", ".mcp.json", errors) - validate_mcp_manifest( - plugin_root / ".mcp.json", - errors, - ) - return - if isinstance(value, dict): - validate_mcp_server_entries( - value, - "plugin.json field `mcpServers`", - "plugin.json field `mcpServers`", - errors, - ) - return - errors.append("plugin.json field `mcpServers` must be a string path or object") - - -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", "category"}, 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" - ) - category = value.get("category") - if category is not None and ( - not isinstance(category, str) or not category.strip() - ): - errors.append( - f"`.app.json` app `{key}` field `category` 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") - validate_mcp_server_entries( - servers, - "`.mcp.json`", - "`.mcp.json` field `mcpServers`", - errors, - ) - - -def validate_mcp_server_entries( - servers: Any, - source_label: str, - field_label: str, - errors: list[str], -) -> None: - if not isinstance(servers, dict): - errors.append(f"{field_label} must be an object") - return - for key, value in servers.items(): - if not isinstance(key, str) or not key.strip(): - errors.append(f"{source_label} server names must be non-empty strings") - if not isinstance(value, dict): - errors.append(f"{source_label} 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() diff --git a/codex-rs/skills/tests/test_plugin_creator.py b/codex-rs/skills/tests/test_plugin_creator.py deleted file mode 100644 index ea930e13b9..0000000000 --- a/codex-rs/skills/tests/test_plugin_creator.py +++ /dev/null @@ -1,338 +0,0 @@ -"""Local-only regression tests for the bundled plugin-creator scripts. - -Run with: - python3 -B -m unittest discover -s codex-rs/skills/tests -p 'test_*.py' -""" - -import json -import runpy -import subprocess -import sys -import tempfile -import types -import unittest -from pathlib import Path -from unittest.mock import patch - - -SCRIPTS = ( - Path(__file__).resolve().parents[1] - / "src" - / "assets" - / "samples" - / "plugin-creator" - / "scripts" -) - - -class PluginCreatorSecurityTests(unittest.TestCase): - def setUp(self) -> None: - self.directory = tempfile.TemporaryDirectory() - self.addCleanup(self.directory.cleanup) - self.root = Path(self.directory.name) - self.marketplace_path = self.root / "marketplace.json" - self.plugin_root = self.root / "plugins" / "demo" - - def run_script( - self, script: str, *arguments: str - ) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [sys.executable, "-B", str(SCRIPTS / script), *arguments], - capture_output=True, - text=True, - check=False, - ) - - def write_marketplace(self, name: object) -> None: - self.marketplace_path.write_text( - json.dumps({"name": name, "plugins": []}), - encoding="utf-8", - ) - - def write_plugin(self, name: object) -> Path: - manifest_path = self.plugin_root / ".codex-plugin" / "plugin.json" - manifest_path.parent.mkdir(parents=True, exist_ok=True) - manifest_path.write_text( - json.dumps({"name": name, "version": "1.0.0"}), - encoding="utf-8", - ) - return manifest_path - - def plugin_validation_errors(self, name: str) -> list[str]: - with ( - patch.dict(sys.modules, {"yaml": types.ModuleType("yaml")}), - patch.object(sys, "path", [str(SCRIPTS), *sys.path]), - patch.object(sys, "dont_write_bytecode", True), - ): - scaffold = runpy.run_path(str(SCRIPTS / "create_basic_plugin.py")) - validator = runpy.run_path(str(SCRIPTS / "validate_plugin.py")) - - manifest = scaffold["build_plugin_json"]( - "demo", with_mcp=False, with_apps=False - ) - manifest["name"] = name - errors: list[str] = [] - validator["validate_manifest_shape"](self.plugin_root, manifest, errors) - return errors - - def test_marketplace_reader_accepts_valid_names(self) -> None: - for name in ("personal", "team-local", "team_local_123", "ABC123_-", "_", "-"): - with self.subTest(name=name): - self.write_marketplace(name) - result = self.run_script( - "read_marketplace_name.py", - "--marketplace-path", - str(self.marketplace_path), - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, f"{name}\n") - - def test_marketplace_reader_rejects_unsafe_names_without_stdout(self) -> None: - unsafe_names = ( - "team;id", - "team\nlocal", - "team local", - " team", - "team ", - "team'local", - 'team"local', - "team$(id)", - "team`id`", - "team&local", - "team|local", - "teamlocal", - "team.local", - "équipe", - "", - ) - for name in unsafe_names: - with self.subTest(name=name): - self.write_marketplace(name) - result = self.run_script( - "read_marketplace_name.py", - "--marketplace-path", - str(self.marketplace_path), - ) - self.assertNotEqual(result.returncode, 0) - self.assertEqual(result.stdout, "") - - def test_marketplace_reader_rejects_missing_and_nonstring_names(self) -> None: - for payload in ({}, {"name": None}, {"name": 123}, {"name": ["team"]}, []): - with self.subTest(payload=payload): - self.marketplace_path.write_text(json.dumps(payload), encoding="utf-8") - result = self.run_script( - "read_marketplace_name.py", - "--marketplace-path", - str(self.marketplace_path), - ) - self.assertNotEqual(result.returncode, 0) - self.assertEqual(result.stdout, "") - - def test_marketplace_reader_rejects_malformed_json(self) -> None: - self.marketplace_path.write_text("{", encoding="utf-8") - result = self.run_script( - "read_marketplace_name.py", - "--marketplace-path", - str(self.marketplace_path), - ) - self.assertNotEqual(result.returncode, 0) - self.assertEqual(result.stdout, "") - - def test_plugin_validator_accepts_canonical_names(self) -> None: - for name in ("demo", "Team_Name-1", "team.tools", "team.tools_v2"): - with self.subTest(name=name): - self.assertEqual(self.plugin_validation_errors(name), []) - - def test_plugin_validator_rejects_unsafe_names(self) -> None: - unsafe_names = ( - "safe;id", - "$(id)", - "safe\nid", - "safe name", - "safe`id`", - ".hidden", - "trailing.", - "safe..name", - "équipe", - ) - for name in unsafe_names: - with self.subTest(name=name): - errors = self.plugin_validation_errors(name) - self.assertTrue(errors) - self.assertTrue(any("name" in error for error in errors)) - - def test_cachebuster_rejects_unsafe_plugin_names_without_writing(self) -> None: - for name in ("safe;id", "$(id)", "safe\nid", ".hidden", "safe..name"): - with self.subTest(name=name): - manifest_path = self.write_plugin(name) - original = manifest_path.read_bytes() - result = self.run_script( - "update_plugin_cachebuster.py", str(self.plugin_root) - ) - self.assertNotEqual(result.returncode, 0) - self.assertEqual(result.stdout, "") - self.assertEqual(manifest_path.read_bytes(), original) - - def test_cachebuster_accepts_dotted_plugin_names(self) -> None: - manifest_path = self.write_plugin("demo.tools") - result = self.run_script( - "update_plugin_cachebuster.py", - str(self.plugin_root), - "--cachebuster", - "safe-token", - ) - self.assertEqual(result.returncode, 0, result.stderr) - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - self.assertEqual(manifest["name"], "demo.tools") - self.assertEqual(manifest["version"], "1.0.0+codex.safe-token") - - def test_scaffold_rejects_invalid_marketplace_before_creating_files(self) -> None: - self.write_marketplace("team;id") - original = self.marketplace_path.read_bytes() - result = self.run_script( - "create_basic_plugin.py", - "demo", - "--path", - str(self.root / "plugins"), - "--with-marketplace", - "--marketplace-path", - str(self.marketplace_path), - ) - self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.plugin_root.exists()) - self.assertEqual(self.marketplace_path.read_bytes(), original) - - def test_scaffold_force_preserves_files_when_marketplace_is_invalid(self) -> None: - self.write_marketplace("team;id") - plugin_manifest = self.write_plugin("demo") - mcp_manifest = self.plugin_root / ".mcp.json" - app_manifest = self.plugin_root / ".app.json" - mcp_manifest.write_text('{"mcpServers":{"existing":{}}}', encoding="utf-8") - app_manifest.write_text('{"apps":{"existing":{}}}', encoding="utf-8") - originals = { - path: path.read_bytes() - for path in ( - self.marketplace_path, - plugin_manifest, - mcp_manifest, - app_manifest, - ) - } - - result = self.run_script( - "create_basic_plugin.py", - "demo", - "--path", - str(self.root / "plugins"), - "--with-marketplace", - "--marketplace-path", - str(self.marketplace_path), - "--with-mcp", - "--with-apps", - "--with-skills", - "--force", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertFalse((self.plugin_root / "skills").exists()) - for path, original in originals.items(): - with self.subTest(path=path): - self.assertEqual(path.read_bytes(), original) - - def test_scaffold_force_preserves_files_when_plugins_field_is_invalid(self) -> None: - self.marketplace_path.write_text( - json.dumps({"name": "team-local", "plugins": {}}), - encoding="utf-8", - ) - plugin_manifest = self.write_plugin("demo") - mcp_manifest = self.plugin_root / ".mcp.json" - app_manifest = self.plugin_root / ".app.json" - mcp_manifest.write_text('{"mcpServers":{"existing":{}}}', encoding="utf-8") - app_manifest.write_text('{"apps":{"existing":{}}}', encoding="utf-8") - originals = { - path: path.read_bytes() - for path in ( - self.marketplace_path, - plugin_manifest, - mcp_manifest, - app_manifest, - ) - } - - result = self.run_script( - "create_basic_plugin.py", - "demo", - "--path", - str(self.root / "plugins"), - "--with-marketplace", - "--marketplace-path", - str(self.marketplace_path), - "--with-mcp", - "--with-apps", - "--with-skills", - "--force", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertFalse((self.plugin_root / "skills").exists()) - for path, original in originals.items(): - with self.subTest(path=path): - self.assertEqual(path.read_bytes(), original) - - def test_scaffold_rejects_duplicate_marketplace_entry_before_creating_files( - self, - ) -> None: - self.marketplace_path.write_text( - json.dumps({"name": "team-local", "plugins": [{"name": "demo"}]}), - encoding="utf-8", - ) - original = self.marketplace_path.read_bytes() - result = self.run_script( - "create_basic_plugin.py", - "demo", - "--path", - str(self.root / "plugins"), - "--with-marketplace", - "--marketplace-path", - str(self.marketplace_path), - ) - self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.plugin_root.exists()) - self.assertEqual(self.marketplace_path.read_bytes(), original) - - def test_scaffold_accepts_existing_valid_marketplace(self) -> None: - self.write_marketplace("team-local_123") - result = self.run_script( - "create_basic_plugin.py", - "demo", - "--path", - str(self.root / "plugins"), - "--with-marketplace", - "--marketplace-path", - str(self.marketplace_path), - ) - self.assertEqual(result.returncode, 0, result.stderr) - marketplace = json.loads(self.marketplace_path.read_text(encoding="utf-8")) - self.assertEqual(marketplace["name"], "team-local_123") - self.assertEqual(marketplace["plugins"][0]["name"], "demo") - - def test_scaffold_creates_missing_personal_marketplace(self) -> None: - self.assertFalse(self.marketplace_path.exists()) - result = self.run_script( - "create_basic_plugin.py", - "demo", - "--path", - str(self.root / "plugins"), - "--with-marketplace", - "--marketplace-path", - str(self.marketplace_path), - ) - self.assertEqual(result.returncode, 0, result.stderr) - marketplace = json.loads(self.marketplace_path.read_text(encoding="utf-8")) - self.assertEqual(marketplace["name"], "personal") - self.assertEqual(marketplace["plugins"][0]["name"], "demo") - - -if __name__ == "__main__": - unittest.main()