- Go 76.3%
- CSS 8.2%
- HTML 7.8%
- JavaScript 6.6%
- Makefile 0.7%
- Other 0.4%
| cmd/cal | ||
| deploy | ||
| internal | ||
| web-site | ||
| .gitignore | ||
| CONTEXT.md | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
cal
A personal CLI calendar. Plaintext storage, no recurring events, no notifications. Just timed meetings and a fast way to add and view them.
Install
go install code.bas.es/arne/cal/cmd/cal@latest
Or from a clone:
go install ./cmd/cal
Usage
cal # next 5 upcoming events, grouped by day
cal week # rolling 7 days from today
cal next # soonest event + time until it starts
cal add <date> <time> <title...> # add a meeting
cal add # opens $EDITOR on events.txt
cal edit # same — opens $EDITOR on events.txt
cal sync # push events.txt to the remote server
cal serve # run the HTTP server (HTML view + ICS feed)
cal gen-token # print a fresh hex token
cal posta-keygen # print a fresh Ed25519 seed + key id for Posta
Adding meetings
cal add today 14:30 Standup
cal add tomorrow 9 1:1 with Alice
cal add wednesday 10 Design review +30m
cal add wed+1 10 Design review next week
cal add 2026-05-20 13 Quarterly planning +2h
cal add +3d 16 Coffee chat
<date> accepts:
| Form | Example | Meaning |
|---|---|---|
today |
today |
Today |
tomorrow |
tomorrow |
Tomorrow |
| weekday | mon, monday |
Next occurrence of that weekday (today included) |
| weekday + N | wed+1, fri+2 |
Next occurrence + N weeks |
+Nd |
+3d |
N days from today |
YYYY-MM-DD |
2026-05-20 |
Absolute |
MM-DD |
05-20 |
Absolute, current year |
<time> is 24-hour: HH or HH:MM (e.g. 10, 14:30).
<title...> is the rest of the args. Add an optional duration as a trailing token:
+30m— preferred on the CLI (no shell quoting needed).#30m— equivalent, but the shell will swallow it as a comment unless quoted.
Default duration is 1 hour.
Overlapping meetings produce a stderr warning but are still written.
Viewing
cal prints up to 5 upcoming events, grouped under day headers. cal week prints all 7 calendar days starting today; empty days show a faint date header with no events.
In-progress meetings are marked with a cyan → between the time and the title (10:00 → Standup).
Day headers are colored: Mon–Fri green, Sat orange, Sun red.
cal next prints the soonest event as two lines — the relative countdown first, then the event itself — for easy use in a tmux status line:
$ cal next
in 2h 13m
14:00 Design review with the team
Files
Both files live under ~/.config/cal/:
events.txt— upcoming meetings, sorted chronologically.archive.txt— appended on every command with anything that's now in the past.
Line format
2026-05-04@10:00 Standup
2026-05-04@10:00 Quick sync #30m
2026-05-04@14:00 Design review with the team
- Date and time joined by
@. - Duration suffix
#<go-duration>is optional; default is 1h. - Title is the rest of the line, minus a trailing
#<duration>if it parses. - Lines starting with
#are comments. Blank lines are ignored.
The file is rewritten and re-sorted on every change. You can hand-edit it; cal edit opens it in $EDITOR.
Auto-archive
Before every mutation (cal add, cal edit, cal sync), meetings whose end time has passed are appended to archive.txt and removed from events.txt. The append happens before the rewrite so meetings can never be dropped on the floor by a partial failure. Reads never write — cal, cal week, and cal next render straight from the local file with no network round-trip and no archive churn.
Server (HTML view + ICS feed)
The same binary also runs a small HTTP server that publishes the calendar as a subscribable iCalendar feed and a browser-friendly HTML week view.
Endpoints
With CAL_FEED_TOKEN set (read endpoints behind a path token):
GET /events return events.txt (auth: Authorization: Bearer ...)
PUT /events replace events.txt (auth: Authorization: Bearer ...)
GET /<feed-token>/ HTML week view
GET /<feed-token>/feed.ics iCalendar feed
With CAL_FEED_TOKEN unset (foomo mode — fear of others missing out — read endpoints are public):
GET /events return events.txt (auth: Authorization: Bearer ...)
PUT /events replace events.txt (auth: Authorization: Bearer ...)
GET / HTML week view
GET /feed.ics iCalendar feed
GET and PUT /events always require CAL_TOKEN. The CLI uses GET /events to pull before each mutation and PUT /events to push afterwards.
Running the server
cal gen-token # produces a fresh hex token
cal serve # listens on :$CAL_PORT
Server env:
| Variable | Default | Purpose |
|---|---|---|
CAL_PORT |
8080 |
listen port |
CAL_TOKEN |
(required) | bearer token for PUT /events |
CAL_FEED_TOKEN |
(optional) | path token for read endpoints; unset → public |
CAL_DATA |
~/.config/cal/events.txt |
events file the server reads/writes |
The server speaks plain HTTP. Put Caddy or nginx in front for TLS (e.g. cal.example.com).
Mutating from the CLI
Every mutation (cal add, cal edit, cal sync) follows the same order when a remote is configured:
- Pull the current
events.txtfrom the server (replaces the local file). - Purge and archive events whose end time has passed.
- Apply the change and save locally.
- Push the result back to the server.
Pulling first means a cal add on machine B can't silently overwrite an event added on machine A — B sees A's events before appending its own. The server PUT is atomic, so a partial network failure can't leave the remote in a half-written state.
Client env:
| Variable | Purpose |
|---|---|
CAL_SERVER |
base URL of the server (e.g. https://cal.example.com) |
CAL_TOKEN |
the same value as on the server |
If the push fails (network blip, server down), the local file already has your change saved — but the remote is now stale. The CLI exits non-zero with the underlying error. Re-run cal sync once the server is reachable to roundtrip the merge.
Reads never touch the network: cal, cal week, and cal next always render from the local file. That makes them safe to call from a tmux status line that ticks every couple of seconds.
Posta inbox (optional)
The server can also speak Posta on /. When all three
of CAL_POSTA_URL, CAL_POSTA_KEY_ID, and CAL_POSTA_SECRET are set, the
root URL serves an actor document on GET / with Accept: application/posta+json
and accepts signed messages on POST / with Content-Type: application/posta+json.
HTML and /feed.ics continue to work unchanged.
Inbound messages are restricted: the sender's URL host must equal
CAL_POSTA_PEER (default arne.posta.no). Replies are sent back to that same
sender; no other recipient is ever reachable. Both inbound and outbound payloads
use posta.text/v1, with body carrying a cal command line and the reply text:
{"kind":"posta.text/v1","body":"week"}
{"kind":"posta.text/v1","body":"add tomorrow 9 meeting with joe"}
Supported commands: week, next, add <date> <time> <title...>. Replies for
week/next are plain-text renderings of the same views the CLI prints (with
ANSI/Unicode disabled); add echoes the canonical event line.
Generate keys and drop them into the server's environment:
$ cal posta-keygen
CAL_POSTA_KEY_ID=2026-05-12
CAL_POSTA_SECRET=4f7e...base64-32-bytes...==
Subscribe URL
With CAL_FEED_TOKEN set:
https://cal.example.com/<CAL_FEED_TOKEN>/feed.ics
Without it (foomo mode):
https://cal.example.com/feed.ics
Apple Calendar, Google Calendar, Outlook, Thunderbird all accept this as a subscription URL. Polling cadence is set by the calendar app (typically 1h–24h).
Releasing & deploying
Build a release
make linux # builds cal-linux-amd64 with version baked in via -ldflags
make release # tags v$(VERSION), pushes tag, creates Forgejo release with asset
Bump Version in cmd/cal/main.go and re-run make release to ship a new version.
First-time deploy
The deploy/ folder ships templates: an OpenRC init script (cal.initd), the env-vars file (cal.confd), and a Caddy snippet.
On the host (one running Incus and Caddy):
# create container and proxy
incus launch images:alpine/3.20 cal
incus exec cal -- apk add --no-cache ca-certificates curl
incus exec cal -- adduser -D -H -s /sbin/nologin cal
incus config device add cal http proxy \
listen=tcp:127.0.0.1:8081 connect=tcp:127.0.0.1:8080
# install service files
incus file push deploy/cal.initd cal/etc/init.d/cal
incus exec cal -- chmod 0755 /etc/init.d/cal
incus file push deploy/cal.confd cal/etc/conf.d/cal
incus exec cal -- chmod 0600 /etc/conf.d/cal
# generate token(s) and edit /etc/conf.d/cal to set them
cal gen-token # CAL_TOKEN — required
cal gen-token # CAL_FEED_TOKEN — optional; unset = foomo mode (public reads)
incus exec cal -- vi /etc/conf.d/cal
# fetch the binary from the latest Forgejo release
incus exec cal -- sh -c \
'curl -fsSL https://code.bas.es/arne/cal/releases/download/latest/cal-linux-amd64 \
-o /usr/local/bin/cal && chmod 0755 /usr/local/bin/cal'
# enable + start
incus exec cal -- rc-update add cal default
incus exec cal -- rc-service cal start
Then append deploy/Caddyfile.snippet to /etc/caddy/Caddyfile and caddy reload.
Updating the deployed binary
After cutting a new release with make release:
incus exec cal -- /usr/local/bin/cal update
The running binary fetches the latest release asset from upstream Forgejo, validates it's an ELF, atomically replaces itself, and exits. OpenRC's supervise-daemon respawns it with the new code. No SSH dance; no service file edits.
Laptop config (environment.d)
~/.config/environment.d/cal.conf:
CAL_SERVER=https://cal.example.com
CAL_TOKEN=<from setup.sh output>
Re-login to pick up the env vars, or set -x them in fish for the current session.
Layout
cmd/cal/main.go CLI entry point (cobra + fang)
internal/parse/ line and date/time parsers
internal/calendar/ Calendar aggregate — Local file, optional Remote, archive
internal/agenda/ Upcoming / Week / Soonest projections of Events
internal/view/ terminal renderers (lipgloss)
internal/htmlview/ HTML week view (server)
internal/ics/ iCalendar marshaller (server)
internal/server/ HTTP handlers
deploy/ OpenRC service, Caddyfile snippet, setup.sh
Makefile build, test, release