Architecture

How ical works under the hood — native EventKit bindings via cgo, Go project structure, and key design decisions.

Overview

ical is a native macOS CLI that manages Calendar events through go-eventkit, a Go library providing direct EventKit bindings via cgo. This gives ical near-native performance — roughly 3000x faster than AppleScript-based alternatives.

The entire application compiles to a single binary with no runtime dependencies beyond macOS itself.

How It Works

User → ical CLI (Cobra) → go-eventkit → EventKit (Objective-C via cgo) → Calendar.app store
  1. The user invokes a command via the Cobra CLI framework
  2. Commands call functions in the go-eventkit/calendar package
  3. go-eventkit uses cgo to call EventKit’s Objective-C APIs directly
  4. EventKit reads from and writes to the same store that Calendar.app uses

There is no IPC, no subprocess spawning, and no Apple Events bridge. The Go binary links against EventKit at compile time.

Project Structure

ical/
├── cmd/ical/
   ├── main.go                  # Entry point (macOS check, version injection)
   └── commands/                # One file per Cobra command
       ├── root.go              # Root command + global flags (--output, --no-color)
       ├── calendars.go         # List calendars
       ├── list.go              # List events (date range, filters)
       ├── show.go              # Show single event detail
       ├── add.go               # Create event (flags + interactive -i)
       ├── update.go            # Update event (flags + interactive -i)
       ├── delete.go            # Delete event + pickEvent() helper
       ├── helpers.go           # Shared helpers
       ├── today.go             # Shortcut: today's events
       ├── upcoming.go          # Next N days
       ├── search.go            # Search events
       ├── export.go            # Export events (JSON/CSV/ICS)
       ├── import.go            # Import events (JSON/CSV)
       └── skills.go            # AI agent skill management
├── internal/
   ├── ui/                      # Output formatting (table/json/plain)
      └── output.go
   ├── export/                  # Import/export logic
      ├── json.go
      ├── csv.go
      └── ics.go
   ├── skills/                  # Agent skill install/uninstall logic
      └── skills.go
   └── update/                  # Background update check
       └── check.go
├── skills/ical-cli/             # Embedded agent skill (baked into binary)
   ├── SKILL.md
   └── references/
├── skills.go                    # go:embed for skills directory
├── Makefile
└── go.mod

Key Dependencies

PackagePurpose
github.com/BRO3886/go-eventkitNative EventKit bindings (cgo) + shared dateparser
github.com/spf13/cobraCLI framework
github.com/olekukonko/tablewriterTable output formatting
github.com/fatih/colorTerminal colors
github.com/charmbracelet/huhInteractive forms and select menus

Design Decisions

Row Numbers Instead of Short IDs

Calendar event identifiers in EventKit share a common prefix per calendar — the UUID before the : separator is the calendar ID, not the event ID. This makes short ID prefixes useless for disambiguation when events belong to the same calendar.

Instead, ical uses sequential row numbers (#1, #2, …) displayed in table output. These numbers are cached to ~/.ical-last-list so subsequent commands like ical show 2 or ical delete 1 can reference events from the last listing.

Three Event Selection Methods

  1. Interactive picker — No arguments triggers a searchable list powered by charmbracelet/huh
  2. Row number — Numeric argument maps to cached row from last listing
  3. Event ID — Full or partial eventIdentifier for scripting and automation

End-of-Day Bumping

When --to resolves to midnight (00:00:00), ical bumps it to 23:59:59 so that --to "feb 12" includes all events on February 12. Without this, midnight would exclude the entire day.

Embedded Agent Skills

ical embeds its own agent skill files into the binary via go:embed. When a user runs ical skills install, the embedded files are written to the appropriate agent’s skill directory (~/.claude/skills/, ~/.codex/skills/, ~/.openclaw/skills/, or ~/.agents/skills/). This ensures the skill documentation always matches the binary version — no separate download or version mismatch possible.

A .ical-version file is written alongside the skill files to track which binary version installed them. When the binary is updated, ical skills status reports the mismatch, and any ordinary command prints a staleness notice.

Post-Run Notices

Two notices can follow a command: a newer release exists, or the installed agent skills are older than the binary. Both go to stderr, never stdout.

One predicate gates both notices and the update check itself, so they cannot drift apart. Notices are skipped when:

  • ICAL_NO_UPDATE_CHECK is set
  • the binary is a dev build
  • --output json is used
  • the command is in the version, completion, or skills group
  • stderr is not a terminal

The terminal test is on stderr because that is where notices go. ical list > events.json keeps its notices; ical list 2> run.log does not.

The check runs in a background goroutine: non-blocking, 2-second timeout, cached to ~/.cache/ical/update-check for 24 hours.

UTC to Local Conversion

EventKit returns all times in UTC. ical converts them to local time using the event’s timezone (or the system timezone) for display. JSON output preserves ISO 8601 timestamps.

Limitations

These are Apple-imposed constraints, not bugs:

  • Attendees can be invited but not removedical add --invite adds attendees (and sends invitations); there is no way to remove an attendee or change the organizer through the CLI
  • Free/busy needs Exchange or Google Workspace — iCloud accounts do not support availability lookups, so ical free cannot resolve iCloud addresses
  • Subscribed calendars are read-only — Cannot create or modify events in subscribed calendars
  • Birthday calendars are read-only — The Birthdays calendar is auto-generated
  • macOS only — EventKit is an Apple framework; ical exits gracefully on other platforms
  • Date ranges required — EventKit requires bounded queries; ical does not support unbounded event fetches