mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
feat(skills): add plugin sideload workflow
This commit is contained in:
@@ -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."
|
||||
+227
@@ -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()
|
||||
Reference in New Issue
Block a user