Skill: projectsnowwork/cron (version 1.0.0)

--- name: cron description: Local-first recurring schedule engine for reminders, repeated tasks, and time-based execution plans. Use whenever the user mentions recurring timing, repetition, schedules, cadence, weekly/daily/monthly routines, future triggers, or wants to know what runs next. Captures natural language timing into structured local schedules, supports pause/resume, and surfaces upcoming executions. No external sync. --- # Cron Turn recurring intentions into structured local schedules. ## Core Philosophy 1. Repetition should be captured once, then trusted. 2. A schedule is not just a reminder — it is an execution contract over time. 3. The system should make recurrence visible, editable, and pausable. 4. Users should always know what runs next. ## Runtime Requirements - Python 3 must be available as `python3` - No external packages required ## Storage All data is stored locally only under: - `~/.openclaw/workspace/memory/cron/jobs.json` - `~/.openclaw/workspace/memory/cron/runs.json` - `~/.openclaw/workspace/memory/cron/stats.json` No external sync. No cloud storage. No third-party cron service. ## Job Status - `active`: schedule is live - `paused`: temporarily disabled - `archived`: no longer active, kept for history ## Schedule Types - `daily` - `weekly` - `monthly` - `interval` ## Key Workflows - **Capture recurring job**: `add_job.py` - **See what runs next**: `next_run.py` - **Pause or resume**: `pause_job.py`, `resume_job.py` - **Inspect one job**: `show_job.py` - **Review all jobs**: `list_jobs.py`

path: references/philosophy.md
content: # Cron Philosophy

1. Repetition should be captured once, then trusted.
2. Good schedule systems reduce repeated decision-making.
3. Recurrence should be visible, editable, and pausable.
4. The user should always know what runs next.


path: scripts/add_job.py
content: #!/usr/bin/env python3
import argparse
import os
import sys
import uuid
from datetime import datetime

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)

from lib.storage import load_jobs, save_jobs, load_stats, save_stats
from lib.schedule import compute_next_run

VALID_TYPES = ["daily", "weekly", "monthly", "interval"]

def parse_csv(value):
    if not value:
        return []
    return [v.strip() for v in value.split(",") if v.strip()]

def main():
    parser = argparse.ArgumentParser(description="Add a recurring cron job")
    parser.add_argument("--title", required=True)
    parser.add_argument("--schedule_type", choices=VALID_TYPES, required=True)
    parser.add_argument("--time_of_day", help="HH:MM for daily/weekly/monthly")
    parser.add_argument("--days_of_week", help="Comma-separated: mon,tue,fri")
    parser.add_argument("--day_of_month", type=int, help="1-31")
    parser.add_argument("--interval", type=int, help="Minutes for interval schedules")
    parser.add_argument("--timezone", default="Asia/Tokyo")
    parser.add_argument("--start_date")
    parser.add_argument("--end_date")
    parser.add_argument("--tags")
    parser.add_argument("--notes", default="")

    args = parser.parse_args()

    if args.schedule_type in ["daily", "weekly", "monthly"] and not args.time_of_day:
        parser.error("--time_of_day is required for daily/weekly/monthly")

    if args.schedule_type == "weekly" and not args.days_of_week:
        parser.error("--days_of_week is required for weekly")

    if args.schedule_type == "monthly" and not args.day_of_month:
        parser.error("--day_of_month is required for monthly")

    if args.schedule_type == "interval" and not args.interval:
        parser.error("--interval is required for interval")

    job_id = f"JOB-{str(uuid.uuid4())[:4].upper()}"
    now = datetime.now().isoformat()

    next_run = compute_next_run(
        schedule_type=args.schedule_type,
        time_of_day=args.time_of_day,
        days_of_week=parse_csv(args.days_of_week),
        day_of_month=args.day_of_month,
        interval=args.interval
    )

    job = {
        "id": job_id,
        "title": args.title,
        "status": "active",
        "schedule_type": args.schedule_type,
        "interval": args.interval,
        "time_of_day": args.time_of_day,
        "days_of_week": parse_csv(args.days_of_week),
        "day_of_month": args.day_of_month,
        "timezone": args.timezone,
        "start_date": args.start_date,
        "end_date": args.end_date,
        "last_run_at": None,
        "next_run_at": next_run.isoformat() if next_run else None,
        "missed_runs": 0,
        "notes": args.notes,
        "tags": parse_csv(args.tags),
        "created_at": now,
        "updated_at": now
    }

    data = load_jobs()
    data["jobs"][job_id] = job
    save_jobs(data)

    stats = load_stats()
    stats["total_jobs_created"] += 1
    save_stats(stats)

    print(f"✓ Job added: {job_id}")
    print(f"  Title: {args.title}")
    print(f"  Next run: {job['next_run_at']}")

if __name__ == "__main__":
    main()


path: scripts/init_storage.py
content: #!/usr/bin/env python3
import json
import os
from datetime import datetime

CRON_DIR = os.path.expanduser("~/.openclaw/workspace/memory/cron")
JOBS_FILE = os.path.join(CRON_DIR, "jobs.json")
RUNS_FILE = os.path.join(CRON_DIR, "runs.json")
STATS_FILE = os.path.join(CRON_DIR, "stats.json")

def write_json_if_missing(path, payload):
    if not os.path.exists(path):
        with open(path, "w", encoding="utf-8") as f:
            json.dump(payload, f, indent=2, ensure_ascii=False)

def main():
    os.makedirs(CRON_DIR, exist_ok=True)
    now = datetime.now().isoformat()

    write_json_if_missing(JOBS_FILE, {
        "metadata": {
            "version": "1.0.0",
            "created_at": now,
            "last_updated": now
        },
        "jobs": {}
    })

    write_json_if_missing(RUNS_FILE, {
        "metadata": {
            "version": "1.0.0",
            "created_at": now,
            "last_updated": now
        },
        "runs": {}
    })

    write_json_if_missing(STATS_FILE, {
        "total_jobs_created": 0,
        "total_jobs_paused": 0,
        "total_jobs_resumed": 0,
        "total_runs_completed": 0,
        "last_reviewed_at": None
    })

    print("✓ Cron storage initialized")
    print(f"  {JOBS_FILE}")
    print(f"  {RUNS_FILE}")
    print(f"  {STATS_FILE}")

if __name__ == "__main__":
    main()


path: scripts/lib/schedule.py
content: #!/usr/bin/env python3
from datetime import datetime, timedelta

WEEKDAY_MAP = {
    "mon": 0,
    "tue": 1,
    "wed": 2,
    "thu": 3,
    "fri": 4,
    "sat": 5,
    "sun": 6,
}

def parse_time_of_day(value):
    return datetime.strptime(value, "%H:%M").time()

def next_daily_run(time_of_day, now=None):
    now = now or datetime.now()
    t = parse_time_of_day(time_of_day)
    candidate = now.replace(hour=t.hour, minute=t.minute, second=0, microsecond=0)
    if candidate <= now:
        candidate += timedelta(days=1)
    return candidate

def next_weekly_run(days_of_week, time_of_day, now=None):
    now = now or datetime.now()
    t = parse_time_of_day(time_of_day)
    target_days = sorted(WEEKDAY_MAP[d.lower()] for d in days_of_week)
    for offset in range(0, 8):
        candidate_day = now + timedelta(days=offset)
        if candidate_day.weekday() in target_days:
            candidate = candidate_day.replace(hour=t.hour, minute=t.minute, second=0, microsecond=0)
            if candidate > now:
                return candidate
    return None

def next_monthly_run(day_of_month, time_of_day, now=None):
    now = now or datetime.now()
    t = parse_time_of_day(time_of_day)

    year = now.year
    month = now.month

    for _ in range(0, 2):
        try:
            candidate = datetime(year, month, day_of_month, t.hour, t.minute)
            if candidate > now:
                return candidate
        except ValueError:
            pass

        if month == 12:
            year += 1
            month = 1
        else:
            month += 1
    return None

def next_interval_run(interval_minutes, now=None):
    now = now or datetime.now()
    return now + timedelta(minutes=interval_minutes)

def compute_next_run(schedule_type, time_of_day=None, days_of_week=None, day_of_month=None, interval=None):
    if schedule_type == "daily":
        return next_daily_run(time_of_day)
    if schedule_type == "weekly":
        return next_weekly_run(days_of_week or [], time_of_day)
    if schedule_type == "monthly":
        return next_monthly_run(day_of_month, time_of_day)
    if schedule_type == "interval":
        return next_interval_run(interval)
    raise ValueError(f"Unsupported schedule_type: {schedule_type}")


path: scripts/lib/storage.py
content: #!/usr/bin/env python3
import json
import os
from datetime import datetime

CRON_DIR = os.path.expanduser("~/.openclaw/workspace/memory/cron")
JOBS_FILE = os.path.join(CRON_DIR, "jobs.json")
RUNS_FILE = os.path.join(CRON_DIR, "runs.json")
STATS_FILE = os.path.join(CRON_DIR, "stats.json")

def ensure_dir():
    os.makedirs(CRON_DIR, exist_ok=True)

def _safe_load(path, default):
    ensure_dir()
    if not os.path.exists(path):
        return default
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except (json.JSONDecodeError, OSError):
        return default

def _atomic_save(path, data):
    ensure_dir()
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)
    os.replace(tmp, path)

def load_jobs():
    now = datetime.now().isoformat()
    return _safe_load(JOBS_FILE, {
        "metadata": {
            "version": "1.0.0",
            "created_at": now,
            "last_updated": now
        },
        "jobs": {}
    })

def save_jobs(data):
    data.setdefault("metadata", {})
    data["metadata"]["last_updated"] = datetime.now().isoformat()
    _atomic_save(JOBS_FILE, data)

def load_runs():
    now = datetime.now().isoformat()
    return _safe_load(RUNS_FILE, {
        "metadata": {
            "version": "1.0.0",
            "created_at": now,
            "last_updated": now
        },
        "runs": {}
    })

def save_runs(data):
    data.setdefault("metadata", {})
    data["metadata"]["last_updated"] = datetime.now().isoformat()
    _atomic_save(RUNS_FILE, data)

def load_stats():
    return _safe_load(STATS_FILE, {
        "total_jobs_created": 0,
        "total_jobs_paused": 0,
        "total_jobs_resumed": 0,
        "total_runs_completed": 0,
        "last_reviewed_at": None
    })

def save_stats(data):
    _atomic_save(STATS_FILE, data)


path: scripts/list_jobs.py
content: #!/usr/bin/env python3
import os
import sys

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)

from lib.storage import load_jobs

def main():
    data = load_jobs()
    jobs = data.get("jobs", {})

    if not jobs:
        print("No jobs found.")
        return

    for job_id, job in jobs.items():
        print(f"{job_id} | {job['title']} | {job['status']} | next={job.get('next_run_at')}")

if __name__ == "__main__":
    main()


path: scripts/next_run.py
content: #!/usr/bin/env python3
import os
import sys
from datetime import datetime

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)

from lib.storage import load_jobs

def main():
    jobs = list(load_jobs().get("jobs", {}).values())
    active_jobs = [j for j in jobs if j.get("status") == "active" and j.get("next_run_at")]

    if not active_jobs:
        print("No active upcoming jobs.")
        return

    active_jobs.sort(key=lambda x: x["next_run_at"])
    job = active_jobs[0]

    print("Next scheduled job:")
    print(f"  ID: {job['id']}")
    print(f"  Title: {job['title']}")
    print(f"  Next run: {job['next_run_at']}")

if __name__ == "__main__":
    main()


path: scripts/pause_job.py
content: #!/usr/bin/env python3
import argparse
import os
import sys
from datetime import datetime

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)

from lib.storage import load_jobs, save_jobs, load_stats, save_stats

def main():
    parser = argparse.ArgumentParser(description="Pause one job")
    parser.add_argument("--id", required=True)
    args = parser.parse_args()

    data = load_jobs()
    jobs = data.get("jobs", {})

    if args.id not in jobs:
        print(f"Job not found: {args.id}")
        sys.exit(1)

    jobs[args.id]["status"] = "paused"
    jobs[args.id]["updated_at"] = datetime.now().isoformat()
    save_jobs(data)

    stats = load_stats()
    stats["total_jobs_paused"] += 1
    save_stats(stats)

    print(f"✓ Paused {args.id}")
    print(f"  {jobs[args.id]['title']}")

if __name__ == "__main__":
    main()


path: scripts/resume_job.py
content: #!/usr/bin/env python3
import argparse
import os
import sys
from datetime import datetime

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)

from lib.storage import load_jobs, save_jobs, load_stats, save_stats
from lib.schedule import compute_next_run

def main():
    parser = argparse.ArgumentParser(description="Resume one job")
    parser.add_argument("--id", required=True)
    args = parser.parse_args()

    data = load_jobs()
    jobs = data.get("jobs", {})

    if args.id not in jobs:
        print(f"Job not found: {args.id}")
        sys.exit(1)

    job = jobs[args.id]
    job["status"] = "active"
    job["next_run_at"] = compute_next_run(
        schedule_type=job["schedule_type"],
        time_of_day=job.get("time_of_day"),
        days_of_week=job.get("days_of_week"),
        day_of_month=job.get("day_of_month"),
        interval=job.get("interval")
    ).isoformat()
    job["updated_at"] = datetime.now().isoformat()
    save_jobs(data)

    stats = load_stats()
    stats["total_jobs_resumed"] += 1
    save_stats(stats)

    print(f"✓ Resumed {args.id}")
    print(f"  Next run: {job['next_run_at']}")

if __name__ == "__main__":
    main()


path: scripts/show_job.py
content: #!/usr/bin/env python3
import argparse
import json
import os
import sys

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)

from lib.storage import load_jobs

def main():
    parser = argparse.ArgumentParser(description="Show one job")
    parser.add_argument("--id", required=True)
    args = parser.parse_args()

    jobs = load_jobs().get("jobs", {})
    if args.id not in jobs:
        print(f"Job not found: {args.id}")
        sys.exit(1)

    print(json.dumps(jobs[args.id], indent=2, ensure_ascii=False))

if __name__ == "__main__":
    main()


path: skill.json
content: {
  "name": "cron",
  "version": "1.0.0",
  "description": "Local-first recurring schedule engine for reminders, repeated tasks, and time-based execution plans.",
  "entrypoints": [],
  "files": [
    "SKILL.md",
    "skill.json",
    "scripts/init_storage.py",
    "scripts/add_job.py",
    "scripts/list_jobs.py",
    "scripts/show_job.py",
    "scripts/pause_job.py",
    "scripts/resume_job.py",
    "scripts/next_run.py",
    "scripts/lib/storage.py",
    "scripts/lib/schedule.py",
    "references/philosophy.md"
  ]
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)