Skill: gostlightai/casual-cron (version 1.2.0)

--- name: casual-cron description: "Create Clawdbot cron jobs from natural language with strict run-guard rules. Use when: users ask to schedule reminders or messages (recurring or one-shot), especially via Telegram, or when they use /at or /every. Examples: 'Create a daily reminder at 8am', 'Remind me in 20 minutes', 'Send me a Telegram message at 3pm', '/every 2h'." metadata: {"openclaw":{"emoji":"⏰","requires":{"bins":["python3","openclaw"],"env":["CRON_DEFAULT_CHANNEL"]}}} --- # Casual Cron Create Clawdbot cron jobs from natural language. Supports one-shot and repeating schedules with safe run-guard rules. ## Cron Run Guard (Hard Rules) - When running inside a cron job: do NOT troubleshoot, do NOT restart gateway, and do NOT check time. - Do NOT send acknowledgements or explanations. - Output ONLY the exact message payload and then stop. --- ## How It Works 1. Agent detects scheduling intent from user message (or `/at` / `/every` command) 2. Parses: time, frequency, channel, destination, message 3. Builds `openclaw cron add` command with correct flags 4. Confirms parsed time, job name, and job id with user before executing --- ## Scheduling Rules When a message starts with `/at` or `/every`, schedule via the CLI (NOT the cron tool API). Use: `openclaw cron add` ### /at (one-shot) - If user gives a clock time (e.g., "3pm"), convert to ISO with offset computed for America/New_York on that date (DST-safe). - Prefer relative times for near-term reminders (e.g., `--at "20m"`). - Use `--session isolated --message "Output exactly: <task>"`. - Always include `--delete-after-run`. - Always include `--deliver --channel <channel> --to <destination>`. ### /every (repeating) - If interval: use `--every "<duration>"` (no timezone needed). - If clock time: use `--cron "<expr>" --tz "America/New_York"`. - Use `--session isolated --message "Output exactly: <task>"`. - Always include `--deliver --channel <channel> --to <destination>`. ### Confirmation - Always confirm parsed time, job name, and job id with the user before finalizing. --- ## Command Reference One-shot (clock time, DST-aware): ``` openclaw cron add \ --name "Reminder example" \ --at "2026-01-28T15:00:00-05:00" \ --session isolated \ --message "Output exactly: <TASK>" \ --deliver --channel telegram --to <TELEGRAM_CHAT_ID> \ --delete-after-run ``` One-shot (relative time): ``` openclaw cron add \ --name "Reminder in 20m" \ --at "20m" \ --session isolated \ --message "Output exactly: <TASK>" \ --deliver --channel telegram --to <TELEGRAM_CHAT_ID> \ --delete-after-run ``` Repeating (clock time, DST-aware): ``` openclaw cron add \ --name "Daily 3pm reminder" \ --cron "0 15 * * *" --tz "America/New_York" \ --session isolated \ --message "Output exactly: <TASK>" \ --deliver --channel telegram --to <TELEGRAM_CHAT_ID> ``` Repeating (interval): ``` openclaw cron add \ --name "Every 2 hours" \ --every "2h" \ --session isolated \ --message "Output exactly: <TASK>" \ --deliver --channel telegram --to <TELEGRAM_CHAT_ID> ``` --- ## Configuration | Setting | Value | |---------|-------| | Default timezone | `America/New_York` (DST-aware) | | Default channel | `telegram` (override via `CRON_DEFAULT_CHANNEL` env var) | | Supported channels | telegram, whatsapp, slack, discord, signal | --- ## Supported Patterns ### Time Formats | Input | Cron | |-------|------| | `8am` | `0 8 * * *` | | `8:45pm` | `45 20 * * *` | | `noon` | `0 12 * * *` | | `midnight` | `0 0 * * *` | | `14:30` | `30 14 * * *` | ### Frequencies | Input | Behavior | |-------|----------| | `daily` / `every day` | Daily at specified time | | `weekdays` / `mon-fri` | Mon-Fri at specified time | | `mondays` / `every monday` | Weekly on Monday | | `hourly` / `every hour` | Every hour at :00 | | `every 2 hours` | `0 */2 * * *` | | `weekly` | Weekly (defaults to Monday) | | `monthly` | Monthly (1st of month) |

path: CHANGELOG.md
content: # Changelog

## 2.0.0

Breaking changes — new script name, rewritten SKILL.md.

### Changed
- **SKILL.md**: Restructured to match operational style. Added OpenClaw metadata to frontmatter. Removed marketing "Trigger Patterns" section. Consolidated time/frequency/channel tables into single "Supported Patterns" section. Added "How It Works" and "Command Reference" sections.
- **scripts/cron_builder.py**: New script replacing `cron_creator.py`. Written from scratch.

### Fixed
- `build_command` now includes `--session isolated` and `--deliver` flags (were missing in v1).
- One-shot schedules (`--at`) now use `--delete-after-run` and skip `--cron` (v1 always used `--cron`).
- Default channel changed from `whatsapp` to `telegram` (configurable via `CRON_DEFAULT_CHANNEL` env var).
- Removed dead `TIME_PATTERNS` dict that was never used by actual parsing.
- Removed `<YOUR_PHONE>` placeholder — defaults now match channel type.
- Frequency pattern ordering fixed: "weekly on Mondays" now correctly resolves to Monday, not generic weekly.

### Added
- `test/test_cron_builder.py` — 42 tests covering time parsing, frequency parsing, channel detection, command generation, and edge cases.
- OpenClaw frontmatter metadata (emoji, requires).
- Default time (9am) when no time specified for repeating schedules.

### Removed
- `scripts/cron_creator.py` (replaced by `cron_builder.py`).
- "Trigger Patterns" section from SKILL.md.

## 1.0.1

Initial fork from i-mw/cron-mastery. Published on ClawHub.


path: _meta.json
content: ownerId: kn78m187dg0fg1dn200v3fb65h80812p
slug: casual-cron
version: 1.2.0
publishedAt: 1770396874371

path: scripts/cron_builder.py
content: #!/usr/bin/env python3
"""
cron_builder — Natural language to openclaw cron command.

Usage: python3 cron_builder.py "<natural language request>"
Output: JSON with success, parsed, command fields.
"""

import json
import os
import re
import shlex
import sys
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

# --- Pattern dictionaries ---

TIME_PATTERNS = [
    (r'(\d{1,2})[.:](\d{2})\s*(am|pm)', 'ampm_min'),
    (r'(\d{1,2})\s*(am|pm)', 'ampm'),
    (r'(\d{1,2})[.:](\d{2})', '24h'),
]

NAMED_TIMES = {
    'noon': (12, 0),
    'midnight': (0, 0),
}

FREQUENCY_PATTERNS = [
    (r'every\s+(\d+)\s+hours?', 'every_n_hours'),
    (r'every\s+(\d+)\s+minutes?', 'every_n_minutes'),
    (r'(?:in\s+)?(\d+)\s*(?:min|minutes?|m)\b', 'in_minutes'),
    # Day-of-week patterns before generic weekly so "weekly on Mondays" -> monday
    (r'mondays?|every\s*monday', 'monday'),
    (r'tuesdays?|every\s*tuesday', 'tuesday'),
    (r'wednesdays?|every\s*wednesday', 'wednesday'),
    (r'thursdays?|every\s*thursday', 'thursday'),
    (r'fridays?|every\s*friday', 'friday'),
    (r'saturdays?|every\s*saturday', 'saturday'),
    (r'sundays?|every\s*sunday', 'sunday'),
    (r'weekdays?|mon[\s-]*fri|work\s*days?', 'weekdays'),
    (r'daily|every\s*day', 'daily'),
    (r'hourly|every\s*hour', 'hourly'),
    (r'weekly|once\s*a\s*week', 'weekly'),
    (r'monthly|once\s*a\s*month', 'monthly'),
]

DAY_MAP = {
    'monday': 1, 'tuesday': 2, 'wednesday': 3, 'thursday': 4,
    'friday': 5, 'saturday': 6, 'sunday': 0,
}

CHANNEL_PATTERNS = [
    (r'\bon\s*telegram\b|telegram', 'telegram'),
    (r'\bon\s*whatsapp\b|whatsapp', 'whatsapp'),
    (r'\bon\s*slack\b|slack', 'slack'),
    (r'\bon\s*discord\b|discord', 'discord'),
    (r'\bon\s*signal\b|signal', 'signal'),
]



def _parse_time(text):
    """Extract hour and minute from text. Returns (hour, minute) or None."""
    lower = text.lower()

    for name, (h, m) in NAMED_TIMES.items():
        if name in lower:
            return (h, m)

    for pattern, kind in TIME_PATTERNS:
        match = re.search(pattern, lower)
        if not match:
            continue
        groups = match.groups()
        if kind == 'ampm_min':
            hour, minute, meridian = int(groups[0]), int(groups[1]), groups[2]
            if meridian == 'pm' and hour != 12:
                hour += 12
            elif meridian == 'am' and hour == 12:
                hour = 0
            return (hour, minute)
        elif kind == 'ampm':
            hour, meridian = int(groups[0]), groups[1]
            if meridian == 'pm' and hour != 12:
                hour += 12
            elif meridian == 'am' and hour == 12:
                hour = 0
            return (hour, 0)
        elif kind == '24h':
            return (int(groups[0]), int(groups[1]))

    return None


def _parse_frequency(text):
    """Parse frequency from text. Returns (freq_type, value).

    freq_type is one of: daily, weekdays, hourly, weekly, monthly,
    every_n_hours, every_n_minutes, in_minutes, or a day name.
    value is the numeric value for every_n_* / in_minutes, or None.
    """
    lower = text.lower()
    for pattern, freq_type in FREQUENCY_PATTERNS:
        match = re.search(pattern, lower)
        if match:
            groups = match.groups()
            if freq_type in ('every_n_hours', 'every_n_minutes', 'in_minutes'):
                return (freq_type, int(groups[0]))
            return (freq_type, None)
    return ('default', None)


def _parse_channel(text):
    """Extract channel from text. Falls back to CRON_DEFAULT_CHANNEL env or telegram."""
    lower = text.lower()
    for pattern, channel in CHANNEL_PATTERNS:
        if re.search(pattern, lower):
            return channel
    return os.environ.get('CRON_DEFAULT_CHANNEL', 'telegram')


def _parse_destination(text, channel):
    """Extract destination from text."""
    phone = re.search(r'\+?[\d\s\-()]{10,}', text)
    if phone:
        return phone.group().strip()
    handle = re.search(r'([#@]\w+)', text)
    if handle:
        return handle.group()
    defaults = {
        'telegram': '<TELEGRAM_CHAT_ID>',
        'whatsapp': '<WHATSAPP_PHONE>',
        'slack': '#general',
        'discord': '#general',
        'signal': '<SIGNAL_PHONE>',
    }
    return defaults.get(channel, '<DESTINATION>')


def _parse_message(text):
    """Extract the user's actual message by stripping scheduling noise."""
    msg = re.sub(
        r'^(create|set\s*up|schedule|add|remind\s*me\s*to?|send\s*me)\s+',
        '', text.strip(), flags=re.IGNORECASE,
    )
    # Strip "a/an" article left at the start after prefix removal
    msg = re.sub(r'^(an?\s+)', '', msg, flags=re.IGNORECASE)
    # Strip "in N minutes/min/m" for one-shot
    msg = re.sub(r'\s*\bin\s+\d+\s*(minutes?|min|m)\b', '', msg, flags=re.IGNORECASE)
    # Strip channel references
    msg = re.sub(r'\s+on\s+(telegram|whatsapp|slack|discord|signal)\b', '', msg, flags=re.IGNORECASE)
    # Strip time references
    msg = re.sub(r'\s+at\s+\d{1,2}[.:]\d{2}\s*(am|pm)?', '', msg, flags=re.IGNORECASE)
    msg = re.sub(r'\s+at\s+\d{1,2}\s*(am|pm)', '', msg, flags=re.IGNORECASE)
    msg = re.sub(r'\s+at\s+(noon|midnight)', '', msg, flags=re.IGNORECASE)
    msg = re.sub(r'^at\s+\d{1,2}[.:]\d{2}\s*(am|pm)?', '', msg, flags=re.IGNORECASE)
    msg = re.sub(r'^at\s+\d{1,2}\s*(am|pm)', '', msg, flags=re.IGNORECASE)
    msg = re.sub(r'^at\s+(noon|midnight)', '', msg, flags=re.IGNORECASE)
    # Strip frequency words
    msg = re.sub(r'\b(a\s+)?(daily|weekly|monthly|hourly|every\s+\d+\s+(hours?|minutes?|min|m))\s*', '', msg, flags=re.IGNORECASE)
    msg = re.sub(r'\bevery\s*(day|hour)\b', '', msg, flags=re.IGNORECASE)
    msg = re.sub(r'\b(mondays?|tuesdays?|wednesdays?|thursdays?|fridays?|saturdays?|sundays?)\b', '', msg, flags=re.IGNORECASE)
    msg = re.sub(r'\bweekdays?\b', '', msg, flags=re.IGNORECASE)
    # Clean up
    msg = msg.strip().strip('"\'').strip()
    return msg if msg else 'Reminder'


def _generate_name(text, freq_type):
    """Generate a short job name from the request."""
    # Strip common prefixes
    name = re.sub(
        r'^(create|set\s*up|schedule|add|remind\s*me\s*to?|send\s*me)\s+',
        '', text.strip(), flags=re.IGNORECASE,
    )
    # Strip "in N minutes/min/m" for one-shot
    name = re.sub(r'\s*\bin\s+\d+\s*(minutes?|min|m)\b', '', name, flags=re.IGNORECASE)
    # Strip channel / time suffixes
    name = re.sub(r'\s+on\s+(telegram|whatsapp|slack|discord|signal)\b', '', name, flags=re.IGNORECASE)
    name = re.sub(r'\s+at\s+\d{1,2}[.:]\d{2}\s*(am|pm)?', '', name, flags=re.IGNORECASE)
    name = re.sub(r'\s+at\s+\d{1,2}\s*(am|pm)', '', name, flags=re.IGNORECASE)
    name = re.sub(r'\s+at\s+(noon|midnight)', '', name, flags=re.IGNORECASE)
    # Strip frequency words for cleaner names
    name = re.sub(r'\b(a\s+)?(daily|weekly|monthly|hourly|every\s+\d+\s+hours?)\s*', '', name, flags=re.IGNORECASE)
    # Trim and capitalize
    name = name.strip().strip('"\'').strip()
    if name:
        name = name[0].upper() + name[1:]
    else:
        name = 'Scheduled Reminder'
    # Truncate
    if len(name) > 60:
        name = name[:57] + '...'
    return name


def _is_one_shot(freq_type):
    """Determine if the schedule is one-shot (not repeating)."""
    return freq_type in ('in_minutes', 'at_clock')


def parse(text):
    """Parse natural language into structured scheduling data.

    Returns dict with: time, frequency, channel, destination, message, name,
    one_shot, and the raw freq_type/freq_value.
    """
    time = _parse_time(text)
    freq_type, freq_value = _parse_frequency(text)

    # Resolve the 'default' freq_type (no explicit frequency keyword matched)
    if freq_type == 'default':
        if time is not None:
            freq_type = 'at_clock'
        else:
            freq_type = 'daily'

    channel = _parse_channel(text)
    destination = _parse_destination(text, channel)
    message = _parse_message(text)
    name = _generate_name(text, freq_type)
    one_shot = _is_one_shot(freq_type)

    errors = []
    # Default to 9am when no time specified for cron-based schedules
    if not one_shot and time is None and freq_type not in ('hourly', 'every_n_hours', 'every_n_minutes'):
        time = (9, 0)

    result = {
        'name': name,
        'time': {'hour': time[0], 'minute': time[1]} if time else None,
        'freq_type': freq_type,
        'freq_value': freq_value,
        'channel': channel,
        'destination': destination,
        'message': message,
        'one_shot': one_shot,
    }
    if errors:
        result['errors'] = errors
    return result


def build_command(parsed):
    """Build a complete openclaw cron add command from parsed data.

    Returns the command string. Raises ValueError on missing required fields.
    """
    parts = ['openclaw cron add']
    parts.append(f'--name {shlex.quote(parsed["name"])}')

    freq = parsed['freq_type']
    fval = parsed['freq_value']
    time = parsed.get('time')

    if freq == 'in_minutes':
        parts.append(f'--at "{fval}m"')
    elif freq == 'at_clock':
        # Build ISO timestamp for next occurrence of this clock time
        tz = ZoneInfo('America/New_York')
        now = datetime.now(tz)
        target = now.replace(
            hour=time['hour'], minute=time['minute'], second=0, microsecond=0,
        )
        if target <= now:
            target += timedelta(days=1)
        iso = target.isoformat()
        parts.append(f'--at {shlex.quote(iso)}')
    elif freq == 'every_n_hours':
        parts.append(f'--every "{fval}h"')
    elif freq == 'every_n_minutes':
        parts.append(f'--every "{fval}m"')
    elif freq == 'hourly':
        parts.append('--every "1h"')
    else:
        # Build cron expression
        h = time['hour'] if time else 9
        m = time['minute'] if time else 0
        if freq == 'daily':
            cron = f'{m} {h} * * *'
        elif freq == 'weekdays':
            cron = f'{m} {h} * * 1-5'
        elif freq == 'weekly':
            cron = f'{m} {h} * * 1'
        elif freq == 'monthly':
            cron = f'{m} {h} 1 * *'
        elif freq in DAY_MAP:
            dow = DAY_MAP[freq]
            cron = f'{m} {h} * * {dow}'
        else:
            cron = f'{m} {h} * * *'
        parts.append(f'--cron "{cron}" --tz "America/New_York"')

    parts.append('--session isolated')
    parts.append(f'--message {shlex.quote("Output exactly: " + parsed["message"])}')
    parts.append('--deliver')
    parts.append(f'--channel {shlex.quote(parsed["channel"])}')
    parts.append(f'--to {shlex.quote(parsed["destination"])}')

    if parsed['one_shot']:
        parts.append('--delete-after-run')

    return ' \\\n  '.join(parts)


def main():
    if len(sys.argv) < 2:
        print(json.dumps({
            'success': False,
            'error': 'No request provided',
            'usage': 'python3 cron_builder.py "Create a daily reminder at 8am"',
        }))
        sys.exit(1)

    text = ' '.join(sys.argv[1:])
    parsed = parse(text)
    has_errors = 'errors' in parsed

    result = {
        'success': not has_errors,
        'parsed': parsed,
    }

    if not has_errors:
        result['command'] = build_command(parsed)
    else:
        result['command'] = None

    print(json.dumps(result, indent=2))
    sys.exit(1 if has_errors else 0)


if __name__ == '__main__':
    main()


path: skills/casual-cron/CHANGELOG.md
content: # Changelog

## 2.0.0

Breaking changes — new script name, rewritten SKILL.md.

### Changed
- **SKILL.md**: Restructured to match operational style. Added OpenClaw metadata to frontmatter. Removed marketing "Trigger Patterns" section. Consolidated time/frequency/channel tables into single "Supported Patterns" section. Added "How It Works" and "Command Reference" sections.
- **scripts/cron_builder.py**: New script replacing `cron_creator.py`. Written from scratch.

### Fixed
- `build_command` now includes `--session isolated` and `--deliver` flags (were missing in v1).
- One-shot schedules (`--at`) now use `--delete-after-run` and skip `--cron` (v1 always used `--cron`).
- Default channel changed from `whatsapp` to `telegram` (configurable via `CRON_DEFAULT_CHANNEL` env var).
- Removed dead `TIME_PATTERNS` dict that was never used by actual parsing.
- Removed `<YOUR_PHONE>` placeholder — defaults now match channel type.
- Frequency pattern ordering fixed: "weekly on Mondays" now correctly resolves to Monday, not generic weekly.

### Added
- `test/test_cron_builder.py` — 42 tests covering time parsing, frequency parsing, channel detection, command generation, and edge cases.
- OpenClaw frontmatter metadata (emoji, requires).
- Default time (9am) when no time specified for repeating schedules.

### Removed
- `scripts/cron_creator.py` (replaced by `cron_builder.py`).
- "Trigger Patterns" section from SKILL.md.

## 1.0.1

Initial fork from i-mw/cron-mastery. Published on ClawHub.


path: skills/casual-cron/_meta.json
content: ownerId: kn78m187dg0fg1dn200v3fb65h80812p
slug: casual-cron
version: 1.1.0
publishedAt: 1770813862759

path: skills/casual-cron/scripts/cron_builder.py
content: #!/usr/bin/env python3
"""
cron_builder — Natural language to openclaw cron command.

Usage: python3 cron_builder.py "<natural language request>"
Output: JSON with success, parsed, command fields.
"""

import json
import os
import re
import shlex
import sys
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

# --- Pattern dictionaries ---

TIME_PATTERNS = [
    (r'(\d{1,2})[.:](\d{2})\s*(am|pm)', 'ampm_min'),
    (r'(\d{1,2})\s*(am|pm)', 'ampm'),
    (r'(\d{1,2})[.:](\d{2})', '24h'),
]

NAMED_TIMES = {
    'noon': (12, 0),
    'midnight': (0, 0),
}

FREQUENCY_PATTERNS = [
    (r'every\s+(\d+)\s+hours?', 'every_n_hours'),
    (r'every\s+(\d+)\s+minutes?', 'every_n_minutes'),
    (r'(?:in\s+)?(\d+)\s*(?:min|minutes?|m)\b', 'in_minutes'),
    # Day-of-week patterns before generic weekly so "weekly on Mondays" -> monday
    (r'mondays?|every\s*monday', 'monday'),
    (r'tuesdays?|every\s*tuesday', 'tuesday'),
    (r'wednesdays?|every\s*wednesday', 'wednesday'),
    (r'thursdays?|every\s*thursday', 'thursday'),
    (r'fridays?|every\s*friday', 'friday'),
    (r'saturdays?|every\s*saturday', 'saturday'),
    (r'sundays?|every\s*sunday', 'sunday'),
    (r'weekdays?|mon[\s-]*fri|work\s*days?', 'weekdays'),
    (r'daily|every\s*day', 'daily'),
    (r'hourly|every\s*hour', 'hourly'),
    (r'weekly|once\s*a\s*week', 'weekly'),
    (r'monthly|once\s*a\s*month', 'monthly'),
]

DAY_MAP = {
    'monday': 1, 'tuesday': 2, 'wednesday': 3, 'thursday': 4,
    'friday': 5, 'saturday': 6, 'sunday': 0,
}

CHANNEL_PATTERNS = [
    (r'\bon\s*telegram\b|telegram', 'telegram'),
    (r'\bon\s*whatsapp\b|whatsapp', 'whatsapp'),
    (r'\bon\s*slack\b|slack', 'slack'),
    (r'\bon\s*discord\b|discord', 'discord'),
    (r'\bon\s*signal\b|signal', 'signal'),
]

PURPOSE_MESSAGES = [
    (r'ikigai', 'Ikigai Journal\n\n1. Purpose - What gives you energy today?\n2. Food - Hara Hachi Bu goal?\n3. Movement - One move today?\n4. Connection - Who will you connect with?\n5. Gratitude - One thing grateful for?'),
    (r'water|hydrat', 'Time to drink water! Stay hydrated!'),
    (r'exercise|workout|movement', 'Time to move! Even a 10-minute walk makes a difference.'),
    (r'meditat|mindful', 'Take a moment to breathe. 5 minutes of stillness.'),
    (r'morning', 'Good morning! Time for your daily check-in.'),
    (r'evening|night', 'Evening check-in! How was your day?'),
    (r'weekly|week', 'Weekly check-in!\n\n1. What went well this week?\n2. What could improve?\n3. Goal for next week?'),
]

DEFAULT_MESSAGE = 'Your scheduled reminder is here!'


def _parse_time(text):
    """Extract hour and minute from text. Returns (hour, minute) or None."""
    lower = text.lower()

    for name, (h, m) in NAMED_TIMES.items():
        if name in lower:
            return (h, m)

    for pattern, kind in TIME_PATTERNS:
        match = re.search(pattern, lower)
        if not match:
            continue
        groups = match.groups()
        if kind == 'ampm_min':
            hour, minute, meridian = int(groups[0]), int(groups[1]), groups[2]
            if meridian == 'pm' and hour != 12:
                hour += 12
            elif meridian == 'am' and hour == 12:
                hour = 0
            return (hour, minute)
        elif kind == 'ampm':
            hour, meridian = int(groups[0]), groups[1]
            if meridian == 'pm' and hour != 12:
                hour += 12
            elif meridian == 'am' and hour == 12:
                hour = 0
            return (hour, 0)
        elif kind == '24h':
            return (int(groups[0]), int(groups[1]))

    return None


def _parse_frequency(text):
    """Parse frequency from text. Returns (freq_type, value).

    freq_type is one of: daily, weekdays, hourly, weekly, monthly,
    every_n_hours, every_n_minutes, in_minutes, or a day name.
    value is the numeric value for every_n_* / in_minutes, or None.
    """
    lower = text.lower()
    for pattern, freq_type in FREQUENCY_PATTERNS:
        match = re.search(pattern, lower)
        if match:
            groups = match.groups()
            if freq_type in ('every_n_hours', 'every_n_minutes', 'in_minutes'):
                return (freq_type, int(groups[0]))
            return (freq_type, None)
    return ('default', None)


def _parse_channel(text):
    """Extract channel from text. Falls back to CRON_DEFAULT_CHANNEL env or telegram."""
    lower = text.lower()
    for pattern, channel in CHANNEL_PATTERNS:
        if re.search(pattern, lower):
            return channel
    return os.environ.get('CRON_DEFAULT_CHANNEL', 'telegram')


def _parse_destination(text, channel):
    """Extract destination from text."""
    phone = re.search(r'\+?[\d\s\-()]{10,}', text)
    if phone:
        return phone.group().strip()
    handle = re.search(r'([#@]\w+)', text)
    if handle:
        return handle.group()
    defaults = {
        'telegram': '<TELEGRAM_CHAT_ID>',
        'whatsapp': '<WHATSAPP_PHONE>',
        'slack': '#general',
        'discord': '#general',
        'signal': '<SIGNAL_PHONE>',
    }
    return defaults.get(channel, '<DESTINATION>')


def _parse_message(text):
    """Determine purpose-appropriate message."""
    lower = text.lower()
    for pattern, msg in PURPOSE_MESSAGES:
        if re.search(pattern, lower):
            return msg
    return DEFAULT_MESSAGE


def _generate_name(text, freq_type):
    """Generate a short job name from the request."""
    # Strip common prefixes
    name = re.sub(
        r'^(create|set\s*up|schedule|add|remind\s*me\s*to?|send\s*me)\s+',
        '', text.strip(), flags=re.IGNORECASE,
    )
    # Strip "in N minutes/min/m" for one-shot
    name = re.sub(r'\s*\bin\s+\d+\s*(minutes?|min|m)\b', '', name, flags=re.IGNORECASE)
    # Strip channel / time suffixes
    name = re.sub(r'\s+on\s+(telegram|whatsapp|slack|discord|signal)\b', '', name, flags=re.IGNORECASE)
    name = re.sub(r'\s+at\s+\d{1,2}[.:]\d{2}\s*(am|pm)?', '', name, flags=re.IGNORECASE)
    name = re.sub(r'\s+at\s+\d{1,2}\s*(am|pm)', '', name, flags=re.IGNORECASE)
    name = re.sub(r'\s+at\s+(noon|midnight)', '', name, flags=re.IGNORECASE)
    # Strip frequency words for cleaner names
    name = re.sub(r'\b(a\s+)?(daily|weekly|monthly|hourly|every\s+\d+\s+hours?)\s*', '', name, flags=re.IGNORECASE)
    # Trim and capitalize
    name = name.strip().strip('"\'').strip()
    if name:
        name = name[0].upper() + name[1:]
    else:
        name = 'Scheduled Reminder'
    # Truncate
    if len(name) > 60:
        name = name[:57] + '...'
    return name


def _is_one_shot(freq_type):
    """Determine if the schedule is one-shot (not repeating)."""
    return freq_type in ('in_minutes', 'at_clock')


def parse(text):
    """Parse natural language into structured scheduling data.

    Returns dict with: time, frequency, channel, destination, message, name,
    one_shot, and the raw freq_type/freq_value.
    """
    time = _parse_time(text)
    freq_type, freq_value = _parse_frequency(text)

    # Resolve the 'default' freq_type (no explicit frequency keyword matched)
    if freq_type == 'default':
        if time is not None:
            freq_type = 'at_clock'
        else:
            freq_type = 'daily'

    channel = _parse_channel(text)
    destination = _parse_destination(text, channel)
    message = _parse_message(text)
    name = _generate_name(text, freq_type)
    one_shot = _is_one_shot(freq_type)

    errors = []
    # Default to 9am when no time specified for cron-based schedules
    if not one_shot and time is None and freq_type not in ('hourly', 'every_n_hours', 'every_n_minutes'):
        time = (9, 0)

    result = {
        'name': name,
        'time': {'hour': time[0], 'minute': time[1]} if time else None,
        'freq_type': freq_type,
        'freq_value': freq_value,
        'channel': channel,
        'destination': destination,
        'message': message,
        'one_shot': one_shot,
    }
    if errors:
        result['errors'] = errors
    return result


def build_command(parsed):
    """Build a complete openclaw cron add command from parsed data.

    Returns the command string. Raises ValueError on missing required fields.
    """
    parts = ['openclaw cron add']
    parts.append(f'--name {shlex.quote(parsed["name"])}')

    freq = parsed['freq_type']
    fval = parsed['freq_value']
    time = parsed.get('time')

    if freq == 'in_minutes':
        parts.append(f'--at "{fval}m"')
    elif freq == 'at_clock':
        # Build ISO timestamp for next occurrence of this clock time
        tz = ZoneInfo('America/New_York')
        now = datetime.now(tz)
        target = now.replace(
            hour=time['hour'], minute=time['minute'], second=0, microsecond=0,
        )
        if target <= now:
            target += timedelta(days=1)
        iso = target.isoformat()
        parts.append(f'--at {shlex.quote(iso)}')
    elif freq == 'every_n_hours':
        parts.append(f'--every "{fval}h"')
    elif freq == 'every_n_minutes':
        parts.append(f'--every "{fval}m"')
    elif freq == 'hourly':
        parts.append('--every "1h"')
    else:
        # Build cron expression
        h = time['hour'] if time else 9
        m = time['minute'] if time else 0
        if freq == 'daily':
            cron = f'{m} {h} * * *'
        elif freq == 'weekdays':
            cron = f'{m} {h} * * 1-5'
        elif freq == 'weekly':
            cron = f'{m} {h} * * 1'
        elif freq == 'monthly':
            cron = f'{m} {h} 1 * *'
        elif freq in DAY_MAP:
            dow = DAY_MAP[freq]
            cron = f'{m} {h} * * {dow}'
        else:
            cron = f'{m} {h} * * *'
        parts.append(f'--cron "{cron}" --tz "America/New_York"')

    parts.append('--session isolated')
    parts.append(f'--message {shlex.quote("Output exactly: " + parsed["message"])}')
    parts.append('--deliver')
    parts.append(f'--channel {shlex.quote(parsed["channel"])}')
    parts.append(f'--to {shlex.quote(parsed["destination"])}')

    if parsed['one_shot']:
        parts.append('--delete-after-run')

    return ' \\\n  '.join(parts)


def main():
    if len(sys.argv) < 2:
        print(json.dumps({
            'success': False,
            'error': 'No request provided',
            'usage': 'python3 cron_builder.py "Create a daily reminder at 8am"',
        }))
        sys.exit(1)

    text = ' '.join(sys.argv[1:])
    parsed = parse(text)
    has_errors = 'errors' in parsed

    result = {
        'success': not has_errors,
        'parsed': parsed,
    }

    if not has_errors:
        result['command'] = build_command(parsed)
    else:
        result['command'] = None

    print(json.dumps(result, indent=2))
    sys.exit(1 if has_errors else 0)


if __name__ == '__main__':
    main()


path: skills/casual-cron/test/test_cron_builder.py
content: #!/usr/bin/env python3
"""Tests for cron_builder."""

import os
import re
import sys
import unittest

# Add parent scripts dir to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
from cron_builder import parse, build_command, _parse_time, _parse_frequency, _parse_channel


class TestTimeParsing(unittest.TestCase):
    def test_simple_am(self):
        self.assertEqual(_parse_time('at 8am'), (8, 0))

    def test_simple_pm(self):
        self.assertEqual(_parse_time('at 3pm'), (15, 0))

    def test_with_minutes_am(self):
        self.assertEqual(_parse_time('at 8:45am'), (8, 45))

    def test_with_minutes_pm(self):
        self.assertEqual(_parse_time('at 8:45pm'), (20, 45))

    def test_noon(self):
        self.assertEqual(_parse_time('at noon'), (12, 0))

    def test_midnight(self):
        self.assertEqual(_parse_time('at midnight'), (0, 0))

    def test_24h(self):
        self.assertEqual(_parse_time('at 14:30'), (14, 30))

    def test_12am(self):
        self.assertEqual(_parse_time('at 12am'), (0, 0))

    def test_12pm(self):
        self.assertEqual(_parse_time('at 12pm'), (12, 0))

    def test_dot_separator(self):
        self.assertEqual(_parse_time('at 8.45am'), (8, 45))

    def test_no_time(self):
        self.assertIsNone(_parse_time('remind me to do something'))


class TestFrequencyParsing(unittest.TestCase):
    def test_daily(self):
        self.assertEqual(_parse_frequency('daily reminder'), ('daily', None))

    def test_every_day(self):
        self.assertEqual(_parse_frequency('every day at 8am'), ('daily', None))

    def test_every_2_hours(self):
        self.assertEqual(_parse_frequency('every 2 hours'), ('every_n_hours', 2))

    def test_hourly(self):
        self.assertEqual(_parse_frequency('hourly check'), ('hourly', None))

    def test_mondays(self):
        self.assertEqual(_parse_frequency('on Mondays'), ('monday', None))

    def test_weekdays(self):
        self.assertEqual(_parse_frequency('weekdays at 9am'), ('weekdays', None))

    def test_weekly(self):
        self.assertEqual(_parse_frequency('weekly review'), ('weekly', None))

    def test_monthly(self):
        self.assertEqual(_parse_frequency('monthly report'), ('monthly', None))

    def test_in_minutes(self):
        self.assertEqual(_parse_frequency('in 20 minutes'), ('in_minutes', 20))

    def test_in_minutes_short(self):
        self.assertEqual(_parse_frequency('in 5m'), ('in_minutes', 5))

    def test_weekly_on_mondays(self):
        # "weekly check-in on Mondays" should resolve to monday, not weekly
        self.assertEqual(_parse_frequency('weekly check-in on Mondays'), ('monday', None))

    def test_default_no_keyword(self):
        self.assertEqual(_parse_frequency('reminder at 8am'), ('default', None))

    def test_default_no_keyword_no_time(self):
        self.assertEqual(_parse_frequency('remind me about something'), ('default', None))


class TestChannelDetection(unittest.TestCase):
    def test_telegram(self):
        self.assertEqual(_parse_channel('send on telegram'), 'telegram')

    def test_whatsapp(self):
        self.assertEqual(_parse_channel('on whatsapp please'), 'whatsapp')

    def test_slack(self):
        self.assertEqual(_parse_channel('post on slack'), 'slack')

    def test_discord(self):
        self.assertEqual(_parse_channel('on discord'), 'discord')

    def test_default_telegram(self):
        self.assertEqual(_parse_channel('just remind me'), 'telegram')

    def test_env_override(self):
        os.environ['CRON_DEFAULT_CHANNEL'] = 'whatsapp'
        try:
            self.assertEqual(_parse_channel('just remind me'), 'whatsapp')
        finally:
            del os.environ['CRON_DEFAULT_CHANNEL']


class TestOneShotCommand(unittest.TestCase):
    def test_in_minutes_produces_at_flag(self):
        parsed = parse('Remind me in 20 minutes on telegram')
        self.assertTrue(parsed['one_shot'])
        cmd = build_command(parsed)
        self.assertIn('--at "20m"', cmd)
        self.assertIn('--delete-after-run', cmd)
        self.assertIn('--session isolated', cmd)
        self.assertIn('--deliver', cmd)

    def test_one_shot_no_cron(self):
        parsed = parse('Remind me in 5 minutes')
        cmd = build_command(parsed)
        self.assertNotIn('--cron', cmd)
        self.assertIn('--at "5m"', cmd)


class TestRepeatingCommand(unittest.TestCase):
    def test_daily_produces_cron(self):
        parsed = parse('Create a daily reminder at 8am')
        self.assertFalse(parsed['one_shot'])
        cmd = build_command(parsed)
        self.assertIn('--cron "0 8 * * *"', cmd)
        self.assertIn('--tz "America/New_York"', cmd)
        self.assertNotIn('--delete-after-run', cmd)
        self.assertIn('--session isolated', cmd)
        self.assertIn('--deliver', cmd)

    def test_every_2_hours_produces_every(self):
        parsed = parse('Remind me to drink water every 2 hours')
        cmd = build_command(parsed)
        self.assertIn('--every "2h"', cmd)
        self.assertNotIn('--cron', cmd)

    def test_weekdays(self):
        parsed = parse('Weekday standup at 9am')
        cmd = build_command(parsed)
        self.assertIn('--cron "0 9 * * 1-5"', cmd)

    def test_monday(self):
        parsed = parse('Weekly check-in on Mondays at 9am')
        cmd = build_command(parsed)
        self.assertIn('--cron "0 9 * * 1"', cmd)

    def test_monthly(self):
        parsed = parse('Monthly report at noon')
        cmd = build_command(parsed)
        self.assertIn('--cron "0 12 1 * *"', cmd)

    def test_hourly_produces_every(self):
        parsed = parse('Hourly water reminder')
        cmd = build_command(parsed)
        self.assertIn('--every "1h"', cmd)


class TestEdgeCases(unittest.TestCase):
    def test_no_time_for_repeating(self):
        parsed = parse('daily reminder to stretch')
        # Should still succeed — defaults to 9:00
        self.assertTrue('errors' not in parsed)
        cmd = build_command(parsed)
        self.assertIn('--cron "0 9 * * *"', cmd)

    def test_channel_in_message(self):
        parsed = parse('Send me a quote on discord at 8am')
        self.assertEqual(parsed['channel'], 'discord')

    def test_ikigai_message(self):
        parsed = parse('Create a daily Ikigai reminder at 8:45am')
        self.assertIn('Ikigai', parsed['message'])

    def test_water_message(self):
        parsed = parse('Remind me to drink water every 2 hours')
        self.assertIn('hydrated', parsed['message'])

    def test_command_has_all_required_flags(self):
        parsed = parse('Daily reminder at 8am on telegram')
        cmd = build_command(parsed)
        for flag in ['--session isolated', '--deliver', '--channel', '--to', '--name']:
            self.assertIn(flag, cmd, f'Missing required flag: {flag}')


class TestAtClockOneShot(unittest.TestCase):
    def test_at_3pm_is_one_shot(self):
        parsed = parse('at 3pm')
        self.assertTrue(parsed['one_shot'])
        self.assertEqual(parsed['freq_type'], 'at_clock')
        cmd = build_command(parsed)
        self.assertIn('--at', cmd)
        self.assertIn('--delete-after-run', cmd)
        self.assertNotIn('--cron', cmd)
        # Verify ISO timestamp format in --at value
        self.assertRegex(cmd, r'--at \S*T15:00:00')

    def test_at_noon_is_one_shot(self):
        parsed = parse('at noon')
        self.assertTrue(parsed['one_shot'])
        self.assertEqual(parsed['freq_type'], 'at_clock')
        cmd = build_command(parsed)
        self.assertIn('--at', cmd)
        self.assertIn('--delete-after-run', cmd)
        self.assertRegex(cmd, r'--at \S*T12:00:00')

    def test_daily_at_3pm_still_repeating(self):
        parsed = parse('daily at 3pm')
        self.assertFalse(parsed['one_shot'])
        self.assertEqual(parsed['freq_type'], 'daily')
        cmd = build_command(parsed)
        self.assertIn('--cron "0 15 * * *"', cmd)
        self.assertNotIn('--delete-after-run', cmd)

    def test_no_time_no_keyword_falls_back_to_daily(self):
        parsed = parse('remind me about something')
        self.assertEqual(parsed['freq_type'], 'daily')
        self.assertFalse(parsed['one_shot'])


class TestShellEscaping(unittest.TestCase):
    def test_name_with_semicolon(self):
        parsed = parse('daily reminder at 8am')
        parsed['name'] = 'test; rm -rf /'
        cmd = build_command(parsed)
        # shlex.quote wraps in single quotes — semicolon is safely quoted
        self.assertIn("'test; rm -rf /'", cmd)
        # Verify it's passed as --name value (not bare)
        self.assertIn("--name 'test; rm -rf /'", cmd)

    def test_name_with_single_quote(self):
        parsed = parse('daily reminder at 8am')
        parsed['name'] = "it's a test"
        cmd = build_command(parsed)
        # shlex.quote escapes single quotes
        self.assertNotIn("it's", cmd.replace("it'\\''s", ''))

    def test_destination_with_special_chars(self):
        parsed = parse('daily reminder at 8am')
        parsed['destination'] = '$(whoami)'
        cmd = build_command(parsed)
        # Must be quoted, not bare
        self.assertIn("'$(whoami)'", cmd)

    def test_channel_is_quoted(self):
        parsed = parse('daily reminder at 8am on telegram')
        cmd = build_command(parsed)
        # Channel value should be shell-quoted
        self.assertRegex(cmd, r'--channel \S+')

    def test_message_is_quoted(self):
        parsed = parse('daily reminder at 8am')
        cmd = build_command(parsed)
        self.assertIn('--message', cmd)
        # Message should be a single shell token after --message
        # (shlex.quote wraps in single quotes)
        self.assertRegex(cmd, r"--message '")


if __name__ == '__main__':
    unittest.main()


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