Back to skills
SkillHub ClubShip Full StackFull StackBackend

caldav

Manage CalDAV calendars and events, with special support for Radicale server. Use when the user wants to create, update, delete, or query calendar events, manage calendars, or configure/administer a Radicale CalDAV server.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,086
Hot score
99
Updated
March 20, 2026
Overall rating
C4.0
Composite score
4.0
Best-practice grade
B71.9

Install command

npx @skill-hub/cli install openclaw-skills-caldav-skill

Repository

openclaw/skills

Skill path: skills/chakyiu/caldav-skill

Manage CalDAV calendars and events, with special support for Radicale server. Use when the user wants to create, update, delete, or query calendar events, manage calendars, or configure/administer a Radicale CalDAV server.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack, Backend.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install caldav into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding caldav to shared team environments
  • Use caldav for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: caldav
description: Manage CalDAV calendars and events, with special support for Radicale server. Use when the user wants to create, update, delete, or query calendar events, manage calendars, or configure/administer a Radicale CalDAV server.
homepage: https://github.com/python-caldav/caldav
metadata:
  clawdbot:
    emoji: "📅"
    requires:
      bins: ["python3"]
      env: []
---

# CalDAV & Radicale Management

Interact with CalDAV servers (calendars, events, todos) and manage Radicale server configurations.

## Overview

CalDAV is a protocol for accessing and managing calendaring information (RFC 4791). Radicale is a lightweight CalDAV/CardDAV server. This skill enables:

- Calendar CRUD operations (create, list, update, delete)
- Event management (create, update, delete, query)
- Todo/task management
- Radicale server configuration and administration

## Prerequisites

### Python Library

Install the caldav library:

```bash
pip install caldav
```

For async support:
```bash
pip install caldav[async]
```

### Environment Variables (Recommended)

Store credentials securely in environment or config:

```bash
export CALDAV_URL="http://localhost:5232"
export CALDAV_USER="your_username"
export CALDAV_PASSWORD="your_password"
```

Or use a config file at `~/.config/caldav/config.json`:

```json
{
  "url": "http://localhost:5232",
  "username": "your_username",
  "password": "your_password"
}
```

## Scripts

### Calendar Operations

```bash
# List all calendars
python3 {baseDir}/scripts/calendars.py list

# Create a new calendar
python3 {baseDir}/scripts/calendars.py create --name "Work Calendar" --id work

# Delete a calendar
python3 {baseDir}/scripts/calendars.py delete --id work

# Get calendar info
python3 {baseDir}/scripts/calendars.py info --id work
```

### Event Operations

```bash
# List events (all calendars, next 30 days)
python3 {baseDir}/scripts/events.py list

# List events from specific calendar
python3 {baseDir}/scripts/events.py list --calendar work

# List events in date range
python3 {baseDir}/scripts/events.py list --start 2024-01-01 --end 2024-01-31

# Create an event
python3 {baseDir}/scripts/events.py create \
  --calendar work \
  --summary "Team Meeting" \
  --start "2024-01-15 14:00" \
  --end "2024-01-15 15:00" \
  --description "Weekly sync"

# Create all-day event
python3 {baseDir}/scripts/events.py create \
  --calendar personal \
  --summary "Birthday" \
  --start 2024-02-14 \
  --allday

# Update an event
python3 {baseDir}/scripts/events.py update \
  --uid "event-uid-here" \
  --summary "Updated Title"

# Delete an event
python3 {baseDir}/scripts/events.py delete --uid "event-uid-here"

# Search events by text
python3 {baseDir}/scripts/events.py search --query "meeting"
```

### Todo Operations

```bash
# List todos
python3 {baseDir}/scripts/todos.py list [--calendar name]

# Create a todo
python3 {baseDir}/scripts/todos.py create \
  --calendar work \
  --summary "Complete report" \
  --due "2024-01-20"

# Complete a todo
python3 {baseDir}/scripts/todos.py complete --uid "todo-uid-here"

# Delete a todo
python3 {baseDir}/scripts/todos.py delete --uid "todo-uid-here"
```

### Radicale Server Management

```bash
# Check Radicale status
python3 {baseDir}/scripts/radicale.py status

# List users (from htpasswd file)
python3 {baseDir}/scripts/radicale.py users list

# Add user
python3 {baseDir}/scripts/radicale.py users add --username newuser

# View Radicale config
python3 {baseDir}/scripts/radicale.py config show

# Validate Radicale config
python3 {baseDir}/scripts/radicale.py config validate

# Check storage integrity
python3 {baseDir}/scripts/radicale.py storage verify
```

## Direct HTTP/DAV Operations

For low-level operations, use curl with CalDAV:

```bash
# Discover principal URL
curl -u user:pass -X PROPFIND \
  -H "Depth: 0" \
  -H "Content-Type: application/xml" \
  -d '<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>' \
  http://localhost:5232/

# List calendars
curl -u user:pass -X PROPFIND \
  -H "Depth: 1" \
  -H "Content-Type: application/xml" \
  -d '<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:displayname/><c:calendar-timezone/><d:resourcetype/></d:prop></d:propfind>' \
  http://localhost:5232/user/

# Query events by time range
curl -u user:pass -X REPORT \
  -H "Depth: 1" \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
  <d:prop><d:getetag/><c:calendar-data/></d:prop>
  <c:filter>
    <c:comp-filter name="VCALENDAR">
      <c:comp-filter name="VEVENT">
        <c:time-range start="20240101T000000Z" end="20240131T235959Z"/>
      </c:comp-filter>
    </c:comp-filter>
  </c:filter>
</c:calendar-query>' \
  http://localhost:5232/user/calendar/

# Create calendar (MKCALENDAR)
curl -u user:pass -X MKCALENDAR \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
<d:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
  <d:set><d:prop>
    <d:displayname>New Calendar</d:displayname>
  </d:prop></d:set>
</d:mkcalendar>' \
  http://localhost:5232/user/new-calendar/
```

## Radicale Configuration

### Config File Location

Radicale looks for config in:
- `/etc/radicale/config` (system-wide)
- `~/.config/radicale/config` (user)
- Custom path via `--config` or `RADICALE_CONFIG` env

### Key Configuration Sections

```ini
[server]
hosts = localhost:5232
max_connections = 20
max_content_length = 100000000
timeout = 30
ssl = False

[auth]
type = htpasswd
htpasswd_filename = /etc/radicale/users
htpasswd_encryption = autodetect

[storage]
filesystem_folder = /var/lib/radicale/collections

[rights]
type = owner_only
```

### Authentication Types

| Type | Description |
|------|-------------|
| `none` | No authentication (development only!) |
| `denyall` | Deny all (default since 3.5.0) |
| `htpasswd` | Apache htpasswd file |
| `remote_user` | WSGI-provided username |
| `http_x_remote_user` | Reverse proxy header |
| `ldap` | LDAP/AD authentication |
| `dovecot` | Dovecot auth socket |
| `imap` | IMAP server authentication |
| `oauth2` | OAuth2 authentication |
| `pam` | PAM authentication |

### Creating htpasswd Users

```bash
# Create new file with SHA-512
htpasswd -5 -c /etc/radicale/users user1

# Add another user
htpasswd -5 /etc/radicale/users user2
```

### Running as Service (systemd)

```bash
# Enable and start
sudo systemctl enable radicale
sudo systemctl start radicale

# Check status
sudo systemctl status radicale

# View logs
journalctl -u radicale -f
```

## iCalendar Format Quick Reference

Events use iCalendar (RFC 5545) format:

```ics
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//CalDAV Client//EN
BEGIN:VEVENT
UID:[email protected]
DTSTAMP:20240115T120000Z
DTSTART:20240115T140000Z
DTEND:20240115T150000Z
SUMMARY:Team Meeting
DESCRIPTION:Weekly sync
LOCATION:Conference Room
END:VEVENT
END:VCALENDAR
```

### Common Properties

| Property | Description |
|----------|-------------|
| `UID` | Unique identifier |
| `DTSTART` | Start time |
| `DTEND` | End time |
| `DTSTAMP` | Creation/modification time |
| `SUMMARY` | Event title |
| `DESCRIPTION` | Event description |
| `LOCATION` | Location |
| `RRULE` | Recurrence rule |
| `EXDATE` | Excluded dates |
| `ATTENDEE` | Participant |
| `ORGANIZER` | Event organizer |
| `STATUS` | CONFIRMED/TENTATIVE/CANCELLED |

### Date Formats

```
# DateTime with timezone
DTSTART:20240115T140000Z  ; UTC (Z suffix)
DTSTART;TZID=America/New_York:20240115T090000

# All-day event
DTSTART;VALUE=DATE:20240214

# DateTime local (floating)
DTSTART:20240115T140000
```

## Troubleshooting

### Connection Issues

```bash
# Test basic connectivity
curl -v http://localhost:5232/

# Check with authentication
curl -v -u user:pass http://localhost:5232/

# Verify CalDAV support
curl -X OPTIONS http://localhost:5232/ -I | grep -i dav
```

### Common Errors

| Error | Cause | Solution |
|-------|-------|----------|
| 401 Unauthorized | Wrong credentials | Check htpasswd file |
| 403 Forbidden | Rights restriction | Check `[rights]` config |
| 404 Not Found | Wrong URL path | Check principal/calendar path |
| 409 Conflict | Resource exists | Use different UID |
| 415 Unsupported Media Type | Wrong Content-Type | Use `text/calendar` |

### Debug Mode

Run Radicale with debug logging:
```bash
python3 -m radicale --debug
```

## Python API Quick Reference

```python
from caldav import DAVClient

# Connect
client = DAVClient(
    url="http://localhost:5232",
    username="user",
    password="pass"
)

# Get principal
principal = client.principal()

# List calendars
for cal in principal.calendars():
    print(f"Calendar: {cal.name} ({cal.url})")

# Create calendar
cal = principal.make_calendar(name="My Calendar", cal_id="my-cal")

# Create event
cal.save_event(
    dtstart="2024-01-15 14:00",
    dtend="2024-01-15 15:00",
    summary="Meeting"
)

# Query events by date range
events = cal.date_search(
    start="2024-01-01",
    end="2024-01-31"
)
for event in events:
    print(event.vobject_instance.vevent.summary.value)

# Get event by UID
event = cal.event_by_uid("event-uid")

# Delete event
event.delete()

# Create todo
todo = cal.save_todo(
    summary="Task",
    due="2024-01-20"
)
```

## Workflow Examples

### Create Recurring Event

```bash
python3 {baseDir}/scripts/events.py create \
  --calendar work \
  --summary "Weekly Standup" \
  --start "2024-01-15 09:00" \
  --end "2024-01-15 09:30" \
  --rrule "FREQ=WEEKLY;BYDAY=MO,WE,FR"
```

### Export Calendar to ICS

```bash
# Via curl
curl -u user:pass http://localhost:5232/user/calendar/ > calendar.ics

# Via script
python3 {baseDir}/scripts/calendars.py export --id work --output work.ics
```

### Sync with Git (Radicale)

Configure Radicale to version changes:

```ini
[storage]
hook = git add -A && (git diff --cached --quiet || git commit -m "Changes by \"%(user)s\"")
```

Initialize git in storage folder:
```bash
cd /var/lib/radicale/collections
git init
git config user.email "radicale@localhost"
```


---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### _meta.json

```json
{
  "owner": "chakyiu",
  "slug": "caldav-skill",
  "displayName": "Caldav",
  "latest": {
    "version": "1.0.0",
    "publishedAt": 1771931461032,
    "commit": "https://github.com/openclaw/skills/commit/acdb979268e3dfeee12dfd1b71f6a8a2f32b45d8"
  },
  "history": []
}

```

### scripts/calendars.py

```python
#!/usr/bin/env python3
"""Calendar operations for CalDAV."""

import sys
from pathlib import Path

# Add scripts dir to path
sys.path.insert(0, str(Path(__file__).parent))

from utils import (
    get_client,
    print_result,
    load_config,
    CalendarCommand,
)


def cmd_list(args):
    """List all calendars."""
    client = get_client()
    principal = client.principal()

    calendars = principal.calendars()

    if not calendars:
        print_result(True, "No calendars found")
        return

    data = []
    for cal in calendars:
        info = {
            "name": cal.name or "Unnamed",
            "url": str(cal.url),
            "id": str(cal.url).rstrip("/").split("/")[-1] if cal.url else None,
        }

        # Try to get additional properties
        try:
            props = cal.get_properties([("{DAV:}", "displayname"), ("{DAV:}", "resourcetype")])
            info["displayname"] = props.get("{DAV:}displayname", "")
        except Exception:
            pass

        data.append(info)

    print_result(True, f"Found {len(calendars)} calendar(s)", {"calendars": data})


def cmd_create(args):
    """Create a new calendar."""
    client = get_client()
    principal = client.principal()

    cal_id = args.id or args.name.lower().replace(" ", "-").replace("/", "-")

    try:
        cal = principal.make_calendar(
            name=args.name,
            cal_id=cal_id,
        )

        # Set additional properties if provided
        if args.description or args.color:
            props = {}
            if args.description:
                # Would need additional DAV property setting
                pass
            if args.color:
                # Calendar color is often a custom property
                pass

        print_result(
            True,
            f"Calendar '{args.name}' created",
            {"id": cal_id, "url": str(cal.url)},
        )
    except Exception as e:
        if "already exists" in str(e).lower():
            print_result(False, f"Calendar '{cal_id}' already exists")
        else:
            print_result(False, f"Failed to create calendar: {e}")


def cmd_delete(args):
    """Delete a calendar."""
    client = get_client()
    principal = client.principal()

    # Find calendar by name or ID
    calendar = None
    for cal in principal.calendars():
        cal_id = str(cal.url).rstrip("/").split("/")[-1]
        if cal_id == args.id or cal.name == args.id:
            calendar = cal
            break

    if not calendar:
        print_result(False, f"Calendar '{args.id}' not found")
        return

    if not args.force:
        # Ask for confirmation
        try:
            confirm = input(f"Delete calendar '{calendar.name}'? [y/N] ")
            if confirm.lower() != "y":
                print_result(False, "Cancelled")
                return
        except EOFError:
            # Non-interactive mode
            print_result(False, "Use --force to skip confirmation")
            return

    try:
        calendar.delete()
        print_result(True, f"Calendar '{args.id}' deleted")
    except Exception as e:
        print_result(False, f"Failed to delete calendar: {e}")


def cmd_info(args):
    """Get calendar info."""
    client = get_client()
    principal = client.principal()

    # Find calendar
    calendar = None
    for cal in principal.calendars():
        cal_id = str(cal.url).rstrip("/").split("/")[-1]
        if cal_id == args.id or cal.name == args.id:
            calendar = cal
            break

    if not calendar:
        print_result(False, f"Calendar '{args.id}' not found")
        return

    info = {
        "name": calendar.name,
        "url": str(calendar.url),
        "id": str(calendar.url).rstrip("/").split("/")[-1],
    }

    # Get properties
    try:
        from caldav.lib.namespace import ns

        props = calendar.get_properties(
            [
                (ns("d"), "displayname"),
                (ns("caldav"), "calendar-description"),
                (ns("caldav"), "calendar-timezone"),
                (ns("caldav"), "supported-calendar-component-set"),
            ]
        )

        info["displayname"] = props.get(ns("d", "displayname"), "")
        info["description"] = props.get(ns("caldav", "calendar-description"), "")
        info["timezone"] = props.get(ns("caldav", "calendar-timezone"), "")

        components = props.get(ns("caldav", "supported-calendar-component-set"), "")
        info["components"] = components if components else "VEVENT, VTODO"
    except Exception:
        pass

    # Count events
    try:
        events = calendar.events()
        todos = calendar.todos()
        info["event_count"] = len(events)
        info["todo_count"] = len(todos)
    except Exception:
        pass

    print_result(True, f"Calendar info for '{args.id}'", info)


def cmd_export(args):
    """Export calendar to ICS."""
    client = get_client()
    principal = client.principal()

    # Find calendar
    calendar = None
    for cal in principal.calendars():
        cal_id = str(cal.url).rstrip("/").split("/")[-1]
        if cal_id == args.id or cal.name == args.id:
            calendar = cal
            break

    if not calendar:
        print_result(False, f"Calendar '{args.id}' not found")
        return

    try:
        # Get all events as ICS
        events = calendar.events()
        ics_content = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//OpenClaw CalDAV Export//EN\n"

        for event in events:
            # Get raw ICS data
            data = event.data
            # Extract VEVENT from the full VCALENDAR
            if "BEGIN:VEVENT" in data:
                start = data.find("BEGIN:VEVENT")
                end = data.find("END:VEVENT") + len("END:VEVENT")
                ics_content += data[start:end] + "\n"

        ics_content += "END:VCALENDAR\n"

        if args.output:
            output_path = Path(args.output)
            output_path.write_text(ics_content)
            print_result(True, f"Exported to {args.output}", {"events": len(events)})
        else:
            print(ics_content)

    except Exception as e:
        print_result(False, f"Failed to export: {e}")


def cmd_import(args):
    """Import ICS file to calendar."""
    client = get_client()
    principal = client.principal()

    # Find target calendar
    calendar = None
    for cal in principal.calendars():
        cal_id = str(cal.url).rstrip("/").split("/")[-1]
        if cal_id == args.calendar or cal.name == args.calendar:
            calendar = cal
            break

    if not calendar:
        print_result(False, f"Calendar '{args.calendar}' not found")
        return

    # Read ICS file
    ics_path = Path(args.file)
    if not ics_path.exists():
        print_result(False, f"File not found: {args.file}")
        return

    ics_content = ics_path.read_text()

    try:
        # Parse and import events
        from icalendar import Calendar

        cal = Calendar.from_ical(ics_content)

        imported = 0
        for component in cal.walk():
            if component.name == "VEVENT":
                # Create event from component
                ics_data = f"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//OpenClaw Import//EN\nBEGIN:VEVENT\n"
                # Add properties
                for line in component.to_ical().decode().split("\n"):
                    if line and not line.startswith("BEGIN:VCALENDAR") and not line.startswith("END:VCALENDAR"):
                        ics_data += line + "\n"
                ics_data += "END:VEVENT\nEND:VCALENDAR"

                calendar.save_event(ics_data)
                imported += 1

        print_result(True, f"Imported {imported} event(s)", {"imported": imported})

    except ImportError:
        print_result(False, "icalendar library required for import. pip install icalendar")
    except Exception as e:
        print_result(False, f"Failed to import: {e}")


def main():
    import argparse

    parser = argparse.ArgumentParser(description="Calendar operations")
    subparsers = parser.add_subparsers(dest="command", required=True)

    # List
    subparsers.add_parser("list", help="List all calendars")

    # Create
    create = subparsers.add_parser("create", help="Create a new calendar")
    create.add_argument("--name", required=True, help="Calendar display name")
    create.add_argument("--id", help="Calendar ID (URL-safe)")
    create.add_argument("--description", help="Calendar description")
    create.add_argument("--color", help="Calendar color (hex)")

    # Delete
    delete = subparsers.add_parser("delete", help="Delete a calendar")
    delete.add_argument("--id", required=True, help="Calendar ID or name")
    delete.add_argument("--force", action="store_true", help="Skip confirmation")

    # Info
    info = subparsers.add_parser("info", help="Get calendar info")
    info.add_argument("--id", required=True, help="Calendar ID or name")

    # Export
    export = subparsers.add_parser("export", help="Export calendar to ICS")
    export.add_argument("--id", required=True, help="Calendar ID or name")
    export.add_argument("--output", "-o", help="Output file path")

    # Import
    imp = subparsers.add_parser("import", help="Import ICS file")
    imp.add_argument("--calendar", "-c", required=True, help="Target calendar")
    imp.add_argument("--file", "-f", required=True, help="ICS file to import")

    args = parser.parse_args()

    # Dispatch
    globals()[f"cmd_{args.command}"](args)


if __name__ == "__main__":
    main()

```

### scripts/events.py

```python
#!/usr/bin/env python3
"""Event operations for CalDAV."""

import sys
from pathlib import Path
from datetime import datetime, timedelta
from typing import Optional

# Add scripts dir to path
sys.path.insert(0, str(Path(__file__).parent))

from utils import get_client, print_result, parse_datetime


def find_calendar(client, calendar_name: str):
    """Find calendar by name or ID."""
    principal = client.principal()
    for cal in principal.calendars():
        cal_id = str(cal.url).rstrip("/").split("/")[-1]
        if cal_id == calendar_name or cal.name == calendar_name:
            return cal
    return None


def format_event(event) -> dict:
    """Format event for display."""
    data = {
        "uid": event.icalendar_component.get("uid", "unknown"),
        "url": str(event.url) if event.url else None,
    }

    vevent = event.icalendar_component

    # Summary
    if vevent.get("summary"):
        data["summary"] = str(vevent["summary"])

    # Description
    if vevent.get("description"):
        data["description"] = str(vevent["description"])

    # Location
    if vevent.get("location"):
        data["location"] = str(vevent["location"])

    # Dates
    if vevent.get("dtstart"):
        dt = vevent["dtstart"].dt
        if hasattr(dt, "strftime"):
            data["start"] = dt.isoformat()
        else:
            # Date object (all-day)
            data["start"] = str(dt)
            data["all_day"] = True

    if vevent.get("dtend"):
        dt = vevent["dtend"].dt
        if hasattr(dt, "strftime"):
            data["end"] = dt.isoformat()
        else:
            data["end"] = str(dt)

    # Status
    if vevent.get("status"):
        data["status"] = str(vevent["status"])

    # Recurrence
    if vevent.get("rrule"):
        data["recurrence"] = str(vevent["rrule"])

    # Attendees
    if vevent.get("attendee"):
        attendees = vevent["attendee"]
        if not isinstance(attendees, list):
            attendees = [attendees]
        data["attendees"] = [str(a) for a in attendees]

    return data


def cmd_list(args):
    """List events."""
    client = get_client()
    principal = client.principal()

    # Determine date range
    if args.start:
        start = parse_datetime(args.start)
    else:
        start = datetime.now() - timedelta(days=7)

    if args.end:
        end = parse_datetime(args.end)
    else:
        end = datetime.now() + timedelta(days=30)

    all_events = []

    if args.calendar:
        calendars = [find_calendar(client, args.calendar)]
        calendars = [c for c in calendars if c]
    else:
        calendars = principal.calendars()

    for cal in calendars:
        if not cal:
            continue

        try:
            events = cal.date_search(start=start, end=end)
            for event in events:
                event_data = format_event(event)
                event_data["calendar"] = cal.name
                all_events.append(event_data)
        except Exception as e:
            # Some calendars might not support date_search
            pass

    # Sort by start date
    all_events.sort(key=lambda e: e.get("start", ""))

    # Limit results
    all_events = all_events[: args.limit]

    print_result(
        True,
        f"Found {len(all_events)} event(s)",
        {"events": all_events, "range": {"start": str(start), "end": str(end)}},
    )


def cmd_create(args):
    """Create a new event."""
    client = get_client()

    calendar = find_calendar(client, args.calendar)
    if not calendar:
        print_result(False, f"Calendar '{args.calendar}' not found")
        return

    # Parse dates
    start = parse_datetime(args.start)

    if args.allday:
        # All-day event
        start_date = start.date() if hasattr(start, "date") else start
        end_date = None
        if args.end:
            end_dt = parse_datetime(args.end)
            end_date = end_dt.date() if hasattr(end_dt, "date") else end_dt
    else:
        # Timed event
        if args.end:
            end = parse_datetime(args.end)
        else:
            # Default 1 hour duration
            end = start + timedelta(hours=1)

    try:
        # Build event
        if args.allday:
            event = calendar.save_event(
                dtstart=start_date,
                dtend=end_date,
                summary=args.summary,
                description=args.description,
                location=args.location,
            )
        else:
            extra_kwargs = {}
            if args.rrule:
                extra_kwargs["rrule"] = args.rrule
            if args.uid:
                extra_kwargs["uid"] = args.uid

            event = calendar.save_event(
                dtstart=start,
                dtend=end,
                summary=args.summary,
                description=args.description,
                location=args.location,
                **extra_kwargs,
            )

        event_data = format_event(event)
        print_result(True, "Event created", event_data)

    except Exception as e:
        print_result(False, f"Failed to create event: {e}")


def cmd_update(args):
    """Update an existing event."""
    client = get_client()

    event = None
    calendar = None

    # Find event
    if args.calendar:
        calendar = find_calendar(client, args.calendar)
        if calendar:
            try:
                event = calendar.event_by_uid(args.uid)
            except Exception:
                pass

    if not event:
        # Search all calendars
        principal = client.principal()
        for cal in principal.calendars():
            try:
                event = cal.event_by_uid(args.uid)
                calendar = cal
                break
            except Exception:
                pass

    if not event:
        print_result(False, f"Event '{args.uid}' not found")
        return

    try:
        # Get existing component
        vevent = event.icalendar_component

        # Update properties
        if args.summary:
            vevent["summary"] = args.summary
        if args.description is not None:
            if args.description:
                vevent["description"] = args.description
            elif "description" in vevent:
                del vevent["description"]
        if args.location is not None:
            if args.location:
                vevent["location"] = args.location
            elif "location" in vevent:
                del vevent["location"]
        if args.status:
            vevent["status"] = args.status
        if args.start:
            start = parse_datetime(args.start)
            vevent["dtstart"] = vevent.pop("dtstart")
            vevent["dtstart"].dt = start
        if args.end:
            end = parse_datetime(args.end)
            vevent["dtend"] = vevent.pop("dtend")
            vevent["dtend"].dt = end

        # Save changes
        event.save()

        event_data = format_event(event)
        print_result(True, "Event updated", event_data)

    except Exception as e:
        print_result(False, f"Failed to update event: {e}")


def cmd_delete(args):
    """Delete an event."""
    client = get_client()

    event = None

    # Find event
    if args.calendar:
        calendar = find_calendar(client, args.calendar)
        if calendar:
            try:
                event = calendar.event_by_uid(args.uid)
            except Exception:
                pass

    if not event:
        principal = client.principal()
        for cal in principal.calendars():
            try:
                event = cal.event_by_uid(args.uid)
                break
            except Exception:
                pass

    if not event:
        print_result(False, f"Event '{args.uid}' not found")
        return

    if not args.force:
        try:
            confirm = input(f"Delete event '{args.uid}'? [y/N] ")
            if confirm.lower() != "y":
                print_result(False, "Cancelled")
                return
        except EOFError:
            print_result(False, "Use --force to skip confirmation")
            return

    try:
        event.delete()
        print_result(True, f"Event '{args.uid}' deleted")
    except Exception as e:
        print_result(False, f"Failed to delete event: {e}")


def cmd_search(args):
    """Search events by text."""
    client = get_client()
    principal = client.principal()

    results = []
    query_lower = args.query.lower()

    calendars = principal.calendars()
    if args.calendar:
        cal = find_calendar(client, args.calendar)
        if cal:
            calendars = [cal]
        else:
            calendars = []

    for cal in calendars:
        try:
            events = cal.events()
            for event in events:
                vevent = event.icalendar_component

                # Search in summary, description, location
                searchable = []
                if vevent.get("summary"):
                    searchable.append(str(vevent["summary"]))
                if vevent.get("description"):
                    searchable.append(str(vevent["description"]))
                if vevent.get("location"):
                    searchable.append(str(vevent["location"]))

                text = " ".join(searchable).lower()
                if query_lower in text:
                    event_data = format_event(event)
                    event_data["calendar"] = cal.name
                    results.append(event_data)

        except Exception:
            pass

    print_result(True, f"Found {len(results)} matching event(s)", {"events": results})


def cmd_get(args):
    """Get event by UID."""
    client = get_client()

    event = None
    calendar = None

    if args.calendar:
        calendar = find_calendar(client, args.calendar)
        if calendar:
            try:
                event = calendar.event_by_uid(args.uid)
            except Exception:
                pass

    if not event:
        principal = client.principal()
        for cal in principal.calendars():
            try:
                event = cal.event_by_uid(args.uid)
                calendar = cal
                break
            except Exception:
                pass

    if not event:
        print_result(False, f"Event '{args.uid}' not found")
        return

    event_data = format_event(event)
    event_data["calendar"] = calendar.name if calendar else None
    event_data["raw"] = event.data

    print_result(True, "Event found", event_data)


def main():
    import argparse

    parser = argparse.ArgumentParser(description="Event operations")
    subparsers = parser.add_subparsers(dest="command", required=True)

    # List
    list_cmd = subparsers.add_parser("list", help="List events")
    list_cmd.add_argument("--calendar", "-c", help="Filter by calendar")
    list_cmd.add_argument("--start", help="Start date (YYYY-MM-DD)")
    list_cmd.add_argument("--end", help="End date (YYYY-MM-DD)")
    list_cmd.add_argument("--limit", type=int, default=50, help="Max results")

    # Create
    create = subparsers.add_parser("create", help="Create an event")
    create.add_argument("--calendar", "-c", required=True, help="Target calendar")
    create.add_argument("--summary", "-s", required=True, help="Event title")
    create.add_argument("--start", required=True, help="Start datetime")
    create.add_argument("--end", help="End datetime")
    create.add_argument("--allday", action="store_true", help="All-day event")
    create.add_argument("--description", "-d", help="Description")
    create.add_argument("--location", "-l", help="Location")
    create.add_argument("--rrule", help="Recurrence rule")
    create.add_argument("--uid", help="Custom UID")

    # Update
    update = subparsers.add_parser("update", help="Update an event")
    update.add_argument("--uid", required=True, help="Event UID")
    update.add_argument("--calendar", "-c", help="Calendar")
    update.add_argument("--summary", "-s", help="New title")
    update.add_argument("--start", help="New start")
    update.add_argument("--end", help="New end")
    update.add_argument("--description", "-d", help="New description")
    update.add_argument("--location", "-l", help="New location")
    update.add_argument("--status", choices=["CONFIRMED", "TENTATIVE", "CANCELLED"])

    # Delete
    delete = subparsers.add_parser("delete", help="Delete an event")
    delete.add_argument("--uid", required=True, help="Event UID")
    delete.add_argument("--calendar", "-c", help="Calendar")
    delete.add_argument("--force", action="store_true", help="Skip confirmation")

    # Search
    search = subparsers.add_parser("search", help="Search events")
    search.add_argument("--query", "-q", required=True, help="Search query")
    search.add_argument("--calendar", "-c", help="Filter by calendar")

    # Get
    get = subparsers.add_parser("get", help="Get event by UID")
    get.add_argument("--uid", required=True, help="Event UID")
    get.add_argument("--calendar", "-c", help="Calendar")

    args = parser.parse_args()
    globals()[f"cmd_{args.command}"](args)


if __name__ == "__main__":
    main()

```

### scripts/radicale.py

```python
#!/usr/bin/env python3
"""Radicale server management operations."""

import os
import sys
import json
import subprocess
from pathlib import Path
from typing import Dict, Optional, List

# Default paths
DEFAULT_CONFIG_PATHS = [
    Path("/etc/radicale/config"),
    Path.home() / ".config" / "radicale" / "config",
]

DEFAULT_STORAGE_PATH = "/var/lib/radicale/collections"
DEFAULT_USERS_PATH = "/etc/radicale/users"


def print_result(success: bool, message: str, data: Optional[Dict] = None):
    """Print result in consistent format."""
    result = {"success": success, "message": message}
    if data:
        result["data"] = data
    print(json.dumps(result, indent=2, default=str))


def find_config() -> Optional[Path]:
    """Find Radicale config file."""
    # Check environment variable
    if os.environ.get("RADICALE_CONFIG"):
        path = Path(os.environ["RADICALE_CONFIG"])
        if path.exists():
            return path

    # Check default paths
    for path in DEFAULT_CONFIG_PATHS:
        if path.exists():
            return path

    return None


def parse_config(config_path: Path) -> Dict:
    """Parse Radicale INI config file."""
    import configparser

    config = configparser.ConfigParser()
    config.read(config_path)

    result = {}
    for section in config.sections():
        result[section] = dict(config[section])

    return result


def cmd_status(args):
    """Check Radicale server status."""
    data = {}

    # Check if config exists
    config_path = find_config()
    if config_path:
        data["config_path"] = str(config_path)
    else:
        data["config_path"] = None
        data["config_warning"] = "No config file found"

    # Check if service is running (systemd)
    try:
        result = subprocess.run(
            ["systemctl", "status", "radicale"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        if result.returncode == 0:
            data["service_status"] = "running"
            # Extract active state
            for line in result.stdout.split("\n"):
                if "Active:" in line:
                    data["service_active"] = line.split("Active:")[1].strip()
                    break
        else:
            data["service_status"] = "stopped or not installed"
    except FileNotFoundError:
        data["service_status"] = "systemd not available"
    except subprocess.TimeoutExpired:
        data["service_status"] = "timeout checking service"
    except Exception as e:
        data["service_status"] = f"error: {e}"

    # Check if process is running (non-systemd)
    if data.get("service_status") != "running":
        try:
            result = subprocess.run(
                ["pgrep", "-f", "radicale"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.returncode == 0:
                data["process_running"] = True
                data["pids"] = result.stdout.strip().split("\n")
        except Exception:
            pass

    # Check storage
    config = parse_config(config_path) if config_path else {}
    storage_path = config.get("storage", {}).get("filesystem_folder", DEFAULT_STORAGE_PATH)
    storage = Path(storage_path)

    if storage.exists():
        data["storage_path"] = str(storage)
        # Count collections
        try:
            collections = list(storage.glob("*/*"))
            data["collection_count"] = len([c for c in collections if c.is_dir()])
        except Exception:
            pass
    else:
        data["storage_path"] = str(storage)
        data["storage_exists"] = False

    # Check users file
    auth_type = config.get("auth", {}).get("type", "none")
    data["auth_type"] = auth_type

    if auth_type == "htpasswd":
        users_path = config.get("auth", {}).get("htpasswd_filename", DEFAULT_USERS_PATH)
        if Path(users_path).exists():
            data["users_file"] = users_path
            try:
                with open(users_path) as f:
                    data["user_count"] = len([l for l in f if l.strip() and not l.startswith("#")])
            except Exception:
                pass
        else:
            data["users_file"] = users_path
            data["users_file_exists"] = False

    print_result(True, "Radicale status", data)


def cmd_config_show(args):
    """Show Radicale configuration."""
    config_path = find_config()

    if not config_path:
        print_result(False, "No Radicale config file found")
        return

    config = parse_config(config_path)
    print_result(True, f"Config from {config_path}", {"config": config, "path": str(config_path)})


def cmd_config_validate(args):
    """Validate Radicale configuration."""
    config_path = find_config()

    if not config_path:
        print_result(False, "No Radicale config file found")
        return

    issues = []
    warnings = []
    config = parse_config(config_path)

    # Check auth
    auth_type = config.get("auth", {}).get("type", "denyall")
    if auth_type == "none":
        issues.append("Authentication is disabled (auth.type=none) - anyone can access!")
    elif auth_type == "denyall":
        warnings.append("Authentication is denyall - no users can log in")

    # Check if htpasswd file exists for htpasswd auth
    if auth_type == "htpasswd":
        users_file = config.get("auth", {}).get("htpasswd_filename", DEFAULT_USERS_PATH)
        if not Path(users_file).exists():
            issues.append(f"htpasswd file not found: {users_file}")

    # Check storage
    storage_path = config.get("storage", {}).get("filesystem_folder", DEFAULT_STORAGE_PATH)
    storage = Path(storage_path)
    if not storage.exists():
        warnings.append(f"Storage path does not exist: {storage_path}")
    else:
        # Check permissions
        if not os.access(storage, os.W_OK):
            issues.append(f"No write permission on storage: {storage_path}")

    # Check SSL
    server_config = config.get("server", {})
    hosts = server_config.get("hosts", "localhost:5232")

    if server_config.get("ssl") == "True":
        cert = server_config.get("certificate", "/etc/ssl/radicale.cert.pem")
        key = server_config.get("key", "/etc/ssl/radicale.key.pem")
        if not Path(cert).exists():
            issues.append(f"SSL certificate not found: {cert}")
        if not Path(key).exists():
            issues.append(f"SSL key not found: {key}")
    else:
        # Check if bound to public interface without SSL
        if "0.0.0.0" in hosts or "::" in hosts:
            warnings.append("Server bound to all interfaces without SSL - traffic is unencrypted!")

    result = {
        "config_path": str(config_path),
        "issues": issues,
        "warnings": warnings,
        "valid": len(issues) == 0,
    }

    if issues or warnings:
        print_result(result["valid"], "Configuration has issues", result)
    else:
        print_result(True, "Configuration is valid", result)


def cmd_users_list(args):
    """List Radicale users from htpasswd file."""
    config_path = find_config()

    if not config_path:
        # Try to find users file directly
        users_path = Path(args.users_file or DEFAULT_USERS_PATH)
    else:
        config = parse_config(config_path)
        auth_type = config.get("auth", {}).get("type", "none")

        if auth_type != "htpasswd":
            print_result(False, f"Auth type is '{auth_type}', not htpasswd")
            return

        users_path = Path(config.get("auth", {}).get("htpasswd_filename", DEFAULT_USERS_PATH))

    if not users_path.exists():
        print_result(False, f"Users file not found: {users_path}")
        return

    users = []
    with open(users_path) as f:
        for line in f:
            line = line.strip()
            if line and not line.startswith("#"):
                parts = line.split(":", 1)
                if len(parts) == 2:
                    users.append(
                        {
                            "username": parts[0],
                            "has_password": True,
                        }
                    )

    print_result(True, f"Found {len(users)} user(s)", {"users": users, "file": str(users_path)})


def cmd_users_add(args):
    """Add a user to htpasswd file."""
    config_path = find_config()

    if config_path:
        config = parse_config(config_path)
        users_path = Path(config.get("auth", {}).get("htpasswd_filename", DEFAULT_USERS_PATH))
    else:
        users_path = Path(args.users_file or DEFAULT_USERS_PATH)

    # Check if htpasswd command exists
    try:
        subprocess.run(["htpasswd", "--help"], capture_output=True, check=True)
    except (FileNotFoundError, subprocess.CalledProcessError):
        print_result(False, "htpasswd command not found. Install apache2-utils or httpd-tools")
        return

    # Build htpasswd command
    cmd = ["htpasswd"]

    if args.encryption:
        if args.encryption == "sha512":
            cmd.append("-5")
        elif args.encryption == "sha256":
            cmd.append("-s")
        elif args.encryption == "bcrypt":
            cmd.append("-B")
        elif args.encryption == "md5":
            cmd.append("-m")
        elif args.encryption == "plain":
            # Will add manually
            pass

    # Create file if doesn't exist
    if not users_path.exists():
        cmd.append("-c")

    cmd.extend([str(users_path), args.username])

    try:
        # Run htpasswd interactively (will prompt for password)
        result = subprocess.run(cmd)
        if result.returncode == 0:
            print_result(True, f"User '{args.username}' added to {users_path}")
        else:
            print_result(False, f"Failed to add user: htpasswd returned {result.returncode}")
    except Exception as e:
        print_result(False, f"Failed to add user: {e}")


def cmd_users_remove(args):
    """Remove a user from htpasswd file."""
    config_path = find_config()

    if config_path:
        config = parse_config(config_path)
        users_path = Path(config.get("auth", {}).get("htpasswd_filename", DEFAULT_USERS_PATH))
    else:
        users_path = Path(args.users_file or DEFAULT_USERS_PATH)

    if not users_path.exists():
        print_result(False, f"Users file not found: {users_path}")
        return

    # Read existing users
    lines = []
    found = False
    with open(users_path) as f:
        for line in f:
            if line.strip().startswith(args.username + ":"):
                found = True
            else:
                lines.append(line)

    if not found:
        print_result(False, f"User '{args.username}' not found")
        return

    # Write back
    with open(users_path, "w") as f:
        f.writelines(lines)

    print_result(True, f"User '{args.username}' removed")


def cmd_storage_verify(args):
    """Verify Radicale storage integrity."""
    config_path = find_config()

    if config_path:
        config = parse_config(config_path)
        storage_path = Path(config.get("storage", {}).get("filesystem_folder", DEFAULT_STORAGE_PATH))
    else:
        storage_path = Path(args.storage_path or DEFAULT_STORAGE_PATH)

    if not storage_path.exists():
        print_result(False, f"Storage path not found: {storage_path}")
        return

    # Run radicale --verify-storage if available
    try:
        result = subprocess.run(
            ["python3", "-m", "radicale", "--verify-storage"],
            capture_output=True,
            text=True,
            timeout=60,
        )

        if result.returncode == 0:
            print_result(True, "Storage verified", {"output": result.stdout})
        else:
            print_result(False, "Storage verification failed", {"output": result.stdout, "error": result.stderr})
    except FileNotFoundError:
        print_result(False, "Radicale not installed")
    except subprocess.TimeoutExpired:
        print_result(False, "Storage verification timed out")
    except Exception as e:
        print_result(False, f"Failed to verify storage: {e}")


def main():
    import argparse

    parser = argparse.ArgumentParser(description="Radicale server management")
    subparsers = parser.add_subparsers(dest="command", required=True)

    # Status
    subparsers.add_parser("status", help="Check Radicale status")

    # Config
    config_parser = subparsers.add_parser("config", help="Configuration management")
    config_sub = config_parser.add_subparsers(dest="config_command", required=True)
    config_sub.add_parser("show", help="Show configuration")
    config_sub.add_parser("validate", help="Validate configuration")

    # Users
    users_parser = subparsers.add_parser("users", help="User management")
    users_sub = users_parser.add_subparsers(dest="users_command", required=True)

    users_list = users_sub.add_parser("list", help="List users")
    users_list.add_argument("--users-file", help="Custom users file path")

    users_add = users_sub.add_parser("add", help="Add user")
    users_add.add_argument("--username", required=True, help="Username")
    users_add.add_argument("--encryption", choices=["sha512", "sha256", "bcrypt", "md5", "plain"],
                          default="sha512", help="Password encryption")
    users_add.add_argument("--users-file", help="Custom users file path")

    users_remove = users_sub.add_parser("remove", help="Remove user")
    users_remove.add_argument("--username", required=True, help="Username")
    users_remove.add_argument("--users-file", help="Custom users file path")

    # Storage
    storage_parser = subparsers.add_parser("storage", help="Storage management")
    storage_sub = storage_parser.add_subparsers(dest="storage_command", required=True)
    storage_verify = storage_sub.add_parser("verify", help="Verify storage")
    storage_verify.add_argument("--storage-path", help="Custom storage path")

    args = parser.parse_args()

    # Dispatch
    if args.command == "config":
        globals()[f"cmd_config_{args.config_command}"](args)
    elif args.command == "users":
        globals()[f"cmd_users_{args.users_command}"](args)
    elif args.command == "storage":
        globals()[f"cmd_storage_{args.storage_command}"](args)
    else:
        globals()[f"cmd_{args.command}"](args)


if __name__ == "__main__":
    main()

```

### scripts/todos.py

```python
#!/usr/bin/env python3
"""Todo/Task operations for CalDAV."""

import sys
from pathlib import Path
from datetime import datetime, date
from typing import Optional

# Add scripts dir to path
sys.path.insert(0, str(Path(__file__).parent))

from utils import get_client, print_result, parse_datetime


def find_calendar(client, calendar_name: str):
    """Find calendar by name or ID."""
    principal = client.principal()
    for cal in principal.calendars():
        cal_id = str(cal.url).rstrip("/").split("/")[-1]
        if cal_id == calendar_name or cal.name == calendar_name:
            return cal
    return None


def format_todo(todo) -> dict:
    """Format todo for display."""
    data = {
        "uid": todo.icalendar_component.get("uid", "unknown"),
        "url": str(todo.url) if todo.url else None,
    }

    vtodo = todo.icalendar_component

    # Summary
    if vtodo.get("summary"):
        data["summary"] = str(vtodo["summary"])

    # Description
    if vtodo.get("description"):
        data["description"] = str(vtodo["description"])

    # Status
    if vtodo.get("status"):
        data["status"] = str(vtodo["status"])
        data["completed"] = vtodo["status"] == "COMPLETED"

    # Priority
    if vtodo.get("priority"):
        data["priority"] = int(vtodo["priority"])

    # Due date
    if vtodo.get("due"):
        due = vtodo["due"].dt
        if hasattr(due, "isoformat"):
            data["due"] = due.isoformat()
        else:
            data["due"] = str(due)

    # Completed date
    if vtodo.get("completed"):
        completed = vtodo["completed"].dt
        if hasattr(completed, "isoformat"):
            data["completed_date"] = completed.isoformat()
        else:
            data["completed_date"] = str(completed)

    # Categories
    if vtodo.get("categories"):
        cats = vtodo["categories"]
        if isinstance(cats, str):
            data["categories"] = [c.strip() for c in cats.split(",")]
        elif isinstance(cats, list):
            data["categories"] = [str(c) for c in cats]

    # Check if overdue
    if vtodo.get("due") and not data.get("completed"):
        due = vtodo["due"].dt
        now = datetime.now() if hasattr(due, "hour") else date.today()
        if due < now:
            data["overdue"] = True

    return data


def cmd_list(args):
    """List todos."""
    client = get_client()
    principal = client.principal()

    all_todos = []

    if args.calendar:
        calendars = [find_calendar(client, args.calendar)]
        calendars = [c for c in calendars if c]
    else:
        calendars = principal.calendars()

    for cal in calendars:
        if not cal:
            continue

        try:
            todos = cal.todos()
            for todo in todos:
                todo_data = format_todo(todo)
                todo_data["calendar"] = cal.name

                # Filter by completed status
                if not args.completed and todo_data.get("completed"):
                    continue

                # Filter by overdue
                if args.overdue and not todo_data.get("overdue"):
                    continue

                all_todos.append(todo_data)
        except Exception as e:
            # Calendar might not support todos
            pass

    # Sort by due date, then priority
    def sort_key(t):
        due = t.get("due", "")
        priority = t.get("priority", 5) or 5
        return (due or "zzz", priority)

    all_todos.sort(key=sort_key)

    print_result(True, f"Found {len(all_todos)} todo(s)", {"todos": all_todos})


def cmd_create(args):
    """Create a new todo."""
    client = get_client()

    calendar = find_calendar(client, args.calendar)
    if not calendar:
        print_result(False, f"Calendar '{args.calendar}' not found")
        return

    try:
        kwargs = {
            "summary": args.summary,
        }

        if args.due:
            due = parse_datetime(args.due)
            # If only date provided, use it as date (not datetime)
            if "T" not in args.due and " " not in args.due:
                due = due.date() if hasattr(due, "date") else due
            kwargs["due"] = due

        if args.description:
            kwargs["description"] = args.description

        if args.priority:
            kwargs["priority"] = args.priority

        if args.categories:
            kwargs["categories"] = [c.strip() for c in args.categories.split(",")]

        todo = calendar.save_todo(**kwargs)
        todo_data = format_todo(todo)

        print_result(True, "Todo created", todo_data)

    except Exception as e:
        print_result(False, f"Failed to create todo: {e}")


def cmd_complete(args):
    """Mark a todo as complete."""
    client = get_client()

    todo = None

    # Find todo
    if args.calendar:
        calendar = find_calendar(client, args.calendar)
        if calendar:
            try:
                todo = calendar.todo_by_uid(args.uid)
            except Exception:
                pass

    if not todo:
        principal = client.principal()
        for cal in principal.calendars():
            try:
                todo = cal.todo_by_uid(args.uid)
                break
            except Exception:
                pass

    if not todo:
        print_result(False, f"Todo '{args.uid}' not found")
        return

    try:
        # Mark complete
        vtodo = todo.icalendar_component
        vtodo["status"] = "COMPLETED"
        vtodo["completed"] = datetime.now()
        todo.save()

        todo_data = format_todo(todo)
        print_result(True, "Todo marked as complete", todo_data)

    except Exception as e:
        print_result(False, f"Failed to complete todo: {e}")


def cmd_delete(args):
    """Delete a todo."""
    client = get_client()

    todo = None

    # Find todo
    if args.calendar:
        calendar = find_calendar(client, args.calendar)
        if calendar:
            try:
                todo = calendar.todo_by_uid(args.uid)
            except Exception:
                pass

    if not todo:
        principal = client.principal()
        for cal in principal.calendars():
            try:
                todo = cal.todo_by_uid(args.uid)
                break
            except Exception:
                pass

    if not todo:
        print_result(False, f"Todo '{args.uid}' not found")
        return

    if not args.force:
        try:
            confirm = input(f"Delete todo '{args.uid}'? [y/N] ")
            if confirm.lower() != "y":
                print_result(False, "Cancelled")
                return
        except EOFError:
            print_result(False, "Use --force to skip confirmation")
            return

    try:
        todo.delete()
        print_result(True, f"Todo '{args.uid}' deleted")
    except Exception as e:
        print_result(False, f"Failed to delete todo: {e}")


def main():
    import argparse

    parser = argparse.ArgumentParser(description="Todo operations")
    subparsers = parser.add_subparsers(dest="command", required=True)

    # List
    list_cmd = subparsers.add_parser("list", help="List todos")
    list_cmd.add_argument("--calendar", "-c", help="Filter by calendar")
    list_cmd.add_argument("--completed", action="store_true", help="Show completed")
    list_cmd.add_argument("--overdue", action="store_true", help="Show overdue only")

    # Create
    create = subparsers.add_parser("create", help="Create a todo")
    create.add_argument("--calendar", "-c", required=True, help="Target calendar")
    create.add_argument("--summary", "-s", required=True, help="Todo title")
    create.add_argument("--due", help="Due date")
    create.add_argument("--description", "-d", help="Description")
    create.add_argument("--priority", type=int, help="Priority (1-9, 1=highest)")
    create.add_argument("--categories", help="Comma-separated categories")

    # Complete
    complete = subparsers.add_parser("complete", help="Mark todo complete")
    complete.add_argument("--uid", required=True, help="Todo UID")
    complete.add_argument("--calendar", "-c", help="Calendar")

    # Delete
    delete = subparsers.add_parser("delete", help="Delete a todo")
    delete.add_argument("--uid", required=True, help="Todo UID")
    delete.add_argument("--calendar", "-c", help="Calendar")
    delete.add_argument("--force", action="store_true", help="Skip confirmation")

    args = parser.parse_args()
    globals()[f"cmd_{args.command}"](args)


if __name__ == "__main__":
    main()

```

### scripts/utils.py

```python
#!/usr/bin/env python3
"""Shared utilities for CalDAV operations."""

import os
import json
import argparse
from pathlib import Path
from typing import Optional, Dict, Any

# Config file locations
CONFIG_PATHS = [
    Path.home() / ".config" / "caldav" / "config.json",
    Path("/etc/caldav/config.json"),
]


def load_config() -> Dict[str, Any]:
    """Load configuration from file or environment."""
    config = {}

    # Try config files
    for config_path in CONFIG_PATHS:
        if config_path.exists():
            with open(config_path) as f:
                config.update(json.load(f))
            break

    # Environment overrides
    if os.environ.get("CALDAV_URL"):
        config["url"] = os.environ["CALDAV_URL"]
    if os.environ.get("CALDAV_USER"):
        config["username"] = os.environ["CALDAV_USER"]
    if os.environ.get("CALDAV_PASSWORD"):
        config["password"] = os.environ["CALDAV_PASSWORD"]

    return config


def get_client():
    """Get authenticated CalDAV client."""
    try:
        from caldav import DAVClient
    except ImportError:
        print("Error: caldav library not installed.")
        print("Install with: pip install caldav")
        raise SystemExit(1)

    config = load_config()

    if not config.get("url"):
        print("Error: No CalDAV URL configured.")
        print("Set CALDAV_URL environment variable or create config.json")
        raise SystemExit(1)

    return DAVClient(
        url=config.get("url"),
        username=config.get("username"),
        password=config.get("password"),
    )


def format_datetime(dt_str: str) -> str:
    """Format datetime string for display."""
    # Handle various formats
    if "T" in dt_str:
        if dt_str.endswith("Z"):
            return dt_str.replace("Z", " UTC")
        return dt_str
    return dt_str


def parse_datetime(dt_str: str):
    """Parse datetime string to datetime object."""
    from datetime import datetime

    # Try various formats
    formats = [
        "%Y-%m-%dT%H:%M:%S",
        "%Y-%m-%dT%H:%M:%SZ",
        "%Y-%m-%dT%H:%M",
        "%Y-%m-%d %H:%M:%S",
        "%Y-%m-%d %H:%M",
        "%Y-%m-%d",
    ]

    for fmt in formats:
        try:
            return datetime.strptime(dt_str, fmt)
        except ValueError:
            continue

    raise ValueError(f"Unable to parse datetime: {dt_str}")


def print_result(success: bool, message: str, data: Optional[Dict] = None):
    """Print result in consistent format."""
    import json

    result = {"success": success, "message": message}
    if data:
        result["data"] = data

    print(json.dumps(result, indent=2, default=str))


class CalendarCommand:
    """Base class for calendar commands."""

    def __init__(self):
        self.parser = argparse.ArgumentParser(description="Calendar operations")
        subparsers = self.parser.add_subparsers(dest="command", required=True)

        # List
        subparsers.add_parser("list", help="List all calendars")

        # Create
        create = subparsers.add_parser("create", help="Create a new calendar")
        create.add_argument("--name", required=True, help="Calendar display name")
        create.add_argument("--id", help="Calendar ID (URL-safe)")
        create.add_argument("--description", help="Calendar description")
        create.add_argument(
            "--color", help="Calendar color (hex, e.g., #FF0000)"
        )

        # Delete
        delete = subparsers.add_parser("delete", help="Delete a calendar")
        delete.add_argument("--id", required=True, help="Calendar ID or name")
        delete.add_argument("--force", action="store_true", help="Skip confirmation")

        # Info
        info = subparsers.add_parser("info", help="Get calendar info")
        info.add_argument("--id", required=True, help="Calendar ID or name")

        # Export
        export = subparsers.add_parser("export", help="Export calendar to ICS")
        export.add_argument("--id", required=True, help="Calendar ID or name")
        export.add_argument("--output", "-o", help="Output file path")

        # Import
        imp = subparsers.add_parser("import", help="Import ICS file")
        imp.add_argument("--calendar", required=True, help="Target calendar ID or name")
        imp.add_argument("--file", "-f", required=True, help="ICS file to import")

    def run(self):
        args = self.parser.parse_args()
        getattr(self, f"cmd_{args.command}")(args)


class EventCommand:
    """Base class for event commands."""

    def __init__(self):
        self.parser = argparse.ArgumentParser(description="Event operations")
        subparsers = self.parser.add_subparsers(dest="command", required=True)

        # List
        list_cmd = subparsers.add_parser("list", help="List events")
        list_cmd.add_argument("--calendar", "-c", help="Filter by calendar")
        list_cmd.add_argument("--start", help="Start date (YYYY-MM-DD)")
        list_cmd.add_argument("--end", help="End date (YYYY-MM-DD)")
        list_cmd.add_argument("--limit", type=int, default=50, help="Max results")

        # Create
        create = subparsers.add_parser("create", help="Create an event")
        create.add_argument("--calendar", "-c", required=True, help="Target calendar")
        create.add_argument("--summary", "-s", required=True, help="Event title")
        create.add_argument("--start", required=True, help="Start datetime")
        create.add_argument("--end", help="End datetime")
        create.add_argument("--allday", action="store_true", help="All-day event")
        create.add_argument("--description", "-d", help="Event description")
        create.add_argument("--location", "-l", help="Event location")
        create.add_argument("--rrule", help="Recurrence rule (e.g., FREQ=WEEKLY)")
        create.add_argument("--uid", help="Custom UID")
        create.add_argument("--timezone", help="Timezone (e.g., America/New_York)")

        # Update
        update = subparsers.add_parser("update", help="Update an event")
        update.add_argument("--uid", required=True, help="Event UID")
        update.add_argument("--calendar", "-c", help="Calendar (if known)")
        update.add_argument("--summary", "-s", help="New title")
        update.add_argument("--start", help="New start datetime")
        update.add_argument("--end", help="New end datetime")
        update.add_argument("--description", "-d", help="New description")
        update.add_argument("--location", "-l", help="New location")
        update.add_argument("--status", choices=["CONFIRMED", "TENTATIVE", "CANCELLED"])

        # Delete
        delete = subparsers.add_parser("delete", help="Delete an event")
        delete.add_argument("--uid", required=True, help="Event UID")
        delete.add_argument("--calendar", "-c", help="Calendar (if known)")
        delete.add_argument("--force", action="store_true", help="Skip confirmation")

        # Search
        search = subparsers.add_parser("search", help="Search events")
        search.add_argument("--query", "-q", required=True, help="Search query")
        search.add_argument("--calendar", "-c", help="Filter by calendar")

        # Get
        get = subparsers.add_parser("get", help="Get event by UID")
        get.add_argument("--uid", required=True, help="Event UID")
        get.add_argument("--calendar", "-c", help="Calendar (if known)")

    def run(self):
        args = self.parser.parse_args()
        getattr(self, f"cmd_{args.command}")(args)


class TodoCommand:
    """Base class for todo commands."""

    def __init__(self):
        self.parser = argparse.ArgumentParser(description="Todo operations")
        subparsers = self.parser.add_subparsers(dest="command", required=True)

        # List
        list_cmd = subparsers.add_parser("list", help="List todos")
        list_cmd.add_argument("--calendar", "-c", help="Filter by calendar")
        list_cmd.add_argument("--completed", action="store_true", help="Show completed")
        list_cmd.add_argument("--overdue", action="store_true", help="Show overdue only")

        # Create
        create = subparsers.add_parser("create", help="Create a todo")
        create.add_argument("--calendar", "-c", required=True, help="Target calendar")
        create.add_argument("--summary", "-s", required=True, help="Todo title")
        create.add_argument("--due", help="Due date")
        create.add_argument("--description", "-d", help="Description")
        create.add_argument("--priority", type=int, help="Priority (1-9)")
        create.add_argument("--categories", help="Comma-separated categories")

        # Complete
        complete = subparsers.add_parser("complete", help="Mark todo complete")
        complete.add_argument("--uid", required=True, help="Todo UID")
        complete.add_argument("--calendar", "-c", help="Calendar")

        # Delete
        delete = subparsers.add_parser("delete", help="Delete a todo")
        delete.add_argument("--uid", required=True, help="Todo UID")
        delete.add_argument("--calendar", "-c", help="Calendar")
        delete.add_argument("--force", action="store_true", help="Skip confirmation")

    def run(self):
        args = self.parser.parse_args()
        getattr(self, f"cmd_{args.command}")(args)

```

caldav | SkillHub