diff --git a/skills/calendar/SKILL.md b/skills/calendar/SKILL.md new file mode 100644 index 0000000..d97a39a --- /dev/null +++ b/skills/calendar/SKILL.md @@ -0,0 +1,313 @@ +--- +name: calendar +description: "Calendar invites and VTODO task management via CalDAV. Send/reply to invites, create/list/complete/delete todos. Syncs to Migadu CalDAV via vdirsyncer." +metadata: {"clawdbot":{"emoji":"📅","requires":{"bins":["himalaya","vdirsyncer"],"skills":["himalaya"]}}} +--- + +# Calendar + +Send, accept, and decline calendar invitations via email. Create and manage VTODO tasks with CalDAV sync. Events and tasks sync to Migadu CalDAV via vdirsyncer. + +## Testing + +See `TESTING.md` for dry-run and live test steps, verification checklists, and troubleshooting. + +## Prerequisites + +- `himalaya` configured and working (see the `himalaya` skill) +- `vdirsyncer` configured and syncing to `~/.openclaw/workspace/calendars/` +- `khal` for reading calendar (optional but recommended) +- Runs via `uv run` (dependencies managed in `pyproject.toml`) + +## Important: Email Sending Rules + +Calendar invites are outbound emails. Follow the workspace email rules: +- **youlu@luyanxin.com -> mail@luyx.org**: send directly, no confirmation needed +- **All other recipients**: confirm with user before sending + +## Owner Auto-Attendee + +When sending invites, `mail@luyx.org` (owner's SimpleLogin alias) is **always added as an attendee automatically**. All invites include a **1-day reminder** (VALARM) by default. This ensures the owner receives every invite and can Accept/Decline from their own email client. No need to include it in `--to` — it's added by the script. + +When accepting or tentatively accepting a received invite, the original invite is **automatically forwarded to `mail@luyx.org`** so the event lands on the owner's calendar too. + +## Usage + +All commands go through the wrapper script: + +```bash +SKILL_DIR=~/.openclaw/workspace/skills/calendar + +# Send an invite +$SKILL_DIR/scripts/calendar.sh send [options] + +# Reply to an invite +$SKILL_DIR/scripts/calendar.sh reply [options] + +# Manage todos +$SKILL_DIR/scripts/calendar.sh todo add [options] +$SKILL_DIR/scripts/calendar.sh todo list [options] +$SKILL_DIR/scripts/calendar.sh todo complete [options] +$SKILL_DIR/scripts/calendar.sh todo delete [options] +$SKILL_DIR/scripts/calendar.sh todo check +``` + +--- + +## Sending Invites + +```bash +$SKILL_DIR/scripts/calendar.sh send \ + --to "friend@example.com" \ + --subject "Lunch on Friday" \ + --summary "Lunch at Tartine" \ + --start "2026-03-20T12:00:00" \ + --end "2026-03-20T13:00:00" \ + --location "Tartine Bakery, SF" +``` + +### Send Options + +| Flag | Required | Description | +|-----------------|----------|------------------------------------------------| +| `--to` | Yes | Recipient(s), comma-separated | +| `--subject` | Yes | Email subject line | +| `--summary` | Yes | Event title (shown on calendar) | +| `--start` | Yes | Start time, ISO 8601 (`2026-03-20T14:00:00`) | +| `--end` | Yes | End time, ISO 8601 (`2026-03-20T15:00:00`) | +| `--from` | No | Sender email (default: `youlu@luyanxin.com`) | +| `--timezone` | No | IANA timezone (default: `America/Los_Angeles`) | +| `--location` | No | Event location | +| `--description` | No | Event description / notes | +| `--organizer` | No | Organizer display name (defaults to `--from`) | +| `--uid` | No | Custom event UID (auto-generated if omitted) | +| `--account` | No | Himalaya account name (if not default) | +| `--dry-run` | No | Print ICS + MIME without sending | + +### Send Examples + +```bash +# Simple invite (--from and --timezone default to youlu@luyanxin.com / LA) +$SKILL_DIR/scripts/calendar.sh send \ + --to "alice@example.com" \ + --subject "Coffee Chat" \ + --summary "Coffee Chat" \ + --start "2026-03-25T10:00:00" \ + --end "2026-03-25T10:30:00" + +# Multiple attendees with details +$SKILL_DIR/scripts/calendar.sh send \ + --to "alice@example.com, bob@example.com" \ + --subject "Team Sync" \ + --summary "Weekly Team Sync" \ + --start "2026-03-23T09:00:00" \ + --end "2026-03-23T09:30:00" \ + --location "Zoom - https://zoom.us/j/123456" \ + --description "Weekly check-in. Agenda: updates, blockers, action items." + +# Dry run +$SKILL_DIR/scripts/calendar.sh send \ + --to "test@example.com" \ + --subject "Test" \ + --summary "Test Event" \ + --start "2026-04-01T15:00:00" \ + --end "2026-04-01T16:00:00" \ + --dry-run +``` + +--- + +## Replying to Invites + +```bash +# Accept by himalaya envelope ID +$SKILL_DIR/scripts/calendar.sh reply \ + --envelope-id 42 \ + --action accept + +# Decline with a comment +$SKILL_DIR/scripts/calendar.sh reply \ + --envelope-id 42 \ + --action decline \ + --comment "Sorry, I have a conflict." + +# From an .ics file +$SKILL_DIR/scripts/calendar.sh reply \ + --ics-file ~/Downloads/meeting.ics \ + --action tentative +``` + +### Reply Options + +| Flag | Required | Description | +|-----------------|----------|-----------------------------------------------------| +| `--action` | Yes | `accept`, `decline`, or `tentative` | +| `--envelope-id` | * | Himalaya envelope ID containing the .ics attachment | +| `--ics-file` | * | Path to an .ics file (alternative to `--envelope-id`) | +| `--from` | No | Your email (default: `youlu@luyanxin.com`) | +| `--account` | No | Himalaya account name | +| `--folder` | No | Himalaya folder (default: `INBOX`) | +| `--comment` | No | Optional message to include in reply | +| `--dry-run` | No | Preview without sending | + +\* One of `--envelope-id` or `--ics-file` is required. + +### Typical Workflow + +1. List emails: `himalaya envelope list` +2. Read the invite: `himalaya message read 57` +3. Reply: `$SKILL_DIR/scripts/calendar.sh reply --envelope-id 57 --action accept` + +--- + +## VTODO Tasks + +Manage tasks as RFC 5545 VTODO components, stored in `~/.openclaw/workspace/calendars/tasks/` and synced to CalDAV. + +### Sync Model + +The agent's local CalDAV is the **source of truth** (no two-way sync). When a todo is created, it's saved locally and emailed to `mail@luyx.org` as a delivery copy. When the user completes a task, they tell the agent, and the agent runs `todo complete`. The daily `todo check` cron reads from local files. + +### Priority Mapping (RFC 5545) + +| Label | `--priority` | RFC 5545 value | +|--------|-------------|----------------| +| 高 (high) | `high` | 1 | +| 中 (medium) | `medium` | 5 (default) | +| 低 (low) | `low` | 9 | + +### `todo add` — Create a Todo + +```bash +$SKILL_DIR/scripts/calendar.sh todo add \ + --summary "跟进iui保险报销" \ + --due "2026-03-25" \ + --priority high \ + --description "确认iui费用保险报销进度" \ + --alarm 1d +``` + +| Flag | Required | Description | +|-----------------|----------|-----------------------------------------------------| +| `--summary` | Yes | Todo title | +| `--due` | No | Due date, YYYY-MM-DD (default: tomorrow) | +| `--priority` | No | `high`, `medium`, or `low` (default: `medium`) | +| `--description` | No | Notes / description | +| `--alarm` | No | Reminder trigger: `1d`, `2h`, `30m` (default: `1d`) | +| `--dry-run` | No | Preview ICS + email without saving | + +### `todo list` — List Todos + +```bash +$SKILL_DIR/scripts/calendar.sh todo list # pending only +$SKILL_DIR/scripts/calendar.sh todo list --all # include completed +``` + +### `todo complete` — Mark as Done + +```bash +$SKILL_DIR/scripts/calendar.sh todo complete --uid "abc123@openclaw" +$SKILL_DIR/scripts/calendar.sh todo complete --match "保险报销" +``` + +### `todo delete` — Remove a Todo + +```bash +$SKILL_DIR/scripts/calendar.sh todo delete --uid "abc123@openclaw" +$SKILL_DIR/scripts/calendar.sh todo delete --match "保险报销" +``` + +### `todo check` — Daily Digest (Cron) + +```bash +$SKILL_DIR/scripts/calendar.sh todo check +``` + +Same as `todo list` but only NEEDS-ACTION items. Exits silently when no pending items. Output is designed for piping to himalaya. + +--- + +## How It Works + +**Sending invites:** +1. Generates an RFC 5545 ICS file with `METHOD:REQUEST` (via `icalendar` library) +2. Builds a MIME email with a `text/calendar` attachment (via Python `email.mime`) +3. Sends via `himalaya message send` (piped through stdin) +4. Saves the event to `~/.openclaw/workspace/calendars/home/` +5. Runs `vdirsyncer sync` to push to Migadu CalDAV + +**Replying to invites:** +1. Extracts the `.ics` attachment from the email (via `himalaya attachment download`) +2. Parses the original event with the `icalendar` library +3. Generates a reply ICS with `METHOD:REPLY` and the correct `PARTSTAT` +4. Sends the reply to the organizer via `himalaya message send` (stdin) +5. On accept/tentative: saves event to local calendar. On decline: removes it +6. Runs `vdirsyncer sync` to push changes to Migadu CalDAV + +**Managing todos:** +1. Creates a VTODO ICS file with `STATUS:NEEDS-ACTION`, `PRIORITY`, `DUE`, `VALARM` +2. Saves to `~/.openclaw/workspace/calendars/tasks/` +3. Emails the VTODO to `mail@luyx.org` as a delivery copy +4. Runs `vdirsyncer sync` to push to Migadu CalDAV +5. `todo check` reads local files for the daily cron digest + +**CalDAV sync:** +- Events and tasks sync to Migadu and appear on all connected devices (DAVx5, etc.) +- Heartbeat runs `vdirsyncer sync` periodically as a fallback +- If sync fails, it warns but doesn't block — next heartbeat catches up + +## Integration with Email Processor + +The email processor (`scripts/email_processor/`) may classify incoming calendar invites as `reminder` or `confirmation`. When reviewing pending emails: +1. Check if the email contains a calendar invite (look for `.ics` attachment or "calendar" in subject) +2. If it does, use `reply` instead of the email processor's delete/archive/keep actions +3. The email processor handles the email lifecycle; this skill handles the calendar response + +## Checking the Calendar + +```bash +# List upcoming events (next 7 days) +khal list today 7d + +# List events for a specific date +khal list 2026-03-25 + +# Check for conflicts before sending an invite +khal list 2026-03-25 2026-03-26 +``` + +## Timezone Reference + +Common IANA timezones: +- `America/Los_Angeles` — Pacific (default) +- `America/Denver` — Mountain +- `America/Chicago` — Central +- `America/New_York` — Eastern +- `Asia/Shanghai` — China +- `Asia/Tokyo` — Japan +- `Europe/London` — UK +- `UTC` — Coordinated Universal Time + +## Troubleshooting + +**Invite shows as attachment instead of calendar event?** +- Ensure the MIME part has `Content-Type: text/calendar; method=REQUEST` +- Some clients require the `METHOD:REQUEST` line in the ICS body + +**Times are wrong?** +- Double-check `--timezone` matches the intended timezone +- Use ISO 8601 format: `YYYY-MM-DDTHH:MM:SS` (no timezone offset in the value) + +**Event not showing on phone/other devices?** +- Run `vdirsyncer sync` manually to force sync +- Check `~/.openclaw/workspace/logs/vdirsyncer.log` for errors +- Verify the .ics file exists in `~/.openclaw/workspace/calendars/home/` + +**Todos not syncing?** +- Check that `~/.openclaw/workspace/calendars/tasks/` exists +- Verify vdirsyncer has a `cal/tasks` pair configured +- Run `vdirsyncer sync` manually + +**Recipient doesn't see Accept/Decline?** +- Gmail, Outlook, Apple Mail all support `text/calendar` method=REQUEST +- Some webmail clients may vary diff --git a/skills/calendar/TESTING.md b/skills/calendar/TESTING.md new file mode 100644 index 0000000..96f8f6c --- /dev/null +++ b/skills/calendar/TESTING.md @@ -0,0 +1,293 @@ +# Testing the Calendar Skill + +End-to-end tests for send, reply, todo, calendar sync, and local calendar. All commands use `--dry-run` first, then live. + +```bash +SKILL_DIR=~/.openclaw/workspace/skills/calendar + +# Use a date 3 days from now for test events +TEST_DATE=$(date -d "+3 days" +%Y-%m-%d) +``` + +--- + +## 1. Dry Run: Send Invite + +Generates the ICS and MIME email without sending. Check that: +- ICS has `METHOD:REQUEST` +- MIME has `Content-Type: text/calendar; method=REQUEST` +- `mail@luyx.org` appears as attendee (auto-added) +- Times and timezone look correct + +```bash +$SKILL_DIR/scripts/calendar.sh send \ + --to "mail@luyx.org" \ + --subject "Test Invite" \ + --summary "Test Event" \ + --start "${TEST_DATE}T15:00:00" \ + --end "${TEST_DATE}T16:00:00" \ + --dry-run +``` + +## 2. Live Send: Self-Invite + +Send a real invite to `mail@luyx.org` only (no confirmation needed per email rules). + +```bash +$SKILL_DIR/scripts/calendar.sh send \ + --to "mail@luyx.org" \ + --subject "Calendar Skill Test" \ + --summary "Calendar Skill Test" \ + --start "${TEST_DATE}T15:00:00" \ + --end "${TEST_DATE}T16:00:00" \ + --location "Test Location" +``` + +**Verify:** +- [ ] Script exits without error +- [ ] Email arrives at `mail@luyx.org` +- [ ] Email shows Accept/Decline/Tentative buttons (not just an attachment) +- [ ] `.ics` file saved to `~/.openclaw/workspace/calendars/home/` + +## 3. Verify Calendar Sync and Local Calendar + +After sending in step 2, check that the event synced and appears locally. + +```bash +# Check vdirsyncer sync ran (should have printed "Synced to CalDAV server" in step 2) +# If not, run manually: +vdirsyncer sync + +# List .ics files in local calendar +ls ~/.openclaw/workspace/calendars/home/ + +# Check the event shows up in khal +khal list "$TEST_DATE" +``` + +**Verify:** +- [ ] `vdirsyncer sync` completes without errors +- [ ] `.ics` file exists in `~/.openclaw/workspace/calendars/home/` +- [ ] `khal list` shows "Calendar Skill Test" on the test date + +## 4. Reply: Accept the Self-Invite + +The invite sent in step 2 should be in the inbox. Find it, then accept it. This tests the full reply flow without needing an external sender. + +```bash +# Find the test invite in inbox +himalaya envelope list + +# Confirm it's the calendar invite +himalaya message read + +# Accept it +$SKILL_DIR/scripts/calendar.sh reply \ + --envelope-id \ + --action accept +``` + +**Verify:** +- [ ] Reply sent to organizer (youlu@luyanxin.com, i.e. ourselves) +- [ ] Original invite forwarded to `mail@luyx.org` +- [ ] Event still in `~/.openclaw/workspace/calendars/home/` +- [ ] `vdirsyncer sync` ran +- [ ] `khal list "$TEST_DATE"` still shows the event + +## 5. Reply: Decline an Invite + +Send another self-invite, then decline it. This verifies decline removes the event from local calendar. + +```bash +# Send a second test invite +$SKILL_DIR/scripts/calendar.sh send \ + --to "mail@luyx.org" \ + --subject "Decline Test" \ + --summary "Decline Test Event" \ + --start "${TEST_DATE}T17:00:00" \ + --end "${TEST_DATE}T18:00:00" + +# Find it in inbox +himalaya envelope list + +# Decline it +$SKILL_DIR/scripts/calendar.sh reply \ + --envelope-id \ + --action decline \ + --comment "Testing decline flow." +``` + +**Verify:** +- [ ] Reply sent to organizer with comment +- [ ] Event NOT forwarded to `mail@luyx.org` +- [ ] Event removed from local calendar +- [ ] `khal list "$TEST_DATE"` does NOT show "Decline Test Event" + +## 6. Verify Final Calendar State + +After all tests, confirm the calendar is in a clean state. + +```bash +# Sync one more time +vdirsyncer sync + +# Only the accepted event should remain +khal list "$TEST_DATE" + +# List all upcoming events +khal list today 7d +``` + +--- + +## 7. Dry Run: Add Todo + +Generates the VTODO ICS and MIME email without saving. Check that: +- ICS has `BEGIN:VTODO` +- ICS has correct `PRIORITY` value (1 for high) +- ICS has `STATUS:NEEDS-ACTION` +- ICS has `BEGIN:VALARM` +- MIME has `Content-Type: text/calendar` + +```bash +$SKILL_DIR/scripts/calendar.sh todo add \ + --summary "Test Todo" \ + --due "$TEST_DATE" \ + --priority high \ + --dry-run +``` + +## 8. Live Add: Create a Todo + +```bash +$SKILL_DIR/scripts/calendar.sh todo add \ + --summary "Test Todo" \ + --due "$TEST_DATE" \ + --priority medium \ + --description "Test description" +``` + +**Verify:** +- [ ] Script exits without error +- [ ] `.ics` file created in `~/.openclaw/workspace/calendars/tasks/` +- [ ] Email arrives at `mail@luyx.org` with .ics attachment +- [ ] `vdirsyncer sync` ran + +## 9. List Todos + +```bash +# List pending todos +$SKILL_DIR/scripts/calendar.sh todo list + +# List all (including completed) +$SKILL_DIR/scripts/calendar.sh todo list --all +``` + +**Verify:** +- [ ] "Test Todo" appears with correct urgency label +- [ ] Priority grouping is correct +- [ ] `--all` flag works (same output when none are completed) + +## 10. Complete a Todo + +```bash +$SKILL_DIR/scripts/calendar.sh todo complete --match "Test Todo" +``` + +**Verify:** +- [ ] .ics file updated with `STATUS:COMPLETED` and `COMPLETED:` timestamp +- [ ] `todo list` — "Test Todo" no longer appears +- [ ] `todo list --all` — "Test Todo" appears as completed (with checkmark) +- [ ] `vdirsyncer sync` ran + +## 11. Delete a Todo + +Create a second test todo, then delete it. + +```bash +# Create +$SKILL_DIR/scripts/calendar.sh todo add \ + --summary "Delete Me Todo" \ + --due "$TEST_DATE" \ + --priority low + +# Delete +$SKILL_DIR/scripts/calendar.sh todo delete --match "Delete Me" +``` + +**Verify:** +- [ ] .ics file removed from tasks dir +- [ ] `todo list` does not show "Delete Me Todo" +- [ ] `vdirsyncer sync` ran + +## 12. Todo Check (Cron Output) + +```bash +# Create a test todo +$SKILL_DIR/scripts/calendar.sh todo add \ + --summary "Check Test Todo" \ + --due "$TEST_DATE" + +# Run check +$SKILL_DIR/scripts/calendar.sh todo check +``` + +**Verify:** +- [ ] Output matches daily digest format (priority groups, urgency labels) +- [ ] Complete the todo, run `todo check` again — silent exit (no output) + +```bash +$SKILL_DIR/scripts/calendar.sh todo complete --match "Check Test" +$SKILL_DIR/scripts/calendar.sh todo check +# Should produce no output +``` + +## 13. Regression: Existing Invite Commands + +Verify the rename didn't break VEVENT flow. + +```bash +$SKILL_DIR/scripts/calendar.sh send \ + --to "test@example.com" \ + --subject "Regression Test" \ + --summary "Regression Test Event" \ + --start "${TEST_DATE}T10:00:00" \ + --end "${TEST_DATE}T11:00:00" \ + --dry-run +``` + +**Verify:** +- [ ] ICS has `BEGIN:VEVENT`, `METHOD:REQUEST` +- [ ] No errors from the renamed script + +--- + +## Quick Health Checks + +Run these first if any step fails. + +```bash +# icalendar library is available +uv run --project $SKILL_DIR python -c "import icalendar; print('ok')" + +# himalaya can list emails +himalaya envelope list --page-size 5 + +# vdirsyncer can sync +vdirsyncer sync + +# khal can read local calendar +khal list today 7d +``` + +## Common Failures + +| Symptom | Likely Cause | +|---------|-------------| +| `himalaya message send` errors | SMTP config issue, check `~/.config/himalaya/config.toml` | +| No `.ics` attachment found | Email doesn't have a calendar invite, or himalaya can't download attachments | +| `vdirsyncer sync` fails | Check credentials in `~/.config/vdirsyncer/config`, or server is unreachable | +| `ModuleNotFoundError: icalendar` | Run `uv sync --project $SKILL_DIR` to install dependencies | +| Invite shows as attachment (no Accept/Decline) | Check MIME `Content-Type` includes `method=REQUEST` | +| Event not in `khal list` after sync | Check `.ics` file exists in `~/.openclaw/workspace/calendars/home/` | +| Todo not syncing | Check `~/.openclaw/workspace/calendars/tasks/` exists, verify vdirsyncer `cal/tasks` pair | diff --git a/skills/calendar/_meta.json b/skills/calendar/_meta.json new file mode 100644 index 0000000..6529bc4 --- /dev/null +++ b/skills/calendar/_meta.json @@ -0,0 +1,5 @@ +{ + "ownerId": "kn7anq2d7gcch060anc2j9cg89800dyv", + "slug": "calendar", + "version": "1.0.0" +} diff --git a/skills/calendar/pyproject.toml b/skills/calendar/pyproject.toml new file mode 100644 index 0000000..1b43063 --- /dev/null +++ b/skills/calendar/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "calendar" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["icalendar"] diff --git a/skills/calendar/scripts/cal_tool.py b/skills/calendar/scripts/cal_tool.py new file mode 100644 index 0000000..1479565 --- /dev/null +++ b/skills/calendar/scripts/cal_tool.py @@ -0,0 +1,820 @@ +#!/usr/bin/env python3 +""" +Calendar — Send/reply to calendar invites and manage VTODO tasks via CalDAV. + +Uses the icalendar library for proper RFC 5545 ICS generation and parsing. +Uses himalaya CLI for email delivery. Syncs to local CalDAV via vdirsyncer. + +Subcommands: + python calendar.py send [options] # create and send an invite + python calendar.py reply [options] # accept/decline/tentative + python calendar.py todo add [options] # create a VTODO task + python calendar.py todo list [options] # list pending tasks + python calendar.py todo complete [options] # mark task as done + python calendar.py todo delete [options] # remove a task + python calendar.py todo check # daily digest for cron +""" + +import argparse +import subprocess +import sys +import uuid +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vText + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +DEFAULT_TIMEZONE = "America/Los_Angeles" +DEFAULT_FROM = "youlu@luyanxin.com" +DEFAULT_OWNER_EMAIL = "mail@luyx.org" # Always added as attendee +CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home" +TASKS_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "tasks" +PRODID = "-//OpenClaw//Calendar//EN" + +# RFC 5545 priority mapping +PRIORITY_MAP = {"high": 1, "medium": 5, "low": 9} +PRIORITY_LABELS = {1: "高", 5: "中", 9: "低"} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _sync_calendar(): + """Sync local calendar to CalDAV server via vdirsyncer.""" + try: + subprocess.run( + ["vdirsyncer", "sync"], + capture_output=True, text=True, check=True, + ) + print("Synced to CalDAV server") + except (subprocess.CalledProcessError, FileNotFoundError): + print("Warning: CalDAV sync failed (will retry on next heartbeat)") + + +def _send_email(email_str, account=None): + """Send a raw MIME email via himalaya message send (stdin).""" + cmd = ["himalaya"] + if account: + cmd += ["--account", account] + cmd += ["message", "send"] + subprocess.run(cmd, input=email_str, text=True, check=True) + + +def _build_calendar_email(from_addr, to_addr, subject, body, ics_bytes, method="REQUEST"): + """Build a MIME email with a text/calendar attachment.""" + msg = MIMEMultipart('mixed') + msg['From'] = from_addr + msg['To'] = to_addr + msg['Subject'] = subject + msg.attach(MIMEText(body, 'plain', 'utf-8')) + ics_part = MIMEBase('text', 'calendar', method=method, charset='utf-8') + ics_part.set_payload(ics_bytes.decode('utf-8')) + ics_part.add_header('Content-Disposition', 'attachment; filename="invite.ics"') + msg.attach(ics_part) + return msg.as_string() + + +def _strip_method(ics_bytes): + """Remove METHOD property from ICS for CalDAV storage. + + CalDAV servers reject METHOD (it's an iTIP/email concept, not a storage one). + """ + cal = Calendar.from_ical(ics_bytes) + if "method" in cal: + del cal["method"] + return cal.to_ical() + + +def _parse_iso_datetime(dt_str): + """Parse ISO 8601 datetime string to a datetime object.""" + # Handle both 2026-03-20T14:00:00 and 2026-03-20T14:00 + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): + try: + return datetime.strptime(dt_str, fmt) + except ValueError: + continue + raise ValueError(f"Cannot parse datetime: {dt_str}") + + +def _parse_date(date_str): + """Parse YYYY-MM-DD date string.""" + return datetime.strptime(date_str, "%Y-%m-%d").date() + + +# --------------------------------------------------------------------------- +# Send invite +# --------------------------------------------------------------------------- + +def cmd_send(args): + """Create and send a calendar invite.""" + start = _parse_iso_datetime(args.start) + end = _parse_iso_datetime(args.end) + uid = args.uid or f"{uuid.uuid4()}@openclaw" + organizer_name = args.organizer or args.sender + + # Build ICS + cal = Calendar() + cal.add("prodid", PRODID) + cal.add("version", "2.0") + cal.add("calscale", "GREGORIAN") + cal.add("method", "REQUEST") + + event = Event() + event.add("uid", uid) + event.add("dtstamp", datetime.now(timezone.utc)) + event.add("dtstart", start, parameters={"TZID": args.timezone}) + event.add("dtend", end, parameters={"TZID": args.timezone}) + event.add("summary", args.summary) + event.add("status", "CONFIRMED") + event.add("sequence", 0) + organizer = vCalAddress(f"mailto:{args.sender}") + organizer.params["CN"] = vText(organizer_name) + event.add("organizer", organizer) + + if args.location: + event.add("location", args.location) + if args.description: + event.add("description", args.description) + + recipients = [addr.strip() for addr in args.to.split(",")] + + # Always include owner as attendee + all_attendees = list(recipients) + if DEFAULT_OWNER_EMAIL not in all_attendees: + all_attendees.append(DEFAULT_OWNER_EMAIL) + + for addr in all_attendees: + event.add("attendee", f"mailto:{addr}", parameters={ + "ROLE": "REQ-PARTICIPANT", + "RSVP": "TRUE", + }) + + # 1-day reminder + alarm = Alarm() + alarm.add("action", "DISPLAY") + alarm.add("description", f"Reminder: {args.summary}") + alarm.add("trigger", timedelta(days=-1)) + event.add_component(alarm) + + cal.add_component(event) + ics_bytes = cal.to_ical() + + # Build plain text body + body = f"You're invited to: {args.summary}\n\nWhen: {args.start} - {args.end} ({args.timezone})" + if args.location: + body += f"\nWhere: {args.location}" + if args.description: + body += f"\n\n{args.description}" + + # Email goes to all attendees (including owner) + all_to = ", ".join(all_attendees) + + # Build MIME email + email_str = _build_calendar_email(args.sender, all_to, args.subject, body, ics_bytes, method="REQUEST") + + if args.dry_run: + print("=== ICS Content ===") + print(ics_bytes.decode()) + print("=== Email Message ===") + print(email_str) + return + + # Send email via himalaya message send (stdin) + _send_email(email_str, args.account) + print(f"Calendar invite sent to: {args.to}") + + # Save to local calendar (without METHOD for CalDAV compatibility) + if CALENDAR_DIR.is_dir(): + dest = CALENDAR_DIR / f"{uid}.ics" + dest.write_bytes(_strip_method(ics_bytes)) + print(f"Saved to local calendar: {dest}") + _sync_calendar() + + +# --------------------------------------------------------------------------- +# Reply to invite +# --------------------------------------------------------------------------- + +PARTSTAT_MAP = { + "accept": "ACCEPTED", + "accepted": "ACCEPTED", + "decline": "DECLINED", + "declined": "DECLINED", + "tentative": "TENTATIVE", +} + +SUBJECT_PREFIX = { + "ACCEPTED": "Accepted", + "DECLINED": "Declined", + "TENTATIVE": "Tentative", +} + + +def _extract_ics_from_email(envelope_id, folder, account): + """Download attachments from an email and find the .ics file.""" + download_dir = Path(f"/tmp/openclaw-ics-extract-{envelope_id}") + download_dir.mkdir(exist_ok=True) + + cmd = ["himalaya"] + if account: + cmd += ["--account", account] + cmd += ["attachment", "download", "--folder", folder, str(envelope_id), "--dir", str(download_dir)] + + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + except subprocess.CalledProcessError: + pass # some emails have no attachments + + ics_files = list(download_dir.glob("*.ics")) + if not ics_files: + print(f"Error: No .ics attachment found in envelope {envelope_id}", file=sys.stderr) + # Cleanup + for f in download_dir.iterdir(): + f.unlink() + download_dir.rmdir() + sys.exit(1) + + return ics_files[0], download_dir + + +def cmd_reply(args): + """Accept, decline, or tentatively accept a calendar invite.""" + partstat = PARTSTAT_MAP.get(args.action.lower()) + if not partstat: + print(f"Error: --action must be accept, decline, or tentative", file=sys.stderr) + sys.exit(1) + + # Get the ICS file + cleanup_dir = None + if args.envelope_id: + ics_path, cleanup_dir = _extract_ics_from_email(args.envelope_id, args.folder, args.account) + elif args.ics_file: + ics_path = Path(args.ics_file) + if not ics_path.is_file(): + print(f"Error: ICS file not found: {ics_path}", file=sys.stderr) + sys.exit(1) + else: + print("Error: --envelope-id or --ics-file is required", file=sys.stderr) + sys.exit(1) + + # Parse original ICS + original_cal = Calendar.from_ical(ics_path.read_bytes()) + + # Find the VEVENT + original_event = None + for component in original_cal.walk(): + if component.name == "VEVENT": + original_event = component + break + + if not original_event: + print("Error: No VEVENT found in ICS file", file=sys.stderr) + sys.exit(1) + + # Extract fields from original + uid = str(original_event.get("uid", "")) + summary = str(original_event.get("summary", "")) + organizer = original_event.get("organizer") + + if not organizer: + print("Error: No ORGANIZER found in ICS", file=sys.stderr) + sys.exit(1) + + organizer_email = str(organizer).replace("mailto:", "").replace("MAILTO:", "") + + # Build reply calendar + reply_cal = Calendar() + reply_cal.add("prodid", PRODID) + reply_cal.add("version", "2.0") + reply_cal.add("calscale", "GREGORIAN") + reply_cal.add("method", "REPLY") + + reply_event = Event() + reply_event.add("uid", uid) + reply_event.add("dtstamp", datetime.now(timezone.utc)) + + # Copy timing from original + if original_event.get("dtstart"): + reply_event["dtstart"] = original_event["dtstart"] + if original_event.get("dtend"): + reply_event["dtend"] = original_event["dtend"] + + reply_event.add("summary", summary) + reply_event["organizer"] = original_event["organizer"] + reply_event.add("attendee", f"mailto:{args.sender}", parameters={ + "PARTSTAT": partstat, + "RSVP": "FALSE", + }) + + if original_event.get("sequence"): + reply_event.add("sequence", original_event.get("sequence")) + + reply_cal.add_component(reply_event) + reply_ics_bytes = reply_cal.to_ical() + + # Build email + prefix = SUBJECT_PREFIX[partstat] + subject = f"{prefix}: {summary}" + + body = f"{prefix}: {summary}" + if args.comment: + body += f"\n\n{args.comment}" + + email_str = _build_calendar_email(args.sender, organizer_email, subject, body, reply_ics_bytes, method="REPLY") + + if args.dry_run: + print("=== Original Event ===") + print(f"Summary: {summary}") + print(f"Organizer: {organizer_email}") + print(f"Action: {partstat}") + print() + print("=== Reply ICS ===") + print(reply_ics_bytes.decode()) + print("=== Email Message ===") + print(email_str) + if cleanup_dir: + for f in cleanup_dir.iterdir(): + f.unlink() + cleanup_dir.rmdir() + return + + # Send reply + _send_email(email_str, args.account) + print(f"Calendar invite {partstat.lower()}: {summary} (replied to {organizer_email})") + + # Forward invite to owner on accept/tentative + if partstat in ("ACCEPTED", "TENTATIVE"): + fwd_body = f"{prefix}: {summary}" + fwd_email = _build_calendar_email( + args.sender, DEFAULT_OWNER_EMAIL, + f"{prefix}: {summary}", fwd_body, + ics_path.read_bytes(), method="REQUEST", + ) + try: + _send_email(fwd_email, args.account) + print(f"Forwarded invite to {DEFAULT_OWNER_EMAIL}") + except subprocess.CalledProcessError: + print(f"Warning: Failed to forward invite to {DEFAULT_OWNER_EMAIL}") + + # Save to / remove from local calendar + if CALENDAR_DIR.is_dir(): + dest = CALENDAR_DIR / f"{uid}.ics" + if partstat in ("ACCEPTED", "TENTATIVE"): + # Save the original event to local calendar (without METHOD for CalDAV) + dest.write_bytes(_strip_method(ics_path.read_bytes())) + print(f"Saved to local calendar: {dest}") + elif partstat == "DECLINED" and dest.is_file(): + dest.unlink() + print("Removed from local calendar") + _sync_calendar() + + # Cleanup + if cleanup_dir: + for f in cleanup_dir.iterdir(): + f.unlink() + cleanup_dir.rmdir() + + +# --------------------------------------------------------------------------- +# VTODO: helpers +# --------------------------------------------------------------------------- + + +def _load_todos(): + """Load all VTODO items from TASKS_DIR. Returns list of (path, vtodo) tuples.""" + if not TASKS_DIR.is_dir(): + return [] + todos = [] + for ics_path in TASKS_DIR.glob("*.ics"): + try: + cal = Calendar.from_ical(ics_path.read_bytes()) + for component in cal.walk(): + if component.name == "VTODO": + todos.append((ics_path, component)) + break + except Exception: + continue + return todos + + +def _get_due_date(vtodo): + """Extract due date from a VTODO as a date object, or None.""" + due = vtodo.get("due") + if due is None: + return None + dt = due.dt + if isinstance(dt, datetime): + return dt.date() + return dt + + +def _get_priority_int(vtodo): + """Get priority as int (1=high, 5=medium, 9=low). Default 5.""" + p = vtodo.get("priority") + if p is None: + return 5 + return int(p) + + +def _days_until(due_date): + """Days from today until due_date. Negative means overdue.""" + if due_date is None: + return None + return (due_date - date.today()).days + + +def _urgency_label(days): + """Urgency label with emoji, matching reminder_check.py style.""" + if days is None: + return "❓ 日期未知" + elif days < 0: + return f"🔴 逾期 {-days} 天" + elif days == 0: + return "🔴 今天" + elif days == 1: + return "🟡 明天" + elif days <= 3: + return f"🟡 {days} 天后" + else: + return f"🟢 {days} 天后" + + +def _find_todo_by_match(todos, match_str): + """Find a single todo by fuzzy match on SUMMARY. Exits on 0 or >1 matches.""" + matches = [] + for path, vtodo in todos: + summary = str(vtodo.get("summary", "")) + if match_str in summary: + matches.append((path, vtodo)) + if not matches: + print(f"Error: No todo matching '{match_str}'", file=sys.stderr) + sys.exit(1) + if len(matches) > 1: + print(f"Error: Multiple todos match '{match_str}':", file=sys.stderr) + for _, vt in matches: + print(f" - {vt.get('summary')} (UID: {vt.get('uid')})", file=sys.stderr) + sys.exit(1) + return matches[0] + + +def _find_todo_by_uid(todos, uid): + """Find a todo by UID. Exits if not found.""" + for path, vtodo in todos: + if str(vtodo.get("uid", "")) == uid: + return path, vtodo + print(f"Error: No todo with UID '{uid}'", file=sys.stderr) + sys.exit(1) + + +def _resolve_todo(args, todos): + """Resolve a todo from --uid or --match args.""" + if args.uid: + return _find_todo_by_uid(todos, args.uid) + elif args.match: + return _find_todo_by_match(todos, args.match) + else: + print("Error: --uid or --match is required", file=sys.stderr) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# VTODO: subcommands +# --------------------------------------------------------------------------- + + +def cmd_todo_add(args): + """Create a VTODO and save to TASKS_DIR.""" + TASKS_DIR.mkdir(parents=True, exist_ok=True) + + uid = f"{uuid.uuid4()}@openclaw" + now = datetime.now(timezone.utc) + + # Parse due date (default: tomorrow) + if args.due: + due_date = _parse_date(args.due) + else: + due_date = date.today() + timedelta(days=1) + + # Parse priority + priority = PRIORITY_MAP.get(args.priority, 5) + + # Parse alarm trigger + alarm_trigger = timedelta(days=-1) # default: 1 day before + if args.alarm: + alarm_str = args.alarm + if alarm_str.endswith("d"): + alarm_trigger = timedelta(days=-int(alarm_str[:-1])) + elif alarm_str.endswith("h"): + alarm_trigger = timedelta(hours=-int(alarm_str[:-1])) + elif alarm_str.endswith("m"): + alarm_trigger = timedelta(minutes=-int(alarm_str[:-1])) + + # Build VTODO calendar + cal = Calendar() + cal.add("prodid", PRODID) + cal.add("version", "2.0") + cal.add("method", "REQUEST") + + todo = Todo() + todo.add("uid", uid) + todo.add("dtstamp", now) + todo.add("created", now) + todo.add("summary", args.summary) + todo.add("due", due_date) + todo.add("priority", priority) + todo.add("status", "NEEDS-ACTION") + + if args.description: + todo.add("description", args.description) + + # VALARM reminder + alarm = Alarm() + alarm.add("action", "DISPLAY") + alarm.add("description", f"Todo: {args.summary}") + alarm.add("trigger", alarm_trigger) + todo.add_component(alarm) + + cal.add_component(todo) + ics_bytes = cal.to_ical() + + # Build email body + prio_label = PRIORITY_LABELS.get(priority, "中") + body = f"待办事项: {args.summary}\n截止日期: {due_date}\n优先级: {prio_label}" + if args.description: + body += f"\n\n{args.description}" + + # Build MIME email + email_str = _build_calendar_email( + DEFAULT_FROM, DEFAULT_OWNER_EMAIL, + f"📋 待办: {args.summary}", + body, ics_bytes, method="REQUEST", + ) + + if args.dry_run: + print("=== ICS Content ===") + print(ics_bytes.decode()) + print("=== Email Message ===") + print(email_str) + return + + # Save to TASKS_DIR (without METHOD for CalDAV) + dest = TASKS_DIR / f"{uid}.ics" + dest.write_bytes(_strip_method(ics_bytes)) + print(f"Todo created: {args.summary} (due: {due_date}, priority: {prio_label})") + print(f"Saved to: {dest}") + + # Sync + _sync_calendar() + + # Email the VTODO to owner + try: + _send_email(email_str) + print(f"Emailed todo to {DEFAULT_OWNER_EMAIL}") + except subprocess.CalledProcessError: + print(f"Warning: Failed to email todo to {DEFAULT_OWNER_EMAIL}") + + +def cmd_todo_list(args): + """List todos, optionally including completed ones.""" + todos = _load_todos() + if not todos: + print("No todos found.") + return + + # Filter + if not args.all: + todos = [(p, vt) for p, vt in todos + if str(vt.get("status", "NEEDS-ACTION")) in ("NEEDS-ACTION", "IN-PROCESS")] + + if not todos: + print("No pending todos.") + return + + # Sort by priority then due date + def sort_key(item): + _, vt = item + prio = _get_priority_int(vt) + due = _get_due_date(vt) + return (prio, due or date.max) + + todos.sort(key=sort_key) + + # Format output + today_str = date.today().isoformat() + print(f"📋 待办事项 ({today_str})") + print("=" * 50) + + # Group by priority + groups = {1: [], 5: [], 9: []} + for path, vt in todos: + prio = _get_priority_int(vt) + # Bucket into nearest standard priority + if prio <= 3: + groups[1].append((path, vt)) + elif prio <= 7: + groups[5].append((path, vt)) + else: + groups[9].append((path, vt)) + + for prio, emoji, label in [(1, "🔴", "高优先级"), (5, "🟡", "中优先级"), (9, "🟢", "低优先级")]: + items = groups[prio] + if not items: + continue + print(f"\n{emoji} {label}:") + for _, vt in items: + summary = str(vt.get("summary", "")) + due = _get_due_date(vt) + days = _days_until(due) + urgency = _urgency_label(days) + status = str(vt.get("status", "")) + desc = str(vt.get("description", "")) + + line = f" • {summary} ({urgency})" + if status == "COMPLETED": + line = f" • ✅ {summary} (已完成)" + if desc: + line += f" | {desc}" + print(line) + + print("\n" + "=" * 50) + + +def cmd_todo_complete(args): + """Mark a todo as COMPLETED.""" + todos = _load_todos() + path, vtodo = _resolve_todo(args, todos) + + # Read, modify, rewrite + cal = Calendar.from_ical(path.read_bytes()) + for component in cal.walk(): + if component.name == "VTODO": + component["status"] = "COMPLETED" + component.add("completed", datetime.now(timezone.utc)) + break + + path.write_bytes(cal.to_ical()) + summary = str(vtodo.get("summary", "")) + print(f"Completed: {summary}") + _sync_calendar() + + +def cmd_todo_delete(args): + """Delete a todo .ics file.""" + todos = _load_todos() + path, vtodo = _resolve_todo(args, todos) + + summary = str(vtodo.get("summary", "")) + path.unlink() + print(f"Deleted: {summary}") + _sync_calendar() + + +def cmd_todo_check(args): + """Daily digest of pending todos (for cron). Silent when empty.""" + todos = _load_todos() + # Filter to NEEDS-ACTION only + pending = [(p, vt) for p, vt in todos + if str(vt.get("status", "NEEDS-ACTION")) in ("NEEDS-ACTION", "IN-PROCESS")] + + if not pending: + return # silent exit + + # Sort by priority then due date + def sort_key(item): + _, vt = item + prio = _get_priority_int(vt) + due = _get_due_date(vt) + return (prio, due or date.max) + + pending.sort(key=sort_key) + + # Format output (same style as todo list but without footer) + today_str = date.today().isoformat() + print(f"📋 待办事项 ({today_str})") + print("=" * 50) + + groups = {1: [], 5: [], 9: []} + for path, vt in pending: + prio = _get_priority_int(vt) + if prio <= 3: + groups[1].append((path, vt)) + elif prio <= 7: + groups[5].append((path, vt)) + else: + groups[9].append((path, vt)) + + for prio, emoji, label in [(1, "🔴", "高优先级"), (5, "🟡", "中优先级"), (9, "🟢", "低优先级")]: + items = groups[prio] + if not items: + continue + print(f"\n{emoji} {label}:") + for _, vt in items: + summary = str(vt.get("summary", "")) + due = _get_due_date(vt) + days = _days_until(due) + urgency = _urgency_label(days) + desc = str(vt.get("description", "")) + line = f" • {summary} ({urgency})" + if desc: + line += f" | {desc}" + print(line) + + print("\n" + "=" * 50) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="Calendar and todo tool") + subparsers = parser.add_subparsers(dest="command", required=True) + + # --- send --- + send_p = subparsers.add_parser("send", help="Send a calendar invite") + send_p.add_argument("--from", dest="sender", default=DEFAULT_FROM, help="Sender email") + send_p.add_argument("--to", required=True, help="Recipient(s), comma-separated") + send_p.add_argument("--subject", required=True, help="Email subject") + send_p.add_argument("--summary", required=True, help="Event title") + send_p.add_argument("--start", required=True, help="Start time (ISO 8601)") + send_p.add_argument("--end", required=True, help="End time (ISO 8601)") + send_p.add_argument("--timezone", default=DEFAULT_TIMEZONE, help="IANA timezone") + send_p.add_argument("--location", default="", help="Event location") + send_p.add_argument("--description", default="", help="Event description") + send_p.add_argument("--organizer", default="", help="Organizer display name") + send_p.add_argument("--uid", default="", help="Custom event UID") + send_p.add_argument("--account", default="", help="Himalaya account") + send_p.add_argument("--dry-run", action="store_true", help="Preview without sending") + + # --- reply --- + reply_p = subparsers.add_parser("reply", help="Reply to a calendar invite") + reply_p.add_argument("--from", dest="sender", default=DEFAULT_FROM, help="Your email") + reply_p.add_argument("--action", required=True, help="accept, decline, or tentative") + reply_p.add_argument("--envelope-id", default="", help="Himalaya envelope ID") + reply_p.add_argument("--ics-file", default="", help="Path to .ics file") + reply_p.add_argument("--account", default="", help="Himalaya account") + reply_p.add_argument("--folder", default="INBOX", help="Himalaya folder") + reply_p.add_argument("--comment", default="", help="Message to include in reply") + reply_p.add_argument("--dry-run", action="store_true", help="Preview without sending") + + # --- todo --- + todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks") + todo_sub = todo_p.add_subparsers(dest="todo_command", required=True) + + # todo add + add_p = todo_sub.add_parser("add", help="Create a new todo") + add_p.add_argument("--summary", required=True, help="Todo title") + add_p.add_argument("--due", default="", help="Due date (YYYY-MM-DD, default: tomorrow)") + add_p.add_argument("--priority", default="medium", choices=["high", "medium", "low"], help="Priority") + add_p.add_argument("--description", default="", help="Notes / description") + add_p.add_argument("--alarm", default="1d", help="Reminder trigger (e.g. 1d, 2h, 30m)") + add_p.add_argument("--dry-run", action="store_true", help="Preview without saving") + + # todo list + list_p = todo_sub.add_parser("list", help="List todos") + list_p.add_argument("--all", action="store_true", help="Include completed todos") + + # todo complete + comp_p = todo_sub.add_parser("complete", help="Mark a todo as done") + comp_p.add_argument("--uid", default="", help="Todo UID") + comp_p.add_argument("--match", default="", help="Fuzzy match on summary") + + # todo delete + del_p = todo_sub.add_parser("delete", help="Delete a todo") + del_p.add_argument("--uid", default="", help="Todo UID") + del_p.add_argument("--match", default="", help="Fuzzy match on summary") + + # todo check + todo_sub.add_parser("check", help="Daily digest (for cron)") + + args = parser.parse_args() + + if args.command == "send": + cmd_send(args) + elif args.command == "reply": + cmd_reply(args) + elif args.command == "todo": + if args.todo_command == "add": + cmd_todo_add(args) + elif args.todo_command == "list": + cmd_todo_list(args) + elif args.todo_command == "complete": + cmd_todo_complete(args) + elif args.todo_command == "delete": + cmd_todo_delete(args) + elif args.todo_command == "check": + cmd_todo_check(args) + + +if __name__ == "__main__": + main() diff --git a/skills/calendar/scripts/calendar.sh b/skills/calendar/scripts/calendar.sh new file mode 100755 index 0000000..cb178de --- /dev/null +++ b/skills/calendar/scripts/calendar.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# calendar — wrapper script for the calendar and todo tool. +# +# Usage: +# ./calendar.sh send [options] # send a calendar invite +# ./calendar.sh reply [options] # accept/decline/tentative +# ./calendar.sh todo add [options] # create a todo +# ./calendar.sh todo list [options] # list pending todos +# ./calendar.sh todo complete [options] # mark todo as done +# ./calendar.sh todo delete [options] # remove a todo +# ./calendar.sh todo check # daily digest for cron +# +# Requires: uv, himalaya, vdirsyncer (for CalDAV sync). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +exec uv run --project "$SKILL_DIR" python "$SCRIPT_DIR/cal_tool.py" "$@" diff --git a/skills/calendar/uv.lock b/skills/calendar/uv.lock new file mode 100644 index 0000000..2c69bb4 --- /dev/null +++ b/skills/calendar/uv.lock @@ -0,0 +1,67 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "calendar" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "icalendar" }, +] + +[package.metadata] +requires-dist = [{ name = "icalendar" }] + +[[package]] +name = "icalendar" +version = "7.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/60/6b0356a2ed1c9689ae14bd8e44f22eac67c420a0ecca4df8306b70906600/icalendar-7.0.3.tar.gz", hash = "sha256:95027ece087ab87184d765f03761f25875821f74cdd18d3b57e9c868216d8fde", size = 443788, upload-time = "2026-03-03T12:00:10.952Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/c6/431fbf9063a6a4306d4cedae7823d69baf0979ba6ca57ab24a9d898cd0aa/icalendar-7.0.3-py3-none-any.whl", hash = "sha256:8c9fea6d3a89671bba8b6938d8565b4d0ec465c6a2796ef0f92790dcb9e627cd", size = 442406, upload-time = "2026-03-03T12:00:09.228Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +]