diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md index dde5779e6c1b..af960ec50c65 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: plugin-creator -description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, or generate or update marketplace entries for plugin ordering and availability metadata. +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 @@ -10,11 +10,11 @@ description: Create and scaffold plugin directories for Codex with a required `. 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 repo root (or replace .agents/... with the absolute path to this SKILL). -# By default creates in ~/plugins/. -python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py +# 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. @@ -23,40 +23,66 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py ` 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 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ +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 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ +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 `~/code/plugins`). +`` is the directory where the plugin folder `` will be +created (for example `~/plugins`). 5. Before handing back a generated plugin, run: ```bash -python3 .agents/skills/plugin-creator/scripts/validate_plugin.py +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/update_plugin_cachebuster.py +``` + +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 are personal: `~/plugins//` plus - `~/.agents/plugins/marketplace.json`. +- 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. @@ -77,9 +103,18 @@ python3 .agents/skills/plugin-creator/scripts/validate_plugin.py ## Marketplace workflow -- Personal creation defaults to `~/.agents/plugins/marketplace.json`. +- 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/`. - 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. @@ -157,6 +192,21 @@ python3 .agents/skills/plugin-creator/scripts/validate_plugin.py - 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 @@ -175,17 +225,19 @@ python3 .agents/skills/plugin-creator/scripts/validate_plugin.py 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 /scripts/quick_validate.py .agents/skills/plugin-creator +python3 ../skill-creator/scripts/quick_validate.py . ``` Before handing back a generated plugin, run: ```bash -python3 .agents/skills/plugin-creator/scripts/validate_plugin.py +python3 scripts/validate_plugin.py ``` diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md new file mode 100644 index 000000000000..28b3b8890815 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md @@ -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 \ + +``` + +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 +``` + +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 +``` + +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 index 0fd444e64b05..5eb2251a0efa 100644 --- 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 @@ -147,7 +147,8 @@ personal marketplace unless the caller explicitly requests a repo-local destinat - 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/`. + - 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` @@ -168,6 +169,8 @@ personal marketplace unless the caller explicitly requests a repo-local destinat - 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` 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 index fb882ce99aaa..78b9fca8fc95 100755 --- 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 @@ -11,15 +11,14 @@ MAX_PLUGIN_NAME_LENGTH = 64 -DEFAULT_PLUGIN_PARENT = Path.home() / "plugins" -DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.json" DEFAULT_INSTALL_POLICY = "AVAILABLE" DEFAULT_AUTH_POLICY = "ON_INSTALL" DEFAULT_CATEGORY = "Productivity" DEFAULT_MARKETPLACE_NAME = "personal" -DEFAULT_MARKETPLACE_DISPLAY_NAME = "Personal" VALID_INSTALL_POLICIES = {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"} VALID_AUTH_POLICIES = {"ON_INSTALL", "ON_USE"} +DEFAULT_PLUGIN_PARENT = Path.home() / "plugins" +DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.json" def normalize_plugin_name(plugin_name: str) -> str: @@ -41,8 +40,17 @@ def validate_plugin_name(plugin_name: str) -> None: ) +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 plugin_name.split("-")) + 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]: @@ -97,11 +105,11 @@ def load_json(path: Path) -> dict[str, Any]: return json.load(handle) -def build_default_marketplace() -> dict[str, Any]: +def build_default_marketplace(marketplace_name: str) -> dict[str, Any]: return { - "name": DEFAULT_MARKETPLACE_NAME, + "name": marketplace_name, "interface": { - "displayName": DEFAULT_MARKETPLACE_DISPLAY_NAME, + "displayName": display_name_from_plugin_name(marketplace_name), }, "plugins": [], } @@ -115,6 +123,7 @@ def validate_marketplace_interface(payload: dict[str, Any]) -> None: def update_marketplace_json( marketplace_path: Path, + marketplace_name: str | None, plugin_name: str, install_policy: str, auth_policy: str, @@ -124,13 +133,24 @@ def update_marketplace_json( if marketplace_path.exists(): payload = load_json(marketplace_path) else: - payload = build_default_marketplace() + 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.") @@ -206,6 +226,13 @@ def parse_args() -> argparse.Namespace: "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, @@ -234,6 +261,10 @@ def main() -> None: 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) @@ -275,6 +306,7 @@ def main() -> None: marketplace_path = Path(args.marketplace_path).expanduser().resolve() update_marketplace_json( marketplace_path, + marketplace_name, plugin_name, args.install_policy, args.auth_policy, 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 new file mode 100644 index 000000000000..597e9f7a4203 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/read_marketplace_name.py @@ -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 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 new file mode 100644 index 000000000000..82d19e5dfda1 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/update_plugin_cachebuster.py @@ -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. 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