scripts/create_basic_plugin.py
scripts/create_basic_plugin.pyBrowse 11 files
2,553 tokens
11,731 bytes
Token encoding: o200k_base
Snapshot 18d7ace
← Back to SKILL.md
1#!/usr/bin/env python32"""Scaffold a plugin directory and optionally update marketplace.json."""3 4from __future__ import annotations5 6import argparse7import json8import re9import sys10from pathlib import Path11from typing import Any12 13sys.path.insert(0, str(Path(__file__).resolve().parent))14 15from identifier_validation import validate_marketplace_name16 17 18MAX_PLUGIN_NAME_LENGTH = 6419DEFAULT_INSTALL_POLICY = "AVAILABLE"20DEFAULT_AUTH_POLICY = "ON_INSTALL"21DEFAULT_CATEGORY = "Productivity"22DEFAULT_MARKETPLACE_NAME = "personal"23VALID_INSTALL_POLICIES = {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"}24VALID_AUTH_POLICIES = {"ON_INSTALL", "ON_USE"}25DEFAULT_PLUGIN_PARENT = Path.home() / "plugins"26DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.json"27 28 29def normalize_plugin_name(plugin_name: str) -> str:30 """Normalize a plugin name to lowercase hyphen-case."""31 normalized = plugin_name.strip().lower()32 normalized = re.sub(r"[^a-z0-9]+", "-", normalized)33 normalized = normalized.strip("-")34 normalized = re.sub(r"-{2,}", "-", normalized)35 return normalized36 37 38def validate_plugin_name(plugin_name: str) -> None:39 if not plugin_name:40 raise ValueError("Plugin name must include at least one letter or digit.")41 if len(plugin_name) > MAX_PLUGIN_NAME_LENGTH:42 raise ValueError(43 f"Plugin name '{plugin_name}' is too long ({len(plugin_name)} characters). "44 f"Maximum is {MAX_PLUGIN_NAME_LENGTH} characters."45 )46 47 48def display_name_from_plugin_name(plugin_name: str) -> str:49 return " ".join(part.capitalize() for part in re.split(r"[-_]+", plugin_name))50 51 52def build_plugin_json(53 plugin_name: str, *, with_mcp: bool, with_apps: bool54) -> dict[str, Any]:55 display_name = display_name_from_plugin_name(plugin_name)56 payload: dict[str, Any] = {57 "name": plugin_name,58 "version": "0.1.0",59 "description": f"{display_name} plugin",60 "author": {61 "name": "Local developer",62 },63 "skills": "./skills/",64 "interface": {65 "displayName": display_name,66 "shortDescription": f"Use {display_name} in Codex.",67 "longDescription": f"{display_name} adds a local Codex plugin scaffold.",68 "developerName": "Local developer",69 "category": DEFAULT_CATEGORY,70 "capabilities": [],71 "defaultPrompt": f"Help me use {display_name}.",72 },73 }74 if with_mcp:75 payload["mcpServers"] = "./.mcp.json"76 if with_apps:77 payload["apps"] = "./.app.json"78 return payload79 80 81def build_marketplace_entry(82 plugin_name: str,83 install_policy: str,84 auth_policy: str,85 category: str,86) -> dict[str, Any]:87 return {88 "name": plugin_name,89 "source": {90 "source": "local",91 "path": f"./plugins/{plugin_name}",92 },93 "policy": {94 "installation": install_policy,95 "authentication": auth_policy,96 },97 "category": category,98 }99 100 101def load_json(path: Path) -> dict[str, Any]:102 with path.open() as handle:103 return json.load(handle)104 105 106def build_default_marketplace(marketplace_name: str) -> dict[str, Any]:107 return {108 "name": marketplace_name,109 "interface": {110 "displayName": display_name_from_plugin_name(marketplace_name),111 },112 "plugins": [],113 }114 115 116def validate_marketplace_interface(payload: dict[str, Any]) -> None:117 interface = payload.get("interface")118 if interface is not None and not isinstance(interface, dict):119 raise ValueError("marketplace.json field 'interface' must be an object.")120 121 122def load_validated_marketplace(123 marketplace_path: Path,124 marketplace_name: str | None,125 plugin_name: str,126 force: bool,127) -> dict[str, Any]:128 if marketplace_path.exists():129 payload = load_json(marketplace_path)130 else:131 payload = build_default_marketplace(132 marketplace_name or DEFAULT_MARKETPLACE_NAME133 )134 135 if not isinstance(payload, dict):136 raise ValueError(f"{marketplace_path} must contain a JSON object.")137 138 validate_marketplace_interface(payload)139 140 existing_marketplace_name = payload.get("name")141 if (142 not isinstance(existing_marketplace_name, str)143 or not existing_marketplace_name.strip()144 ):145 raise ValueError(f"{marketplace_path} must contain a non-empty string 'name'.")146 validate_marketplace_name(existing_marketplace_name)147 148 if marketplace_name is not None:149 if existing_marketplace_name != marketplace_name:150 raise ValueError(151 f"{marketplace_path} already uses marketplace name "152 f"'{existing_marketplace_name}'. Create a new marketplace file to use "153 f"'{marketplace_name}' instead."154 )155 156 plugins = payload.setdefault("plugins", [])157 if not isinstance(plugins, list):158 raise ValueError(f"{marketplace_path} field 'plugins' must be an array.")159 if not force and any(160 isinstance(entry, dict) and entry.get("name") == plugin_name161 for entry in plugins162 ):163 raise FileExistsError(164 f"Marketplace entry '{plugin_name}' already exists in {marketplace_path}. "165 "Use --force to overwrite that entry."166 )167 168 return payload169 170 171def update_marketplace_json(172 marketplace_path: Path,173 marketplace_name: str | None,174 plugin_name: str,175 install_policy: str,176 auth_policy: str,177 category: str,178 force: bool,179) -> None:180 payload = load_validated_marketplace(181 marketplace_path, marketplace_name, plugin_name, force182 )183 plugins = payload["plugins"]184 185 new_entry = build_marketplace_entry(186 plugin_name, install_policy, auth_policy, category187 )188 189 for index, entry in enumerate(plugins):190 if isinstance(entry, dict) and entry.get("name") == plugin_name:191 plugins[index] = new_entry192 break193 else:194 plugins.append(new_entry)195 196 write_json(marketplace_path, payload, force=True)197 198 199def write_json(path: Path, data: dict, force: bool) -> None:200 if path.exists() and not force:201 raise FileExistsError(f"{path} already exists. Use --force to overwrite.")202 path.parent.mkdir(parents=True, exist_ok=True)203 with path.open("w") as handle:204 json.dump(data, handle, indent=2)205 handle.write("\n")206 207 208def create_stub_file(path: Path, payload: dict, force: bool) -> None:209 if path.exists() and not force:210 return211 path.parent.mkdir(parents=True, exist_ok=True)212 with path.open("w") as handle:213 json.dump(payload, handle, indent=2)214 handle.write("\n")215 216 217def parse_args() -> argparse.Namespace:218 parser = argparse.ArgumentParser(219 description="Create a plugin skeleton with a validation-ready plugin.json."220 )221 parser.add_argument("plugin_name")222 parser.add_argument(223 "--path",224 default=str(DEFAULT_PLUGIN_PARENT),225 help=(226 "Parent directory for plugin creation (defaults to <home>/plugins). "227 "Pass an explicit repo path only when a repo/team plugin is intended."228 ),229 )230 parser.add_argument(231 "--with-skills", action="store_true", help="Create skills/ directory"232 )233 parser.add_argument(234 "--with-hooks", action="store_true", help="Create hooks/ directory"235 )236 parser.add_argument(237 "--with-scripts", action="store_true", help="Create scripts/ directory"238 )239 parser.add_argument(240 "--with-assets", action="store_true", help="Create assets/ directory"241 )242 parser.add_argument(243 "--with-mcp", action="store_true", help="Create .mcp.json placeholder"244 )245 parser.add_argument(246 "--with-apps", action="store_true", help="Create .app.json placeholder"247 )248 parser.add_argument(249 "--with-marketplace",250 action="store_true",251 help=(252 "Create or update <home>/.agents/plugins/marketplace.json by default. "253 "Marketplace entries always point to ./plugins/<plugin-name> relative to the "254 "marketplace root."255 ),256 )257 parser.add_argument(258 "--marketplace-path",259 default=str(DEFAULT_MARKETPLACE_PATH),260 help=(261 "Path to marketplace.json (defaults to <home>/.agents/plugins/marketplace.json). "262 "Pass a repo-rooted marketplace path only when a repo/team plugin is intended."263 ),264 )265 parser.add_argument(266 "--marketplace-name",267 help=(268 "Marketplace name to seed into a new marketplace.json. Use this only when the default "269 "'personal' marketplace name is already taken and you need a different new marketplace."270 ),271 )272 parser.add_argument(273 "--install-policy",274 default=DEFAULT_INSTALL_POLICY,275 choices=sorted(VALID_INSTALL_POLICIES),276 help="Marketplace policy.installation value",277 )278 parser.add_argument(279 "--auth-policy",280 default=DEFAULT_AUTH_POLICY,281 choices=sorted(VALID_AUTH_POLICIES),282 help="Marketplace policy.authentication value",283 )284 parser.add_argument(285 "--category",286 default=DEFAULT_CATEGORY,287 help="Marketplace category value",288 )289 parser.add_argument("--force", action="store_true", help="Overwrite existing files")290 return parser.parse_args()291 292 293def main() -> None:294 args = parse_args()295 raw_plugin_name = args.plugin_name296 plugin_name = normalize_plugin_name(raw_plugin_name)297 if plugin_name != raw_plugin_name:298 print(299 f"Note: Normalized plugin name from '{raw_plugin_name}' to '{plugin_name}'."300 )301 validate_plugin_name(plugin_name)302 marketplace_name = None303 if args.marketplace_name is not None:304 marketplace_name = args.marketplace_name.strip()305 validate_marketplace_name(marketplace_name)306 307 if args.with_marketplace:308 marketplace_path = Path(args.marketplace_path).expanduser().resolve()309 load_validated_marketplace(310 marketplace_path, marketplace_name, plugin_name, args.force311 )312 313 plugin_root = Path(args.path).expanduser().resolve() / plugin_name314 plugin_root.mkdir(parents=True, exist_ok=True)315 316 plugin_json_path = plugin_root / ".codex-plugin" / "plugin.json"317 write_json(318 plugin_json_path,319 build_plugin_json(320 plugin_name, with_mcp=args.with_mcp, with_apps=args.with_apps321 ),322 args.force,323 )324 325 optional_directories = {326 "skills": args.with_skills,327 "hooks": args.with_hooks,328 "scripts": args.with_scripts,329 "assets": args.with_assets,330 }331 for folder, enabled in optional_directories.items():332 if enabled:333 (plugin_root / folder).mkdir(parents=True, exist_ok=True)334 335 if args.with_mcp:336 create_stub_file(337 plugin_root / ".mcp.json",338 {"mcpServers": {}},339 args.force,340 )341 342 if args.with_apps:343 create_stub_file(344 plugin_root / ".app.json",345 {346 "apps": {},347 },348 args.force,349 )350 351 if args.with_marketplace:352 update_marketplace_json(353 marketplace_path,354 marketplace_name,355 plugin_name,356 args.install_policy,357 args.auth_policy,358 args.category,359 args.force,360 )361 362 print(f"Created plugin scaffold: {plugin_root}")363 print(f"plugin manifest: {plugin_json_path}")364 if args.with_marketplace:365 print(f"marketplace manifest: {marketplace_path}")366 367 368if __name__ == "__main__":369 main()370 Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 16.SKILL.mdView in source ↗16# By default creates in `~/plugins/<plugin-name>`.17python3 scripts/create_basic_plugin.py <plugin-name>18```
Source excerpt starting at line 26.SKILL.mdView in source ↗26# Personal marketplace entries default to `~/.agents/plugins/marketplace.json`.27python3 scripts/create_basic_plugin.py my-plugin --with-marketplace28```
Source excerpt starting at line 33.SKILL.mdView in source ↗33```bash34python3 scripts/create_basic_plugin.py my-plugin \35 --with-marketplace \
Source excerpt starting at line 41.SKILL.mdView in source ↗41```bash42python3 scripts/create_basic_plugin.py my-plugin \43 --path <repo-root>/plugins \
Source excerpt starting at line 55.55```bash56python3 scripts/create_basic_plugin.py my-plugin \57 --path <parent-plugin-directory> \
