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
- Human date input on every flag —
--start "tomorrow 9am",--from today,--to +2w - Quick date-range shortcuts:
ekctl today,ekctl tomorrow,ekctl next - Find open time across calendars with
ekctl free— working hours, buffers, and multi-calendar merging - 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) - RFC 3339 or jq-friendly compact timestamps (
--time-format rfc3339|compact) - Full EventKit integration with proper permission handling
- Support for iCloud, Exchange, and local calendars
- macOS 13.0 (Ventura) or later
- Building from source additionally requires a full Xcode installation (the Command Line Tools alone currently fail on the SwiftPM manifest); the prebuilt release binary has no build-time requirements
Every release ships a prebuilt universal (Apple Silicon + Intel) binary — pick the latest from the releases page:
curl -L -o ekctl.tar.gz https://github.com/schappim/ekctl/releases/download/v1.8.0/ekctl-v1.8.0.tar.gz
tar -xzf ekctl.tar.gz
xattr -d com.apple.quarantine ekctl # release binaries are ad-hoc signed, not notarized
sudo mv ekctl /usr/local/bin/A .sha256 checksum is published next to each tarball.
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 access to the data the command touches — Calendars, Reminders, or both (e.g., ekctl list calendars lists both stores). Commands only request what they need, so a reminders-only workflow never triggers the Calendar prompt. 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,
"alarms": [
{ "type": "relative", "minutesBeforeStart": 10 }
],
"travelTimeMinutes": null,
"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,
"alarms": [
{ "type": "relative", "minutesBeforeStart": 10 },
{ "type": "relative", "minutesBeforeStart": 30 }
],
"travelTimeMinutes": 20,
"hasRecurrenceRules": false
}
}--alarms takes comma-separated minutes and replaces every existing alarm on
the event (it does not append). A bare number means minutes before the start; a
leading + means minutes after:
ekctl update event EVENT_ID --alarms "10,60" # 10 min and 1 hour before
ekctl update event EVENT_ID --alarms "+15" # 15 min after the start
ekctl update event EVENT_ID --alarms "0" # at the startBoth fields are echoed back in the event JSON, in the same units the flags accept, so output round-trips into input:
"alarms": [
{ "type": "relative", "minutesBeforeStart": 10 },
{ "type": "relative", "minutesBeforeStart": -15 },
{ "type": "absolute", "date": "2026-02-15T08:00:00+11:00" }
],
"travelTimeMinutes": 20minutesBeforeStart is negative for an alarm that fires after the start, matching
the + flag form. Alarms set outside ekctl may be absolute rather than relative, in
which case they carry a date instead. EventKit does not preserve the order alarms
were supplied in, so read them as a set.
Two things worth knowing:
hasAlarmsis not evidence that your--alarmstook effect. Calendars apply a default alarm to new events, so a freshly created event usually reportshasAlarms: truewith an alarm you never asked for. Checkalarmsinstead.travelTimeMinutesisnullwhen unset. EventKit exposes no public API for travel time, so ekctl reads and writes it through KVC on an undocumented property; if a future macOS drops it,--travel-timereturns a clear error rather than crashing.
Command:
ekctl delete event EVENT_IDOutput:
{
"status": "success",
"message": "Event 'Team Meeting' deleted successfully",
"deletedEventID": "ABC123:DEF456"
}ekctl free answers the question list events can't: not what's booked, but what isn't. It merges the busy intervals from every calendar you name, clips them to your working hours day by day, and reports the gaps big enough to be useful.
ekctl free --calendar work --duration 30Output:
{
"count": 2,
"minimumDurationMinutes": 30,
"slots": [
{
"startDate": "2026-09-07T09:00:00+10:00",
"endDate": "2026-09-07T10:00:00+10:00",
"durationMinutes": 60,
"date": "2026-09-07",
"weekday": "monday"
},
{
"startDate": "2026-09-07T11:00:00+10:00",
"endDate": "2026-09-07T13:00:00+10:00",
"durationMinutes": 120,
"date": "2026-09-07",
"weekday": "monday"
}
],
"searchedFrom": "2026-09-07T08:15:00+10:00",
"searchedTo": "2026-09-14T08:15:00+10:00",
"workingHours": "09:00-17:00",
"weekdays": "monday,tuesday,wednesday,thursday,friday",
"busyEventCount": 11,
"status": "success"
}Each slot is a maximal gap, so its durationMinutes tells you how much room you actually have — --duration is the minimum a gap must reach to be reported, not the size of the slot returned, and it's echoed back at the top level as minimumDurationMinutes. Slots come back in chronological order, and use the same startDate / endDate field names as events.
| Flag | Default | Description |
|---|---|---|
--calendar |
required | Calendar ID or alias. Comma-separated for several (work,personal) — their events are merged into one busy view. |
--duration |
30 |
Minimum usable slot length, in minutes. |
--from |
now | Search start — ISO 8601 or shorthand (see Date Format). |
--to |
--days after the start |
Search end — ISO 8601 or shorthand (see Date Format). |
--days |
7 |
How far ahead to search when --to is omitted. |
--working-hours |
09:00-17:00 |
Daily window. all searches the whole day; overnight windows like 22:00-02:00 are supported. |
--weekdays |
weekdays |
mon,wed,fri, a range (mon-fri, fri-mon wraps), or weekdays / weekends / all. An overnight window belongs to the day it opens, so --working-hours 22:00-02:00 --weekdays mon-fri includes Friday 22:00 – Saturday 02:00. |
--buffer |
0 |
Minutes to leave either side of every meeting, so back-to-back slots aren't proposed. |
--round |
0 |
Round slot starts up to the next multiple of N minutes (e.g. 15), instead of whenever the previous meeting ended. |
--limit |
20 |
Maximum slots to return. |
--ignore-all-day |
off | Drop all-day events entirely instead of honouring their availability. |
An event blocks its time unless you've already said it doesn't:
- Marked free —
availability: freenever blocks. Everything else does, includingtentative,unavailable, and thenotSupportedthat calendars without availability report. - Cancelled — an event with a cancelled status doesn't block.
- Declined — an invitation you declined isn't a commitment, so it doesn't block.
- All-day events block according to their availability like anything else, so a busy all-day "Offsite" takes out the day while a free all-day birthday doesn't. Pass
--ignore-all-dayto skip them regardless.
The rule leans on the availability you (or your calendar server) already recorded rather than guessing from event titles.
# An hour, in the next fortnight, with 15 minutes' padding around every meeting
ekctl free --calendar work,personal --duration 60 --days 14 --buffer 15
# Early starts and Fridays off, with times landing on the quarter hour
ekctl free --calendar work --working-hours 08:00-16:00 --weekdays mon-thu --round 15
# Any time at all this weekend — for scheduling that isn't work
ekctl free --calendar personal --working-hours all --weekdays weekends
# Human-readable, for eyeballing
ekctl free --calendar work --duration 45 --format textThe earliest workable start time, as a one-liner:
ekctl free --calendar work --duration 30 --limit 1 | jq -r '.slots[0].startDate'Slots are a first-class row type in CSV and text output, the same as events and reminders:
ekctl free --calendar work --duration 30 --days 30 --format csv > openings.csvAll 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 date inputs (--from, --to, --start, --end, --due, --recurrence-end-date) accept either a full ISO 8601 timestamp or a shorthand. The two are interchangeable everywhere:
ekctl add event --calendar work --title Standup --start "tomorrow 9am" --end "tomorrow 9:15am"
ekctl list events --calendar work --from today --to +1w
ekctl add reminder --list personal --title "Call the dentist" --due "fri 5pm"| Form | Example | Means |
|---|---|---|
| Now | now |
The current instant |
| Offset | +90m, +3d, +1w |
From now. Units: m/min, h/hr, d/day, w/week |
| Negative offset | --from=-2h |
Same, backwards. Needs the = form — a bare -2h looks like a flag |
| Named day | today, tomorrow, yesterday |
Local midnight of that day |
| Weekday | fri, friday |
The next Friday, today included |
| Qualified weekday | next fri, last fri |
The next / previous Friday, today excluded |
| Week | next week, last week |
Seven days either side of today |
| Plain date | 2026-02-01 |
Local midnight |
| Time | 14:30, 3pm, 9:15am |
That time today |
| Named time | noon, midnight |
|
| Day + time | tomorrow 3pm, next fri at 09:00, 2026-02-01 14:30 |
Any day above with any time above |
Case doesn't matter, and at is optional filler.
A bare day resolves to the start of that day. That's what --from tomorrow should mean, but it applies just as much to the other flags: --start tomorrow creates a midnight event and --due tomorrow sets a midnight due date. Add a time — --start "tomorrow 9am" — whenever you mean a point in the day rather than its beginning.
A bare number like 9 is rejected, not guessed — it could be 9am or the 9th, and being wrong there books a meeting three weeks out. Write 9am or 09:00. Months and years are not offset units for the same reason: +3m is unambiguously 3 minutes.
Times are grafted onto days by wall clock, so tomorrow 09:00 is 9am even on the day the clocks change.
Accepted with any of these timezone suffixes, with or without fractional seconds. ISO is matched first, so a timestamp always parses as itself:
| Format | Example | Description |
|---|---|---|
| UTC | 2026-01-15T09:00:00Z |
9:00 AM UTC |
| Offset with colon | 2026-01-15T09:00:00+10:00 |
9:00 AM AEST (RFC 3339) |
| Compact offset | 2026-01-15T09:00:00+1000 |
Same instant, jq-style %z form |
Timestamps in output are rendered in your local timezone and are always valid input, so values round-trip between commands. The rendering is controlled by --time-format on every command:
--time-format rfc3339(default): colon-separated offset,Zfor UTC —2026-01-15T20:00:00+11:00--time-format compact: no colon, and+0000instead ofZ—2026-01-15T20:00:00+1100
compact exists because jq's strptime understands the %z offset form (+1100) but not the colon-separated %:z form (+11:00), so it can post-process ekctl timestamps directly:
# "09:00AM Standup" — the next 5 events with 12-hour start times
ekctl next --calendar work --count 5 --time-format compact |
jq -r '.events[] | "\(.startDate | strptime("%Y-%m-%dT%H:%M:%S%z") | strftime("%I:%M%p")) \(.title)"'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, and they accept the same --search, --availability, and --format flags as list events. (For anything they don't cover, --from and --to take the same shorthand — --from today --to +1w — so there's no need to wrangle date -v+1d either.)
# 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.
free and add event compose — the slot's start is already in a format --start accepts:
# --time-format compact so jq's strptime can read the offset (see Date Format)
SLOT=$(ekctl free --calendar work --duration 30 --limit 1 --time-format compact)
START=$(echo "$SLOT" | jq -r '.slots[0].startDate')
END=$(echo "$SLOT" | jq -r '.slots[0].startDate as $s | $s
| strptime("%Y-%m-%dT%H:%M:%S%z") | mktime + 1800
| strftime("%Y-%m-%dT%H:%M:%S") + $s[19:]')
ekctl add event --calendar work --title "Focus block" --start "$START" --end "$END"+ $s[19:] re-attaches the slot's own UTC offset. Don't be tempted to write
strftime("%Y-%m-%dT%H:%M:%SZ") instead: jq's strptime hands back local
wall-clock fields and mktime reads them back as UTC, so labelling the result
Z books an event wrong by your whole UTC offset — a 30-minute focus block
becomes ten and a half hours in Sydney, and ends before it starts in New York.
This recipe relies on ekctl rendering timestamps in your machine's own zone; it
isn't a general-purpose converter for timestamps from elsewhere.
--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 --<flag> date format: the message echoes the value it rejected. Use an ISO 8601 timestamp (2026-01-15T09:00:00Z,+10:00or+1000offsets) or a shorthand such astomorrow 9amor+2h— see Date Format
Exit codes: 0 success, 1 failure, 2 permission denied, 64 invalid usage (bad flags/values).
ekctl --help
ekctl list --help
ekctl add event --helpMIT License
Pull requests welcome.
ekctl was made by Marcus Schappi. I create software (and even hardware) for real-world businesses, including:
- Little Bird Electronics — Australia's electronics and STEM store, shipping Australia-wide. We sell Arduino, Raspberry Pi, micro:bit, STEM and STEAM education kits, e-textiles, robotics, sensors and electronic components.
- Struth.app — AI runs and grows your trade business. The Struth platform is field service management + CRM + AI.