Skill: lucaperret/macos-calendar (version 1.2.0)

--- name: macos-calendar description: Create, list, and manage macOS Calendar events via AppleScript. Use when the user asks to add a reminder, schedule an event, create a calendar entry, set a deadline, or anything involving Apple Calendar on macOS. Triggers on requests like "remind me in 3 days", "add to my calendar", "schedule a meeting next Monday at 2pm", "create a recurring weekly event". macOS only. license: MIT compatibility: Requires macOS with Calendar.app. Uses osascript (AppleScript) and python3 for JSON parsing. metadata: author: lucaperret version: "1.2.0" openclaw: os: macos emoji: "\U0001F4C5" homepage: https://github.com/lucaperret/agent-skills requires: bins: - osascript - python3 --- # macOS Calendar Manage Apple Calendar events via `$SKILL_DIR/scripts/calendar.sh`. All date handling uses relative math (`current date + N * days`) to avoid locale issues (FR/EN/DE date formats). ## Quick start ### List calendars Always list calendars first to find the correct calendar name: ```bash "$SKILL_DIR/scripts/calendar.sh" list-calendars ``` ### Create an event ```bash echo '<json>' | "$SKILL_DIR/scripts/calendar.sh" create-event ``` JSON fields: | Field | Required | Default | Description | |---|---|---|---| | `summary` | yes | - | Event title | | `calendar` | no | first calendar | Calendar name (from list-calendars) | | `description` | no | "" | Event notes | | `offset_days` | no | 0 | Days from today (0=today, 1=tomorrow, 7=next week) | | `iso_date` | no | - | Absolute date `YYYY-MM-DD` (overrides offset_days) | | `hour` | no | 9 | Start hour (0-23) | | `minute` | no | 0 | Start minute (0-59) | | `duration_minutes` | no | 30 | Duration | | `alarm_minutes` | no | 0 | Alert N minutes before (0=no alarm) | | `all_day` | no | false | All-day event | | `recurrence` | no | - | iCal RRULE string. See [references/recurrence.md](references/recurrence.md) | ## Interpreting natural language Map user requests to JSON fields: | User says | JSON | |---|---| | "tomorrow at 2pm" | `offset_days: 1, hour: 14` | | "in 3 days" | `offset_days: 3` | | "next Monday at 10am" | Calculate offset_days from today to next Monday, `hour: 10` | | "February 25 at 3:30pm" | `iso_date: "2026-02-25", hour: 15, minute: 30` | | "every weekday at 9am" | `hour: 9, recurrence: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"` | | "remind me 1 hour before" | `alarm_minutes: 60` | | "all day event on March 1" | `iso_date: "2026-03-01", all_day: true` | For "next Monday", "next Friday" etc: compute the day offset using the current date. Use `date` command if needed: ```bash # Days until next Monday (1=Monday) target=1; today=$(date +%u); echo $(( (target - today + 7) % 7 )) ``` ## Example prompts These are real user prompts and the commands you should run: **"Remind me to call the dentist in 2 days"** ```bash "$SKILL_DIR/scripts/calendar.sh" list-calendars ``` Then: ```bash echo '{"calendar":"Personnel","summary":"Call dentist","offset_days":2,"hour":9,"duration_minutes":15,"alarm_minutes":30}' | "$SKILL_DIR/scripts/calendar.sh" create-event ``` **"Schedule a team sync every Tuesday at 2pm with a 10-min reminder"** ```bash echo '{"calendar":"Work","summary":"Team sync","hour":14,"duration_minutes":60,"recurrence":"FREQ=WEEKLY;BYDAY=TU","alarm_minutes":10}' | "$SKILL_DIR/scripts/calendar.sh" create-event ``` **"Block July 15 as a vacation day"** ```bash echo '{"calendar":"Personnel","summary":"Vacances","iso_date":"2026-07-15","all_day":true}' | "$SKILL_DIR/scripts/calendar.sh" create-event ``` **"I have a doctor appointment next Thursday at 3:30pm, remind me 1 hour before"** ```bash # First compute offset_days to next Thursday (4=Thursday) target=4; today=$(date +%u); offset=$(( (target - today + 7) % 7 )); [ "$offset" -eq 0 ] && offset=7 ``` Then: ```bash echo "{\"calendar\":\"Personnel\",\"summary\":\"Doctor appointment\",\"offset_days\":$offset,\"hour\":15,\"minute\":30,\"duration_minutes\":60,\"alarm_minutes\":60}" | "$SKILL_DIR/scripts/calendar.sh" create-event ``` **"Set up a daily standup at 9am on weekdays for the next 4 weeks"** ```bash echo '{"calendar":"Work","summary":"Daily standup","hour":9,"duration_minutes":15,"recurrence":"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=20"}' | "$SKILL_DIR/scripts/calendar.sh" create-event ``` **"Add a biweekly 1-on-1 with my manager on Fridays at 11am"** ```bash echo '{"calendar":"Work","summary":"1-on-1 Manager","hour":11,"duration_minutes":30,"recurrence":"FREQ=WEEKLY;INTERVAL=2;BYDAY=FR","alarm_minutes":5}' | "$SKILL_DIR/scripts/calendar.sh" create-event ``` ## Critical rules 1. **Always list calendars first** if the user hasn't specified one — calendars marked `[read-only]` cannot be used for event creation 2. **Never use hardcoded date strings** in AppleScript — always use `offset_days` or `iso_date` 3. **Confirm the calendar name** with the user if multiple personal calendars exist 4. **Never target a `[read-only]` calendar** — the script will reject it with an error 5. **For recurring events**, consult [references/recurrence.md](references/recurrence.md) for RRULE syntax 6. **Pass JSON via stdin** — never as a CLI argument (avoids leaking data in process list) 7. **All fields are validated** by the script (type coercion, range checks, format validation) — invalid input is rejected with an error message 8. **All actions are logged** to `logs/calendar.log` with timestamp, command, calendar, and summary

path: references/recurrence.md
content: # iCal Recurrence Rules (RRULE)

Apple Calendar uses standard iCal RRULE format for recurring events.

## Common patterns

| Pattern | RRULE |
|---|---|
| Daily | `FREQ=DAILY;INTERVAL=1` |
| Every weekday | `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR` |
| Weekly | `FREQ=WEEKLY;INTERVAL=1` |
| Biweekly | `FREQ=WEEKLY;INTERVAL=2` |
| Monthly (same date) | `FREQ=MONTHLY;INTERVAL=1` |
| Monthly (e.g. 2nd Tuesday) | `FREQ=MONTHLY;BYDAY=2TU` |
| Yearly | `FREQ=YEARLY;INTERVAL=1` |

## Limiting recurrence

- End after N occurrences: add `COUNT=10`
- End by date: add `UNTIL=20261231T000000Z`

## Examples

- Every Monday and Wednesday: `FREQ=WEEKLY;BYDAY=MO,WE`
- First Friday of every month: `FREQ=MONTHLY;BYDAY=1FR`
- Every 3 days for 5 times: `FREQ=DAILY;INTERVAL=3;COUNT=5`


path: scripts/calendar.sh
content: #!/bin/bash
# macOS Calendar helper via AppleScript
# Usage: calendar.sh <command>
#
# Commands:
#   list-calendars              List all available calendars
#   create-event                Create an event from JSON (reads stdin)

set -euo pipefail

# Verify required dependencies are available
for bin in osascript python3; do
  command -v "$bin" >/dev/null 2>&1 || { echo "Error: $bin is required but not found" >&2; exit 1; }
done

# Ensure Calendar.app is running (avoids AppleScript error -600)
if ! pgrep -q "Calendar"; then
  open -a Calendar
  sleep 2
fi

LOGFILE="${SKILL_DIR:-$(dirname "$0")/..}/logs/calendar.log"

# SR-004: Append-only action log
log_action() {
  mkdir -p "$(dirname "$LOGFILE")"
  printf '%s\t%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" "$3" >> "$LOGFILE"
}

cmd="${1:-help}"

case "$cmd" in
  list-calendars)
    osascript -e 'tell application "Calendar"
      set output to ""
      repeat with c in calendars
        if writable of c then
          set output to output & name of c & linefeed
        else
          set output to output & name of c & " [read-only]" & linefeed
        end if
      end repeat
      return output
    end tell'
    log_action "list-calendars" "-" "-"
    ;;

  create-event)
    # Read JSON from stdin (avoids exposing sensitive data in process list)
    json=$(cat)

    # Validate, normalize, and extract all fields in a single Python call.
    # Outputs tab-separated values on one line.
    # Tabs and newlines in string values are replaced with spaces for safe parsing.
    # JSON is passed via environment variable (not pipe) because the heredoc
    # already occupies stdin — a pipe would be silently discarded by bash.
    validated=$(CALENDAR_JSON="$json" python3 << 'PYEOF'
import os, sys, json

try:
    data = json.loads(os.environ['CALENDAR_JSON'])
except json.JSONDecodeError as e:
    print(f"Error: invalid JSON: {e}", file=sys.stderr)
    sys.exit(1)

if 'summary' not in data:
    print("Error: 'summary' field is required", file=sys.stderr)
    sys.exit(1)

try:
    summary = str(data['summary'])
    calendar = str(data.get('calendar', ''))
    description = str(data.get('description', ''))
    recurrence = str(data.get('recurrence', ''))
    iso_date = str(data.get('iso_date', ''))
    offset_days = int(data.get('offset_days', 0))
    hour = int(data.get('hour', 9))
    minute = int(data.get('minute', 0))
    duration_min = int(data.get('duration_minutes', 30))
    alarm_min = int(data.get('alarm_minutes', 0))
    all_day = bool(data.get('all_day', False))
except (ValueError, TypeError) as e:
    print(f"Error: invalid field value: {e}", file=sys.stderr)
    sys.exit(1)

# Range checks
errors = []
if not 0 <= hour <= 23: errors.append("hour must be 0-23")
if not 0 <= minute <= 59: errors.append("minute must be 0-59")
if duration_min < 0: errors.append("duration_minutes must be >= 0")
if alarm_min < 0: errors.append("alarm_minutes must be >= 0")

# Validate and normalize iso_date (ensure zero-padded YYYY-MM-DD)
if iso_date:
    parts = iso_date.split('-')
    if len(parts) != 3:
        errors.append("iso_date must be YYYY-MM-DD")
    else:
        try:
            y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
            if not (1 <= m <= 12 and 1 <= d <= 31 and y >= 1):
                errors.append("iso_date has invalid date values")
            else:
                iso_date = f"{y:04d}-{m:02d}-{d:02d}"
        except ValueError:
            errors.append("iso_date must contain numeric values")

if errors:
    for e in errors:
        print(f"Error: {e}", file=sys.stderr)
    sys.exit(1)

# Safe output: replace newlines in string values (one field per line)
def safe(s):
    return s.replace('\n', ' ').replace('\r', '')

fields = [
    safe(summary), safe(calendar), safe(description), safe(recurrence), safe(iso_date),
    str(offset_days), str(hour), str(minute), str(duration_min), str(alarm_min),
    'true' if all_day else 'false'
]
for f in fields:
    print(f)
PYEOF
    )

    # Read validated values (one field per line, handles empty fields correctly)
    {
      read -r summary
      read -r calendar
      read -r description
      read -r recurrence
      read -r iso_date
      read -r offset_days
      read -r hour
      read -r minute
      read -r duration_min
      read -r alarm_min
      read -r all_day
    } <<< "$validated"

    # Defense-in-depth: verify numeric fields are pure integers
    for var in offset_days hour minute duration_min alarm_min; do
        if ! [[ "${!var}" =~ ^-?[0-9]+$ ]]; then
            echo "Error: $var must be an integer" >&2
            exit 1
        fi
    done

    # Auto-detect calendar if not specified
    if [ -z "$calendar" ]; then
        calendar=$(osascript -e 'tell application "Calendar" to get name of first calendar')
    fi

    # Execute via osascript with argv parameter passing.
    # All user-provided strings are passed as typed parameters via "on run argv",
    # never interpolated into executable AppleScript code. This prevents injection.
    result=$(osascript - "$summary" "$description" "$calendar" "$recurrence" \
        "$offset_days" "$hour" "$minute" "$duration_min" "$alarm_min" \
        "$all_day" "$iso_date" <<'APPLESCRIPT'
on run argv
    set evtSummary to item 1 of argv
    set evtDescription to item 2 of argv
    set calName to item 3 of argv
    set evtRecurrence to item 4 of argv
    set offsetDays to (item 5 of argv) as integer
    set evtHour to (item 6 of argv) as integer
    set evtMinute to (item 7 of argv) as integer
    set durationMin to (item 8 of argv) as integer
    set alarmMin to (item 9 of argv) as integer
    set isAllDay to (item 10 of argv) is "true"
    set isoDate to item 11 of argv

    -- Calculate start date
    if isoDate is not "" then
        set startDate to current date
        set year of startDate to (text 1 thru 4 of isoDate) as integer
        set month of startDate to (text 6 thru 7 of isoDate) as integer
        set day of startDate to (text 9 thru 10 of isoDate) as integer
        set hours of startDate to evtHour
        set minutes of startDate to evtMinute
        set seconds of startDate to 0
    else
        set startDate to (current date) + offsetDays * days
        set hours of startDate to evtHour
        set minutes of startDate to evtMinute
        set seconds of startDate to 0
    end if

    -- Create event
    tell application "Calendar"
        -- SR-001: Reject read-only calendars
        if not (writable of calendar calName) then
            error "Calendar '" & calName & "' is read-only. Choose a writable calendar."
        end if
        tell calendar calName
            if isAllDay then
                set newEvent to make new event with properties {summary:evtSummary, start date:startDate, end date:startDate, allday event:true, description:evtDescription}
            else
                set endDate to startDate + durationMin * minutes
                set newEvent to make new event with properties {summary:evtSummary, start date:startDate, end date:endDate, description:evtDescription}
            end if

            -- Set recurrence if provided
            if evtRecurrence is not "" then
                set recurrence of newEvent to evtRecurrence
            end if

            -- Set alarm if provided
            if alarmMin > 0 then
                make new display alarm at end of newEvent with properties {trigger interval:-alarmMin}
            end if
        end tell
    end tell

    return "Event created: " & evtSummary
end run
APPLESCRIPT
    )
    log_action "create-event" "$calendar" "$summary"
    echo "$result"
    ;;

  help|*)
    echo "macOS Calendar CLI"
    echo ""
    echo "Commands:"
    echo "  list-calendars              List all calendars"
    echo "  create-event                Create event from JSON (reads stdin)"
    echo ""
    echo "Usage:"
    echo "  echo '<json>' | calendar.sh create-event"
    echo ""
    echo "JSON fields:"
    echo "  summary        (required) Event title"
    echo "  calendar       Calendar name (auto-detects if omitted)"
    echo "  description    Event notes"
    echo "  offset_days    Days from today (default: 0)"
    echo "  iso_date       Absolute date YYYY-MM-DD (overrides offset_days)"
    echo "  hour           Start hour 0-23 (default: 9)"
    echo "  minute         Start minute 0-59 (default: 0)"
    echo "  duration_minutes  Duration in minutes (default: 30)"
    echo "  alarm_minutes  Alert before event in minutes (0=none)"
    echo "  all_day        true/false (default: false)"
    echo "  recurrence     iCal RRULE (e.g. FREQ=WEEKLY;BYDAY=TU)"
    ;;
esac


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