#!/usr/bin/env python3 """Scaffold a plugin directory and optionally update marketplace.json.""" from __future__ import annotations import argparse import json import re from pathlib import Path from typing import Any 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 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 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 update_marketplace_json( marketplace_path: Path, marketplace_name: str | None, plugin_name: str, install_policy: str, auth_policy: str, category: str, force: bool, ) -> None: 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 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.") 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: if not force: raise FileExistsError( f"Marketplace entry '{plugin_name}' already exists in {marketplace_path}. " "Use --force to overwrite that entry." ) 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) 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: marketplace_path = Path(args.marketplace_path).expanduser().resolve() 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()