feat(skills): add plugin sideload workflow

This commit is contained in:
Casey Chow
2026-05-06 17:26:04 -04:00
parent 8f5d68f9d2
commit 374652fe23
5 changed files with 341 additions and 5 deletions
@@ -17,7 +17,8 @@ description: Create and scaffold plugin directories for Codex with a required `.
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py <plugin-name>
```
2. Open `<plugin-path>/.codex-plugin/plugin.json` and replace `[TODO: ...]` placeholders.
2. Open `<plugin-path>/.codex-plugin/plugin.json` and replace `[TODO: ...]` placeholders only
with real values from the user. Ask for needed values, and leave irrelevant fields blank.
3. Generate or update the repo marketplace entry when the plugin should appear in Codex UI ordering:
@@ -49,9 +50,15 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin -
- If the user has not made the plugin location explicit, ask whether they want a repo-local plugin or a home-local plugin before generating marketplace entries.
- Creates plugin root at `/<parent-plugin-directory>/<plugin-name>/`.
- Always creates `/<parent-plugin-directory>/<plugin-name>/.codex-plugin/plugin.json`.
- Fills the manifest with the full schema shape, placeholder values, and the complete `interface` section.
- The scaffold script may create a broad manifest template. After running it, edit
`.codex-plugin/plugin.json` so optional metadata reflects real user input:
- keep `"name"`
- keep component paths such as `"skills"`, `"mcpServers"`, or `"apps"` only when the matching files or directories exist
- ask the user for real description, version, and interface metadata before filling them
- leave irrelevant or unknown optional fields blank rather than inventing author, URL, license, prompt, icon, logo, or screenshot metadata
- For local testing after scaffold creation, use `plugin-sideload`; it installs the plugin into the temporary `$CODEX_HOME/plugins/cache` development cache.
- Creates or updates `<repo-root>/.agents/plugins/marketplace.json` when `--with-marketplace` is set.
- If the marketplace file does not exist yet, seed top-level `name` plus `interface.displayName` placeholders before adding the first plugin entry.
- If the marketplace file does not exist yet, replace placeholder top-level marketplace metadata with real values, or ask the user before leaving the file as part of the result.
- `<plugin-name>` is normalized using skill-creator naming rules:
- `My Plugin` → `my-plugin`
- `My--Plugin` → `my-plugin`
@@ -138,7 +145,8 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin -
- Outer folder name and `plugin.json` `"name"` are always the same normalized plugin name.
- Do not remove required structure; keep `.codex-plugin/plugin.json` present.
- Keep manifest values as placeholders until a human or follow-up step explicitly fills them.
- Before replacing placeholder optional metadata, ask the user for the real value or leave the field blank when it is not relevant.
- Do not replace placeholders with invented values.
- 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.
@@ -1,6 +1,6 @@
interface:
display_name: "Plugin Creator"
short_description: "Scaffold plugins and marketplace entries"
default_prompt: "Use $plugin-creator to scaffold a plugin with placeholder plugin.json, optional structure, and a marketplace.json entry."
default_prompt: "Use $plugin-creator to scaffold a plugin. Ask before filling unknown plugin.json or marketplace.json fields, and leave irrelevant fields blank rather than inventing values. Use $plugin-sideload next when the user wants to install the local plugin into the temporary Codex cache for testing."
icon_small: "./assets/plugin-creator-small.svg"
icon_large: "./assets/plugin-creator.png"
@@ -0,0 +1,97 @@
---
name: plugin-sideload
description: Install a local Codex plugin into the temporary local Codex plugin cache for development and testing. Use after plugin-creator or when a user asks to try, reinstall, refresh, or test a local plugin in Codex without publishing it.
---
# Plugin Sideload
Use this skill to copy a local plugin into Codex's local plugin cache so it can be tested in the current Codex installation.
This is a development workflow. The plugin cache is temporary runtime state under `$CODEX_HOME/plugins/cache` and can be overwritten, refreshed, deleted, or replaced by Codex at any time. Do not treat it as source storage, a publication mechanism, or a durable install location.
## Quick Start
Install a local plugin source directory into the dev marketplace cache and enable it:
```bash
python3 .agents/skills/plugin-sideload/scripts/install_plugin_to_cache.py <plugin-path>
```
On Windows, run the same script with the Python launcher or `python`:
```powershell
py -3 .agents\skills\plugin-sideload\scripts\install_plugin_to_cache.py <plugin-path>
```
The script:
- reads `<plugin-path>/.codex-plugin/plugin.json`
- copies the plugin to `$CODEX_HOME/plugins/cache/dev/<plugin-name>/local`
- associates and enables the cached plugin by writing or updating this valid `$CODEX_HOME/config.toml` entry:
```toml
[plugins."<plugin-name>@dev"]
enabled = true
```
Restart Codex after installation so the refreshed plugin cache and enabled plugin entry are picked up.
## Common Workflows
Reinstall after editing plugin files:
```bash
python3 .agents/skills/plugin-sideload/scripts/install_plugin_to_cache.py <plugin-path>
```
Use a custom marketplace namespace:
```bash
python3 .agents/skills/plugin-sideload/scripts/install_plugin_to_cache.py <plugin-path> --marketplace debug
```
Install to a specific cache version:
```bash
python3 .agents/skills/plugin-sideload/scripts/install_plugin_to_cache.py <plugin-path> --version local
```
Install without modifying `$CODEX_HOME/config.toml`:
```bash
python3 .agents/skills/plugin-sideload/scripts/install_plugin_to_cache.py <plugin-path> --no-enable
```
Use a non-default Codex home:
```bash
python3 .agents/skills/plugin-sideload/scripts/install_plugin_to_cache.py <plugin-path> --codex-home <path>
```
## Behavior
- Defaults `--codex-home` to `$CODEX_HOME`, then `~/.codex`.
- Defaults `--marketplace` to `dev`.
- Defaults `--version` to `local`.
- Requires plugin and marketplace names to use only ASCII letters, digits, `_`, and `-`.
- Requires cache version names to use only ASCII letters, digits, `.`, `+`, `_`, and `-`.
- Replaces the existing cache entry for the same `<marketplace>/<plugin>` with the newly copied plugin.
- Associates the plugin in `config.toml` by plugin key (`<plugin>@<marketplace>`), not by writing a `source` field; plugin source files stay in the cache path.
- Skips symlinks while copying, matching Codex's plugin cache behavior.
- Works on macOS, Linux, and Windows with Python 3 and only the standard library.
## When To Use Marketplace Workflows Instead
Use `plugin-creator` or a real marketplace workflow when the goal is to publish, share, or persist plugin source metadata. This skill is only for local development installs into a disposable cache.
## Validation
After changing the script, run a smoke test with a temporary Codex home:
```bash
python3 codex-rs/skills/src/assets/samples/plugin-sideload/scripts/install_plugin_to_cache.py \
<plugin-path> \
--codex-home <temporary-codex-home>
```
Confirm the output path exists and the config contains the enabled plugin entry.
@@ -0,0 +1,4 @@
interface:
display_name: "Plugin Sideload"
short_description: "Install local plugins into the temporary Codex cache"
default_prompt: "Use $plugin-sideload to install a local plugin into the temporary Codex plugin cache for development testing."
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Install a local Codex plugin into the temporary local plugin cache."""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import tempfile
from pathlib import Path
from typing import Any
DEFAULT_MARKETPLACE = "dev"
DEFAULT_VERSION = "local"
PLUGIN_MANIFEST_RELATIVE_PATH = Path(".codex-plugin") / "plugin.json"
PLUGIN_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
PLUGIN_VERSION_RE = re.compile(r"^[A-Za-z0-9_.+-]+$")
def default_codex_home() -> Path:
codex_home = os.environ.get("CODEX_HOME")
if codex_home:
return Path(codex_home).expanduser()
return Path.home() / ".codex"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Copy a local plugin into $CODEX_HOME/plugins/cache for development. "
"The cache is temporary and can be overwritten by Codex at any time."
)
)
parser.add_argument("plugin_path", help="Local plugin source directory")
parser.add_argument(
"--codex-home",
default=str(default_codex_home()),
help="Codex home directory (defaults to $CODEX_HOME, then ~/.codex)",
)
parser.add_argument(
"--marketplace",
default=DEFAULT_MARKETPLACE,
help=f"Marketplace namespace for the cache entry (default: {DEFAULT_MARKETPLACE})",
)
parser.add_argument(
"--version",
default=DEFAULT_VERSION,
help=f"Cache version directory to write (default: {DEFAULT_VERSION})",
)
parser.add_argument(
"--no-enable",
action="store_true",
help="Only copy into the plugin cache; do not update config.toml",
)
return parser.parse_args()
def validate_segment(value: str, kind: str) -> None:
if not value:
raise ValueError(f"invalid {kind}: must not be empty")
if not PLUGIN_SEGMENT_RE.fullmatch(value):
raise ValueError(
f"invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed"
)
def validate_version(value: str) -> None:
if not value:
raise ValueError("invalid plugin version: must not be empty")
if value in {".", ".."}:
raise ValueError("invalid plugin version: path traversal is not allowed")
if not PLUGIN_VERSION_RE.fullmatch(value):
raise ValueError(
"invalid plugin version: only ASCII letters, digits, `.`, `+`, `_`, and `-` are allowed"
)
def load_manifest(plugin_root: Path) -> dict[str, Any]:
manifest_path = plugin_root / PLUGIN_MANIFEST_RELATIVE_PATH
if not manifest_path.is_file():
raise FileNotFoundError(f"missing plugin manifest: {manifest_path}")
with manifest_path.open(encoding="utf-8") as handle:
manifest = json.load(handle)
if not isinstance(manifest, dict):
raise ValueError(f"{manifest_path} must contain a JSON object")
return manifest
def plugin_name_from_manifest(manifest: dict[str, Any]) -> str:
plugin_name = manifest.get("name")
if not isinstance(plugin_name, str):
raise ValueError("plugin.json field `name` must be a string")
plugin_name = plugin_name.strip()
validate_segment(plugin_name, "plugin name")
return plugin_name
def copy_plugin_tree(source: Path, target: Path) -> None:
target.mkdir(parents=True, exist_ok=True)
for entry in os.scandir(source):
source_path = Path(entry.path)
target_path = target / entry.name
if entry.is_dir(follow_symlinks=False):
copy_plugin_tree(source_path, target_path)
elif entry.is_file(follow_symlinks=False):
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_path, target_path)
def replace_cache_entry(source: Path, plugin_base: Path, version: str) -> Path:
parent = plugin_base.parent
parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="plugin-install-", dir=parent) as staging_dir:
staged_base = Path(staging_dir) / plugin_base.name
staged_version = staged_base / version
copy_plugin_tree(source, staged_version)
backup_base = None
if plugin_base.exists():
backup_base = Path(staging_dir) / f"{plugin_base.name}.backup"
plugin_base.rename(backup_base)
try:
staged_base.rename(plugin_base)
except Exception:
if backup_base is not None and backup_base.exists() and not plugin_base.exists():
backup_base.rename(plugin_base)
raise
return plugin_base / version
def update_config_enabled(codex_home: Path, plugin_key: str) -> None:
config_path = codex_home / "config.toml"
config_path.parent.mkdir(parents=True, exist_ok=True)
section_header = f'[plugins."{plugin_key}"]'
enabled_line = "enabled = true"
if config_path.exists():
contents = config_path.read_text(encoding="utf-8")
else:
contents = ""
lines = contents.splitlines()
section_start = next(
(index for index, line in enumerate(lines) if line.strip() == section_header),
None,
)
if section_start is None:
if contents and not contents.endswith("\n"):
contents += "\n"
if contents:
contents += "\n"
contents += f"{section_header}\n{enabled_line}\n"
config_path.write_text(contents, encoding="utf-8")
return
section_end = next(
(
index
for index in range(section_start + 1, len(lines))
if lines[index].lstrip().startswith("[")
),
len(lines),
)
lines = [
line
for index, line in enumerate(lines)
if not (
section_start < index < section_end
and re.match(r"^\s*source\s*=", line)
)
]
section_end = next(
(
index
for index in range(section_start + 1, len(lines))
if lines[index].lstrip().startswith("[")
),
len(lines),
)
for index in range(section_start + 1, section_end):
if re.match(r"^\s*enabled\s*=", lines[index]):
lines[index] = enabled_line
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return
lines.insert(section_start + 1, enabled_line)
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> None:
args = parse_args()
source = Path(args.plugin_path).expanduser().resolve()
codex_home = Path(args.codex_home).expanduser().resolve()
marketplace = args.marketplace
version = args.version
if not source.is_dir():
raise ValueError(f"plugin source path is not a directory: {source}")
validate_segment(marketplace, "marketplace name")
validate_version(version)
manifest = load_manifest(source)
plugin_name = plugin_name_from_manifest(manifest)
plugin_key = f"{plugin_name}@{marketplace}"
plugin_base = codex_home / "plugins" / "cache" / marketplace / plugin_name
installed_path = replace_cache_entry(source, plugin_base, version)
print(f"Installed plugin cache entry: {installed_path}")
print(f"Plugin key: {plugin_key}")
print("Note: this cache is temporary and can be overwritten by Codex at any time.")
if args.no_enable:
print("Skipped config.toml update because --no-enable was set.")
else:
update_config_enabled(codex_home, plugin_key)
print(f"Associated and enabled plugin in: {codex_home / 'config.toml'}")
print("Restart Codex to pick up the refreshed plugin cache.")
if __name__ == "__main__":
main()