mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Teach plugin creator scheduled task templates
This commit is contained in:
@@ -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 <parent-plugin-directory> \
|
||||
--marketplace-path <marketplace-json-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
|
||||
```
|
||||
|
||||
`<parent-plugin-directory>` is the directory where the plugin folder `<plugin-name>` 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
|
||||
|
||||
@@ -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/<template-key>.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 <plugin-path>`. 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+158
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+162
@@ -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")
|
||||
Reference in New Issue
Block a user