diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md index af960ec50c..e6c58c0762 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: plugin-creator -description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, generate or update marketplace entries for plugin ordering and availability metadata, or update an existing local plugin during development with the CLI-driven cachebuster and reinstall flow. +description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional components such as skills, hooks, apps, MCP servers, Scheduled tasks, scripts, and assets, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, generate or update marketplace entries, or update an existing local plugin with the cachebuster and reinstall flow. --- # Plugin Creator @@ -56,7 +56,8 @@ On Windows, use the equivalent path under the user profile. python3 scripts/create_basic_plugin.py my-plugin \ --path \ --marketplace-path \ - --with-skills --with-hooks --with-scripts --with-assets --with-mcp --with-apps --with-marketplace + --with-skills --with-hooks --with-scripts --with-assets --with-mcp --with-apps \ + --with-scheduled --with-marketplace ``` `` is the directory where the plugin folder `` will be @@ -98,6 +99,7 @@ See `references/installing-and-updating.md` for the expected cachebuster and rei - `hooks/` - `scripts/` - `assets/` + - `scheduled/` - `.mcp.json` - `.app.json` @@ -186,6 +188,8 @@ See `references/installing-and-updating.md` for the expected cachebuster and rei - Do not remove required structure; keep `.codex-plugin/plugin.json` present. - Do not leave `[TODO: ...]` placeholders in plugin manifests. - Keep `apps` and `mcpServers` out of `plugin.json` unless their companion files are actually created. +- Keep Scheduled task templates under `scheduled/`; do not declare them in `plugin.json`. Follow + `references/plugin-json-spec.md` for their schema and authoring guidance. - Omit unsupported plugin manifest fields that validation rejects, including `hooks`. - If creating files inside an existing plugin path, use `--force` only when overwrite is intentional. - Preserve any existing marketplace `interface.displayName`. @@ -222,7 +226,7 @@ See `references/installing-and-updating.md` for the expected cachebuster and rei ## Reference to exact spec sample -For the exact canonical sample JSON for both plugin manifests and marketplace entries, use: +For exact JSON contracts and authoring guidance, use: - `references/plugin-json-spec.md` - `references/installing-and-updating.md` for update/reinstall guidance while diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md index 40d3ae6b54..207de2d20a 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md @@ -118,6 +118,91 @@ Or as an object directly in `plugin.json`: - Custom path values must follow the plugin root convention and naming/namespacing rules. - This repo’s scaffold writes `.codex-plugin/plugin.json`; treat that as the manifest location this skill generates. +# Scheduled Task Template JSON sample spec + +Plugins can bundle recipes for recurring Codex work under the reserved `scheduled/` root: + +```text +my-plugin/ +├── .codex-plugin/ +│ └── plugin.json +├── skills/ +└── scheduled/ + ├── daily-review.json + └── weekly-triage.json +``` + +Do not add a `scheduled` field to `plugin.json`. Supporting Codex clients discover each +`scheduled/.json` file by convention. The filename stem is the template key and must +be lowercase kebab case. + +Every template contains exactly `name`, `prompt`, and `schedule`: + +```json +{ + "name": "Morning inbox triage", + "prompt": "Review my inbox since the previous workday and surface messages that need attention, with a prioritized next-action list.", + "schedule": { + "type": "weekdays", + "time": "08:30" + } +} +``` + +Supported schedule shapes are: + +```json +{ + "type": "hourly", + "intervalHours": 2, + "days": ["MO", "TU", "WE", "TH", "FR"] +} +``` + +`days` is optional for hourly schedules; omitting it runs on every day. `intervalHours` must be a +positive integer. + +```json +{ "type": "daily", "time": "09:00" } +``` + +```json +{ "type": "weekdays", "time": "09:00" } +``` + +```json +{ + "type": "weekly", + "days": ["TU", "TH"], + "time": "16:45" +} +``` + +Times use local wall-clock `HH:MM` in 24-hour format from `00:00` through `23:59`. Every `days` +array must be a nonempty, unique subset of `MO`, `TU`, `WE`, `TH`, `FR`, `SA`, and `SU`. + +### Authoring guidance + +- Choose a task that genuinely repeats and can be completed with the plugin's capabilities. +- Confirm its cadence, source scope, expected output, and whether it may make changes. +- Prefer one plugin's natural workflow. Use a purpose-built plugin when the recurring workflow + intentionally coordinates multiple connectors. +- Keep the default prompt concise but useful before personalization. Include the source and time + window, what deserves attention, the expected output, and an explicit read-only boundary when + appropriate. +- Avoid personal account identifiers, team names, destinations, and project-specific assumptions + that belong in the user's customized instance. +- Write real templates rather than placeholders, then run + `scripts/validate_plugin.py `. Strict JSON, valid filenames, and known fields matter + because Codex silently omits invalid templates. +- When adding templates to an existing installed plugin, use the cachebuster and reinstall flow so + Codex reads the updated materialized copy. + +Installing a plugin does not activate its templates. The user reviews a template and explicitly +creates an ordinary user-owned Scheduled task from it. Codex does not retain a live relationship to +the plugin or template, so plugin updates affect future creations only. Describe these as Codex +Scheduled task templates; do not promise availability as ChatGPT or cloud automations. + # Marketplace JSON sample spec `marketplace.json` depends on where the plugin should live. New plugin creation defaults to the diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py index 78b9fca8fc..7c428d1916 100755 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py @@ -207,6 +207,11 @@ def parse_args() -> argparse.Namespace: 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-scheduled", + action="store_true", + help="Create scheduled/ directory for Scheduled task template JSON files", + ) 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( @@ -281,6 +286,7 @@ def main() -> None: "hooks": args.with_hooks, "scripts": args.with_scripts, "assets": args.with_assets, + "scheduled": args.with_scheduled, } for folder, enabled in optional_directories.items(): if enabled: diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/test_validate_scheduled_tasks.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/test_validate_scheduled_tasks.py new file mode 100644 index 0000000000..80952aaae1 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/test_validate_scheduled_tasks.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Tests for plugin Scheduled task scaffolding and validation.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from create_basic_plugin import build_plugin_json # noqa: E402 +from validate_plugin import validate_plugin # noqa: E402 +from validate_scheduled_tasks import validate_scheduled_task_templates # noqa: E402 + + +class ScheduledTaskTemplateValidationTest(unittest.TestCase): + def test_accepts_every_schedule_shape(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + plugin_root = Path(temp_dir) + templates = { + "hourly.json": { + "type": "hourly", + "intervalHours": 2.0, + "days": ["MO", "WE", "FR"], + }, + "daily.json": {"type": "daily", "time": "07:05"}, + "weekdays.json": {"type": "weekdays", "time": "09:00"}, + "weekly.json": { + "type": "weekly", + "days": ["TU", "SU"], + "time": "23:59", + }, + } + for filename, schedule in templates.items(): + self._write_template(plugin_root, filename, schedule=schedule) + + self.assertEqual(validate_scheduled_task_templates(plugin_root), []) + + def test_reports_templates_codex_would_silently_omit(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + plugin_root = Path(temp_dir) + self._write_template( + plugin_root, + "Daily.JSON", + schedule={"type": "daily", "time": "09:00"}, + ) + self._write_template( + plugin_root, + "bad-hourly.json", + name="", + prompt=" ", + extra=True, + schedule={ + "type": "hourly", + "intervalHours": True, + "days": ["MO", "MO"], + "time": "09:00", + }, + ) + broken_path = plugin_root / "scheduled" / "broken.json" + broken_path.write_text("not-json", encoding="utf-8") + + self.assertEqual( + validate_scheduled_task_templates(plugin_root), + [ + "`scheduled/Daily.JSON` filename must be lowercase kebab-case ending in `.json`", + "`scheduled/bad-hourly.json` field `extra` is not supported", + "`scheduled/bad-hourly.json` field `name` must be a non-empty string", + "`scheduled/bad-hourly.json` field `prompt` must be a non-empty string", + "`scheduled/bad-hourly.json` field `schedule.time` is not supported", + "`scheduled/bad-hourly.json` field `schedule.intervalHours` must be a positive integer", + "`scheduled/bad-hourly.json` field `schedule.days` must contain unique weekdays from `MO` through `SU`", + "`scheduled/broken.json` must contain strict standard JSON", + ], + ) + + def test_plugin_validator_includes_scheduled_task_errors(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + plugin_root = Path(temp_dir) / "reporting" + manifest_path = plugin_root / ".codex-plugin" / "plugin.json" + manifest_path.parent.mkdir(parents=True) + manifest_path.write_text( + json.dumps( + build_plugin_json( + "reporting", + with_mcp=False, + with_apps=False, + ) + ), + encoding="utf-8", + ) + self._write_template( + plugin_root, + "report.json", + schedule={"type": "daily", "time": "9:00"}, + ) + + self.assertEqual( + validate_plugin(plugin_root), + [ + "`scheduled/report.json` field `schedule.time` must use 24-hour `HH:MM` format" + ], + ) + + def test_scaffold_can_create_an_empty_scheduled_directory(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + script_path = Path(__file__).with_name("create_basic_plugin.py") + subprocess.run( + [ + sys.executable, + str(script_path), + "reporting", + "--path", + temp_dir, + "--with-scheduled", + ], + check=True, + capture_output=True, + text=True, + ) + + scheduled_root = Path(temp_dir) / "reporting" / "scheduled" + self.assertTrue(scheduled_root.is_dir()) + self.assertEqual(list(scheduled_root.iterdir()), []) + + def _write_template( + self, + plugin_root: Path, + filename: str, + *, + name: str = "Report", + prompt: str = "Summarize the queue.", + schedule: dict[str, object], + **extra: object, + ) -> None: + scheduled_root = plugin_root / "scheduled" + scheduled_root.mkdir(parents=True, exist_ok=True) + payload = { + "name": name, + "prompt": prompt, + "schedule": schedule, + **extra, + } + (scheduled_root / filename).write_text( + json.dumps(payload), + encoding="utf-8", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py index 88fae0fd00..e3b7c252a4 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import json import re +import sys from pathlib import Path, PurePosixPath from typing import Any from urllib.parse import urlparse @@ -13,6 +14,13 @@ from urllib.parse import urlparse import yaml +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from validate_scheduled_tasks import validate_scheduled_task_templates # noqa: E402 + + TODO_MARKER = "[TODO:" SEMVER_RE = re.compile( r"^(0|[1-9]\d*)\." @@ -52,6 +60,7 @@ def validate_plugin(plugin_root: Path) -> list[str]: reject_todo_markers(manifest, "$", errors) validate_manifest_shape(plugin_root, manifest, errors) + errors.extend(validate_scheduled_task_templates(plugin_root)) return errors diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_scheduled_tasks.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_scheduled_tasks.py new file mode 100644 index 0000000000..19f64a9450 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_scheduled_tasks.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Validate plugin Scheduled task templates against the Codex JSON contract.""" + +from __future__ import annotations + +import json +import math +import re +from pathlib import Path +from typing import Any + + +TEMPLATE_FILENAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*\.json$") +TIME_RE = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$") +WEEKDAYS = {"MO", "TU", "WE", "TH", "FR", "SA", "SU"} +TEMPLATE_FIELDS = {"name", "prompt", "schedule"} + + +def validate_scheduled_task_templates(plugin_root: Path) -> list[str]: + """Return author-facing errors for templates that Codex would silently omit.""" + scheduled_root = plugin_root / "scheduled" + if not scheduled_root.exists(): + return [] + if not scheduled_root.is_dir(): + return ["`scheduled` must be a directory"] + + errors: list[str] = [] + for template_path in sorted(scheduled_root.iterdir()): + if not template_path.is_file() or not template_path.name.lower().endswith( + ".json" + ): + continue + _validate_template_file(plugin_root, template_path, errors) + return errors + + +def _validate_template_file( + plugin_root: Path, + template_path: Path, + errors: list[str], +) -> None: + relative_path = template_path.relative_to(plugin_root).as_posix() + if TEMPLATE_FILENAME_RE.fullmatch(template_path.name) is None: + errors.append( + f"`{relative_path}` filename must be lowercase kebab-case ending in `.json`" + ) + + try: + payload = json.loads(template_path.read_text(encoding="utf-8")) + except OSError: + errors.append(f"unable to read `{relative_path}`") + return + except json.JSONDecodeError: + errors.append(f"`{relative_path}` must contain strict standard JSON") + return + + if not isinstance(payload, dict): + errors.append(f"`{relative_path}` must contain a JSON object") + return + + _reject_unknown_fields(payload, TEMPLATE_FIELDS, relative_path, "$", errors) + _require_non_empty_string(payload, "name", relative_path, errors) + _require_non_empty_string(payload, "prompt", relative_path, errors) + _validate_schedule(payload.get("schedule"), relative_path, errors) + + +def _validate_schedule( + schedule: Any, + relative_path: str, + errors: list[str], +) -> None: + if not isinstance(schedule, dict): + errors.append(f"`{relative_path}` field `schedule` must be an object") + return + + schedule_type = schedule.get("type") + if schedule_type == "hourly": + allowed_fields = {"type", "intervalHours", "days"} + _reject_unknown_fields( + schedule, allowed_fields, relative_path, "schedule", errors + ) + interval = schedule.get("intervalHours") + if not _is_positive_integer(interval): + errors.append( + f"`{relative_path}` field `schedule.intervalHours` must be a positive integer" + ) + if "days" in schedule: + _validate_days(schedule["days"], relative_path, errors) + return + + if schedule_type in {"daily", "weekdays"}: + _reject_unknown_fields( + schedule, {"type", "time"}, relative_path, "schedule", errors + ) + _validate_time(schedule.get("time"), relative_path, errors) + return + + if schedule_type == "weekly": + _reject_unknown_fields( + schedule, + {"type", "days", "time"}, + relative_path, + "schedule", + errors, + ) + _validate_days(schedule.get("days"), relative_path, errors) + _validate_time(schedule.get("time"), relative_path, errors) + return + + errors.append( + f"`{relative_path}` field `schedule.type` must be `hourly`, `daily`, " + "`weekdays`, or `weekly`" + ) + + +def _validate_time(value: Any, relative_path: str, errors: list[str]) -> None: + if not isinstance(value, str) or TIME_RE.fullmatch(value) is None: + errors.append( + f"`{relative_path}` field `schedule.time` must use 24-hour `HH:MM` format" + ) + + +def _is_positive_integer(value: Any) -> bool: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return False + return math.isfinite(value) and value > 0 and float(value).is_integer() + + +def _validate_days(value: Any, relative_path: str, errors: list[str]) -> None: + if ( + not isinstance(value, list) + or not value + or any(not isinstance(day, str) or day not in WEEKDAYS for day in value) + or len(set(value)) != len(value) + ): + errors.append( + f"`{relative_path}` field `schedule.days` must contain unique weekdays " + "from `MO` through `SU`" + ) + + +def _require_non_empty_string( + payload: dict[str, Any], + field: str, + relative_path: str, + errors: list[str], +) -> None: + value = payload.get(field) + if not isinstance(value, str) or not value.strip(): + errors.append(f"`{relative_path}` field `{field}` must be a non-empty string") + + +def _reject_unknown_fields( + payload: dict[str, Any], + allowed_fields: set[str], + relative_path: str, + prefix: str, + errors: list[str], +) -> None: + for field in sorted(set(payload) - allowed_fields): + field_path = field if prefix == "$" else f"{prefix}.{field}" + errors.append(f"`{relative_path}` field `{field_path}` is not supported")