Native macOS command-line tool for managing Calendar events and Reminders using EventKit. Output is JSON by default, with --format csv and --format text available on every command for spreadsheets and quick eyeballing.
- List, create, update, and delete calendar events
- List, create, update, complete, and delete reminders
- Quick date-range shortcuts:
ekctl today,ekctl tomorrow,ekctl next - Search and filter (
--search,--availability busy) without piping through jq - Calendar aliases (use friendly names instead of UUIDs)
- JSON, CSV, or plain-text output (
--format json|csv|text) - Full EventKit integration with proper permission handling
- Support for iCloud, Exchange, and local calendars
- macOS 13.0 (Ventura) or later
- Xcode Command Line Tools or Xcode
- Swift 5.9+
brew tap schappim/ekctl
brew install ekctlgit clone https://github.com/schappim/ekctl.git
cd ekctl
swift build -c release
# Optional: Sign with entitlements
codesign --force --sign - --entitlements ekctl.entitlements .build/release/ekctl
# Install
sudo cp .build/release/ekctl /usr/local/bin/On first run, macOS will prompt for Calendar and Reminders access. Manage permissions in System Settings → Privacy & Security → Calendars / Reminders.
Command:
ekctl list calendarsOutput:
{
"calendars": [
{
"id": "CA513B39-1659-4359-8FE9-0C2A3DCEF153",
"title": "Work",
"type": "event",
"source": "iCloud",
"color": "#0088FF",
"allowsModifications": true
}
],
"status": "success"
}Command:
ekctl calendar create --title "Project X" --color "#FF5500"Command:
ekctl calendar update CALENDAR_ID --title "New Name" --color "#00FF00"Command:
ekctl calendar delete CALENDAR_IDUse friendly names instead of UUIDs. Aliases work anywhere a calendar ID is accepted.
Set alias:
ekctl alias set work "CA513B39-1659-4359-8FE9-0C2A3DCEF153"
ekctl alias set personal "4E367C6F-354B-4811-935E-7F25A1BB7D39"List aliases:
ekctl alias listOutput:
{
"aliases": [
{ "name": "groceries", "id": "E30AE972-8F29-40AF-BFB9-E984B98B08AB" },
{ "name": "personal", "id": "4E367C6F-354B-4811-935E-7F25A1BB7D39" },
{ "name": "work", "id": "CA513B39-1659-4359-8FE9-0C2A3DCEF153" }
],
"count": 3,
"configPath": "/Users/you/.ekctl/config.json",
"status": "success"
}Remove alias:
ekctl alias remove workUsage:
# These are equivalent:
ekctl list events --calendar "CA513B39-1659-4359-8FE9-0C2A3DCEF153" --from "2026-01-01T00:00:00Z" --to "2026-01-31T23:59:59Z"
ekctl list events --calendar work --from "2026-01-01T00:00:00Z" --to "2026-01-31T23:59:59Z"Aliases are stored in ~/.ekctl/config.json.
Command:
ekctl list events --calendar work --from "2026-01-01T00:00:00Z" --to "2026-01-31T23:59:59Z"To fetch events from multiple calendars in a single call, pass a comma-separated list of IDs or aliases. Each event's source calendar is reported in its calendar field, so the merged stream is still distinguishable:
ekctl list events --calendar work,personal --from "2026-01-01T00:00:00Z" --to "2026-01-31T23:59:59Z"Filtering:
Narrow the result set further with --search (case-insensitive substring across title, location, and notes) and --availability (one of busy, free, tentative, unavailable, notSupported). Both filters compose with each other and with the calendar/date selection:
# Just the standup-related events
ekctl list events --calendar work --from "$NOWISH" --to "$TOMORROW" --search standup
# Only "busy" events — useful for finding actual blocked-out time
ekctl list events --calendar work --from "$NOWISH" --to "$TOMORROW" --availability busy
# Combine — standups marked busy
ekctl list events --calendar work --from "$NOWISH" --to "$TOMORROW" --search standup --availability busyOutput:
{
"count": 2,
"events": [
{
"id": "ABC123:DEF456",
"title": "Team Meeting",
"calendar": {
"id": "CA513B39-1659-4359-8FE9-0C2A3DCEF153",
"title": "Work"
},
"startDate": "2026-01-15T09:00:00Z",
"endDate": "2026-01-15T10:00:00Z",
"location": "Conference Room A",
"notes": null,
"allDay": false,
"hasAlarms": true,
"hasRecurrenceRules": false,
"availability": "busy",
"attendees": []
}
],
"status": "success"
}Three top-level shortcuts wrap the most common list events queries with a pre-computed local date range. No more date -u -v+1d shell prelude (which is BSD-only and breaks on Linux):
# Events occurring today (local time)
ekctl today --calendar work
# Events occurring tomorrow
ekctl tomorrow --calendar work
# The single next upcoming event (looks 90 days ahead by default)
ekctl next --calendar work
# The next N events
ekctl next --calendar work --count 5
# Look further out
ekctl next --calendar work --count 5 --days 365All three accept the same filter / format flags as list events (--search, --availability, --format, and comma-separated --calendar), so they compose:
ekctl today --calendar work,personal --availability busy --format csv
ekctl next --calendar work --search standup --count 3 --format textnext returns events sorted by start time ascending and includes events that are currently in progress (their endDate is still in the future).
Command:
ekctl show event EVENT_IDBasic event:
ekctl add event --calendar work --title "Lunch" --start "2026-02-10T12:30:00Z" --end "2026-02-10T13:30:00Z"With location, notes, and alarms:
ekctl add event \
--calendar work \
--title "Project Review" \
--start "2026-02-15T14:00:00Z" \
--end "2026-02-15T15:30:00Z" \
--location "Building 2, Room 301" \
--notes "Bring Q1 reports" \
--alarms "10,60"Recurring event (weekly):
ekctl add event \
--calendar personal \
--title "Gym" \
--start "2026-02-12T18:00:00Z" \
--end "2026-02-12T19:00:00Z" \
--recurrence-frequency weekly \
--recurrence-days "mon,wed,fri" \
--recurrence-end-count 20With travel time:
ekctl add event \
--calendar work \
--title "Client Site Visit" \
--start "2026-02-20T14:00:00Z" \
--end "2026-02-20T16:00:00Z" \
--location "1 Infinite Loop, Cupertino, CA" \
--travel-time 30Output:
{
"status": "success",
"message": "Event created successfully",
"event": {
"id": "NEW123:EVENT456",
"title": "Lunch",
"calendar": {
"id": "CA513B39-1659-4359-8FE9-0C2A3DCEF153",
"title": "Work"
},
"startDate": "2026-02-10T12:30:00Z",
"endDate": "2026-02-10T13:30:00Z",
"location": null,
"notes": null,
"allDay": false
}
}All flags are optional — only the fields you pass will be changed:
ekctl update event EVENT_ID --title "New title"With multiple fields:
ekctl update event EVENT_ID \
--title "Updated title" \
--start "2026-02-15T14:00:00Z" \
--end "2026-02-15T15:30:00Z" \
--location "Building 2, Room 301" \
--notes "Updated notes" \
--alarms "10,30" \
--travel-time 20 \
--availability busy \
--url "https://example.com/meeting"Output:
{
"status": "success",
"message": "Event updated successfully",
"event": {
"id": "ABC123:DEF456",
"title": "Updated title",
"calendar": {
"id": "CA513B39-1659-4359-8FE9-0C2A3DCEF153",
"title": "Work"
},
"startDate": "2026-02-15T14:00:00+08:00",
"endDate": "2026-02-15T15:30:00+08:00",
"location": "Building 2, Room 301",
"notes": "Updated notes",
"allDay": false,
"hasAlarms": true,
"hasRecurrenceRules": false
}
}Command:
ekctl delete event EVENT_IDOutput:
{
"status": "success",
"message": "Event 'Team Meeting' deleted successfully",
"deletedEventID": "ABC123:DEF456"
}All reminders:
ekctl list reminders --list personalOnly incomplete:
ekctl list reminders --list personal --completed falseOnly completed:
ekctl list reminders --list personal --completed trueSubstring filter on title and notes:
ekctl list reminders --list personal --search milkOutput:
{
"count": 2,
"reminders": [
{
"id": "REM123-456-789",
"title": "Buy groceries",
"list": {
"id": "4E367C6F-354B-4811-935E-7F25A1BB7D39",
"title": "Reminders"
},
"dueDate": "2026-01-20T17:00:00Z",
"completed": false,
"priority": 0,
"notes": null
}
],
"status": "success"
}Command:
ekctl show reminder REMINDER_IDSimple reminder:
ekctl add reminder --list personal --title "Call dentist"With due date:
ekctl add reminder --list personal --title "Submit expense report" --due "2026-01-25T09:00:00Z"With priority and notes (priority: 0=none, 1=high, 5=medium, 9=low):
ekctl add reminder \
--list groceries \
--title "Buy milk" \
--due "2026-02-01T12:00:00Z" \
--priority 1 \
--notes "Check expiration date"Output:
{
"status": "success",
"message": "Reminder created successfully",
"reminder": {
"id": "NEWREM-123-456",
"title": "Submit expense report",
"list": {
"id": "4E367C6F-354B-4811-935E-7F25A1BB7D39",
"title": "Reminders"
},
"dueDate": "2026-01-25T09:00:00Z",
"completed": false,
"priority": 0,
"notes": null
}
}Command:
ekctl update reminder REMINDER_ID --title "New title" --due "2026-02-01T09:00:00Z" --priority 1 --notes "Updated notes"All flags are optional — only the fields you pass will be changed:
# Just change the title
ekctl update reminder REMINDER_ID --title "Renamed reminder"
# Bump priority and add a due date
ekctl update reminder REMINDER_ID --priority 1 --due "2026-03-10T09:00:00Z"
# Mark as completed via update (same effect as complete command)
ekctl update reminder REMINDER_ID --completed trueOutput:
{
"status": "success",
"message": "Reminder updated successfully",
"reminder": {
"id": "REM123-456-789",
"title": "New title",
"list": {
"id": "4E367C6F-354B-4811-935E-7F25A1BB7D39",
"title": "Reminders"
},
"dueDate": "2026-02-01T09:00:00+08:00",
"completed": false,
"priority": 1,
"notes": "Updated notes"
}
}Command:
ekctl complete reminder REMINDER_IDOutput:
{
"status": "success",
"message": "Reminder 'Buy groceries' marked as completed",
"reminder": {
"id": "REM123-456-789",
"title": "Buy groceries",
"completed": true,
"completionDate": "2026-01-21T10:30:00Z"
}
}Command:
ekctl delete reminder REMINDER_IDAll dates use ISO 8601 format with timezone. Examples:
| Format | Example | Description |
|---|---|---|
| UTC | 2026-01-15T09:00:00Z |
9:00 AM UTC |
| With offset | 2026-01-15T09:00:00+10:00 |
9:00 AM AEST |
| Midnight | 2026-01-15T00:00:00Z |
Start of day |
| End of day | 2026-01-15T23:59:59Z |
End of day |
CALENDAR_ID=$(ekctl list calendars | jq -r '.calendars[] | select(.title == "Work") | .id')
echo $CALENDAR_IDekctl today --calendar "$CALENDAR_ID"The today / tomorrow / next subcommands work out the date range locally so you don't have to wrangle date -v+1d (which is BSD-only and breaks under Linux), and they accept the same --search, --availability, and --format flags as list events:
# Tomorrow's busy meetings as CSV
ekctl tomorrow --calendar work --availability busy --format csv
# Next 3 events that mention "standup"
ekctl next --calendar work --count 3 --search standupTITLE="Sprint Planning"
START="2026-01-20T10:00:00Z"
END="2026-01-20T11:00:00Z"
ekctl add event \
--calendar "$CALENDAR_ID" \
--title "$TITLE" \
--start "$START" \
--end "$END"ekctl list reminders --list "$LIST_ID" --completed false | jq '.count'Use the built-in --format csv flag — no jq pipeline required. The CSV header is the union of every field across the returned events, so new fields like availability and attendees are picked up automatically as they're added:
ekctl list events \
--calendar "$CALENDAR_ID" \
--from "2026-01-01T00:00:00Z" \
--to "2026-12-31T23:59:59Z" \
--format csv \
> events.csvNested objects flatten to dot-notated columns (e.g., calendar.id, calendar.title), and nested arrays (like attendees) become a single JSON-encoded cell.
--format text emits one key: value line per field, with a blank line between items — handy for grep, eyeballing, or quick head/tail checks:
ekctl list events --calendar work --from "$TODAY" --to "$TOMORROW" --format textAll errors return JSON with status: "error":
{
"status": "error",
"error": "Calendar not found with ID: invalid-id"
}Common errors:
Permission denied: Grant access in System Settings → Privacy & Security → Calendars/RemindersCalendar not found: Check calendar ID withekctl list calendarsInvalid date format: Use ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)
ekctl --help
ekctl list --help
ekctl add event --helpMIT License
Pull requests welcome.