325 lines
11 KiB
Python
325 lines
11 KiB
Python
#!/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 <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)
|
|
|
|
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()
|