No description
  • Go 76.3%
  • CSS 8.2%
  • HTML 7.8%
  • JavaScript 6.6%
  • Makefile 0.7%
  • Other 0.4%
Find a file
2026-05-12 22:03:33 +00:00
cmd/cal bump version to 0.0.9 2026-05-12 22:03:33 +00:00
deploy Add Posta inbox/actor on the server root 2026-05-12 21:33:50 +00:00
internal posta: fix posta.text/v1 payload shape 2026-05-12 21:42:18 +00:00
web-site Add static homepage; refresh README to match current architecture (#2) 2026-05-06 16:45:03 +02:00
.gitignore Initial commit — cal v0.0.1 2026-05-05 18:29:51 +00:00
CONTEXT.md Architecture pass: extract Calendar and Agenda modules (#1) 2026-05-05 23:41:15 +02:00
go.mod Architecture pass: extract Calendar and Agenda modules (#1) 2026-05-05 23:41:15 +02:00
go.sum Architecture pass: extract Calendar and Agenda modules (#1) 2026-05-05 23:41:15 +02:00
Makefile v0.0.2 2026-05-05 19:40:04 +00:00
README.md Add Posta inbox/actor on the server root 2026-05-12 21:33:50 +00:00

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: MonFri 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:

  1. Pull the current events.txt from the server (replaces the local file).
  2. Purge and archive events whose end time has passed.
  3. Apply the change and save locally.
  4. 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 1h24h).

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