scripts/create_basic_plugin.py
2,553 tokens · o200k_base · 11,731 bytes
Source excerpt starting at line 1.#!/usr/bin/env python3"""Scaffold a plugin directory and optionally update marketplace.json.""" from __future__ import annotations import argparseimport jsonimport reimport sysfrom pathlib import Pathfrom typing import Any sys.path.insert(0, str(Path(__file__).resolve().parent)) from identifier_validation import validate_marketplace_name MAX_PLUGIN_NAME_LENGTH = 64DEFAULT_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 <home>/plugins). " "Pass an explicit repo path only when a repo/team plugin is intended." ), ) parser.add_argument( "--with-skills", action="store_true", help="Create skills/ directory" ) parser.add_argument( "--with-hooks", action="store_true", help="Create hooks/ directory" ) parser.add_argument( "--with-scripts", action="store_true", help="Create scripts/ directory" ) parser.add_argument( "--with-assets", action="store_true", help="Create assets/ directory" ) parser.add_argument( "--with-mcp", action="store_true", help="Create .mcp.json placeholder" ) parser.add_argument( "--with-apps", action="store_true", help="Create .app.json placeholder" ) parser.add_argument( "--with-marketplace", action="store_true", help=( "Create or update <home>/.agents/plugins/marketplace.json by default. " "Marketplace entries always point to ./plugins/<plugin-name> relative to the " "marketplace root." ), ) parser.add_argument( "--marketplace-path", default=str(DEFAULT_MARKETPLACE_PATH), help=( "Path to marketplace.json (defaults to <home>/.agents/plugins/marketplace.json). " "Pass a repo-rooted marketplace path only when a repo/team plugin is intended." ), ) parser.add_argument( "--marketplace-name", help=( "Marketplace name to seed into a new marketplace.json. Use this only when the default " "'personal' marketplace name is already taken and you need a different new marketplace." ), ) parser.add_argument( "--install-policy", default=DEFAULT_INSTALL_POLICY, choices=sorted(VALID_INSTALL_POLICIES), help="Marketplace policy.installation value", ) parser.add_argument( "--auth-policy", default=DEFAULT_AUTH_POLICY, choices=sorted(VALID_AUTH_POLICIES), help="Marketplace policy.authentication value", ) parser.add_argument( "--category", default=DEFAULT_CATEGORY, help="Marketplace category value", ) parser.add_argument("--force", action="store_true", help="Overwrite existing files") return parser.parse_args() def main() -> None: args = parse_args() raw_plugin_name = args.plugin_name plugin_name = normalize_plugin_name(raw_plugin_name) if plugin_name != raw_plugin_name: print( f"Note: Normalized plugin name from '{raw_plugin_name}' to '{plugin_name}'." ) validate_plugin_name(plugin_name) marketplace_name = None if args.marketplace_name is not None: marketplace_name = args.marketplace_name.strip() validate_marketplace_name(marketplace_name) 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()
Referenced from SKILL.md
Source excerpt starting at line 16.# By default creates in `~/plugins/<plugin-name>`.python3 scripts/create_basic_plugin.py <plugin-name>```
Source excerpt starting at line 26.# Personal marketplace entries default to `~/.agents/plugins/marketplace.json`.python3 scripts/create_basic_plugin.py my-plugin --with-marketplace```
Source excerpt starting at line 33.```bashpython3 scripts/create_basic_plugin.py my-plugin \ --with-marketplace \
Source excerpt starting at line 41.```bashpython3 scripts/create_basic_plugin.py my-plugin \ --path <repo-root>/plugins \
Source excerpt starting at line 55.```bashpython3 scripts/create_basic_plugin.py my-plugin \ --path <parent-plugin-directory> \