Skip to content

schappim/ekctl

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ekctl

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.

Features

  • 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

Requirements

  • macOS 13.0 (Ventura) or later
  • Xcode Command Line Tools or Xcode
  • Swift 5.9+

Installation

Homebrew

brew tap schappim/ekctl
brew install ekctl

Build from source

git 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/

Permissions

On first run, macOS will prompt for Calendar and Reminders access. Manage permissions in System Settings → Privacy & Security → Calendars / Reminders.

Calendars

List Calendars

Command:

ekctl list calendars

Output:

{
  "calendars": [
    {
      "id": "CA513B39-1659-4359-8FE9-0C2A3DCEF153",
      "title": "Work",
      "type": "event",
      "source": "iCloud",
      "color": "#0088FF",
      "allowsModifications": true
    }
  ],
  "status": "success"
}

Create Calendar

Command:

ekctl calendar create --title "Project X" --color "#FF5500"

Update Calendar

Command:

ekctl calendar update CALENDAR_ID --title "New Name" --color "#00FF00"

Delete Calendar

Command:

ekctl calendar delete CALENDAR_ID

Aliases

Use 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 list

Output:

{
  "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 work

Usage:

# 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.

Events

List Events

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 busy

Output:

{
  "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"
}

Quick date ranges: today / tomorrow / next

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 365

All 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 text

next returns events sorted by start time ascending and includes events that are currently in progress (their endDate is still in the future).

Show Event

Command:

ekctl show event EVENT_ID

Add Event

Basic 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 20

With 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 30

Output:

{
  "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
  }
}

Update Event

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
  }
}

Delete Event

Command:

ekctl delete event EVENT_ID

Output:

{
  "status": "success",
  "message": "Event 'Team Meeting' deleted successfully",
  "deletedEventID": "ABC123:DEF456"
}

Reminders

List Reminders

All reminders:

ekctl list reminders --list personal

Only incomplete:

ekctl list reminders --list personal --completed false

Only completed:

ekctl list reminders --list personal --completed true

Substring filter on title and notes:

ekctl list reminders --list personal --search milk

Output:

{
  "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"
}

Show Reminder

Command:

ekctl show reminder REMINDER_ID

Add Reminder

Simple 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
  }
}

Update Reminder

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 true

Output:

{
  "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"
  }
}

Complete Reminder

Command:

ekctl complete reminder REMINDER_ID

Output:

{
  "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"
  }
}

Delete Reminder

Command:

ekctl delete reminder REMINDER_ID

Date Format

All 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

Scripting Examples

Get calendar ID by name

CALENDAR_ID=$(ekctl list calendars | jq -r '.calendars[] | select(.title == "Work") | .id')
echo $CALENDAR_ID

List today's events

ekctl 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 standup

Create event from variables

TITLE="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"

Count incomplete reminders

ekctl list reminders --list "$LIST_ID" --completed false | jq '.count'

Export events to CSV

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.csv

Nested objects flatten to dot-notated columns (e.g., calendar.id, calendar.title), and nested arrays (like attendees) become a single JSON-encoded cell.

Human-readable plain text

--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 text

Error Handling

All 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/Reminders
  • Calendar not found: Check calendar ID with ekctl list calendars
  • Invalid date format: Use ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)

Help

ekctl --help
ekctl list --help
ekctl add event --help

License

MIT License

Contributing

Pull requests welcome.

About

A native macOS CLI tool for managing Calendar events and Reminders via EventKit with JSON output

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors