
Calendar Files | SSH Authorized Keys | Headerless CSV and TSV
In Part 1 I covered using jc as a subnet calculator, for exploring X.509 certificates, and for converting timestamps. In Part 2 we looked at URLs, POSIX paths, and git log output. I promised more to come, and it took a bit longer than planned! The good news is that jc has picked up a lot of new parsers in the meantime, so there is plenty to talk about.
This time the theme is files you might not realize you can turn into JSON:
- iCalendar (
.ics) files - SSH
authorized_keysfiles - CSV and TSV files without a header row
Calendar Files to JSON
Just about every calendar app can export or subscribe to iCalendar (.ics) files: Google Calendar, Outlook, Apple Calendar, on-call schedulers, project trackers, you name it. The format itself is a bit of a throwback, with folded lines, BEGIN/END blocks, and property parameters jammed in with semicolons. It is not something you want to grep your way through.
New in jc v1.26.1 is the --ics parser (thanks to Paolo Valletta for the contribution!). Here is a small work calendar run through it:
% cat work.ics | jc --ics --pretty
[
{
"type": "VEVENT",
"uid": "standup-001@example.com",
"summary": "Team Standup",
"location": "Zoom",
"dtstart": "2026-10-19T09:00:00",
"dtstart_tzid": "America/Los_Angeles",
"dtend": "2026-10-19T09:15:00",
"dtend_tzid": "America/Los_Angeles",
"dtstamp": "2026-10-01T12:00:00Z",
"rrule": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
},
{
"type": "VEVENT",
"uid": "review-002@example.com",
"summary": "Design Review: jc v1.27 roadmap",
"location": "Conference Room B",
"dtstart": "2026-10-19T20:00:00Z",
"dtend": "2026-10-19T21:00:00Z",
"dtstamp": "2026-10-01T12:00:00Z",
"categories": [
"Engineering",
"Planning"
],
"organizer": {
"name": "Alice Smith",
"address": "alice@example.com"
},
"attendees": [
{
"name": "Bob Jones",
"address": "bob@example.com",
"role": "REQ-PARTICIPANT",
"partstat": "ACCEPTED"
},
{
"name": "Carol White",
"address": "carol@example.com",
"role": "OPT-PARTICIPANT",
"partstat": "TENTATIVE",
"rsvp": true
}
],
"alarms": [
{
"action": "DISPLAY",
"trigger": "-PT15M",
"description": "Design review in 15 minutes"
}
]
},
<snip>
]
There is a lot of nice cleanup happening here. The mailto: prefixes are stripped from addresses, the CN parameter becomes a name field, RSVP=TRUE becomes a boolean, and the compact iCalendar timestamps (20261019T200000Z) are normalized to ISO 8601. If an event carries a timezone, you get it in a separate _tzid field instead of having to pick it out of the property parameters. Need the original values? Use --raw (or -r):
% cat work.ics | jc --ics --raw | jq '.[0] | {dtstart, dtstart_tzid}'
{
"dtstart": "20261019T090000",
"dtstart_tzid": "America/Los_Angeles"
}
Now the fun part. What’s on the calendar today?
% cat work.ics | jc --ics | jq -r '.[] | select(.dtstart | startswith("2026-10-19")) | "\(.dtstart) \(.summary)"'
2026-10-19T09:00:00 Team Standup
2026-10-19T20:00:00Z Design Review: jc v1.27 roadmap
Who is coming to the design review, and who hasn’t committed yet?
% cat work.ics | jc --ics | jq -r '.[] | select(.attendees) | .attendees[] | "\(.name) <\(.address)> \(.partstat)"' Bob Jones <bob@example.com> ACCEPTED Carol White <carol@example.com> TENTATIVE
Categories come through as an array, so filtering by tag is easy:
% cat work.ics | jc --ics | jq -r '.[] | select(.categories[]? == "On-call") | "\(.dtstart) → \(.dtend) \(.summary)"' 2026-10-20 → 2026-10-27 On-call: primary
Here’s one I really like for automation. Since timestamps in UTC come out in a form that jq‘s fromdateiso8601 understands, you can check whether a given moment falls inside a change-freeze window on a shared calendar:
% cat work.ics | jc --ics | jq --arg t 2026-11-25T12:00:00Z '
($t | fromdateiso8601) as $ts
| [.[] | select(.categories[]? == "Freeze")
| select((.dtstart | fromdateiso8601) <= $ts and $ts < (.dtend | fromdateiso8601))]
| length > 0'
true
Swap $ts for jq‘s built-in now and you have the start of a deploy guard that refuses to ship during a freeze.
A couple of things to keep in mind:
- Fields are only included when they are present in the file, so use
select(.attendees)or the[]?operator to skip events that don’t have them. - Recurrence rules are passed through in the
rrulefield but not expanded into individual occurrences, so the weekly standup above shows up once with its first date. VTODOandVJOURNALentries are parsed too. Check thetypefield to tell them apart.
Auditing SSH Authorized Keys
The ~/.ssh/authorized_keys file is one of those things that tends to accumulate cruft over the years. A key for a laptop you retired, a backup job someone set up in 2019, a CI runner that may or may not still exist. Auditing these by eye gets old fast, especially when the lines are 700 characters long and the interesting options are crammed in front of the key.
The --authorized-keys parser (added in v1.26.0) splits each line into its options, key type, key, and comment. Comment lines and blank lines are skipped:
% cat ~/.ssh/authorized_keys | jc --authorized-keys --pretty
[
{
"options": [],
"type": "ssh-ed25519",
"key": "AAAAC3NzaC1lZDI1NTE5AAAAIOslpT5sYNulTcWS...",
"comment": "kelly@laptop"
},
{
"options": [
"command=\"/usr/bin/rsync --server --sender -logDtpre.iLsfxC . /srv/backup\"",
"no-port-forwarding",
"no-pty"
],
"type": "ssh-rsa",
"key": "AAAAB3NzaC1yc2EAAAADAQABAAABAQCzdBGMPnTe...",
"comment": "backup@nas"
},
{
"options": [
"from=\"10.0.8.0/24\"",
"no-agent-forwarding"
],
"type": "ecdsa-sha2-nistp256",
"key": "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlz...",
"comment": "ci-runner@build01"
},
{
"options": [],
"type": "ssh-ed25519",
"key": "AAAAC3NzaC1lZDI1NTE5AAAAIKTRaGyUTxRiVchn...",
"comment": null
}
]
Note: I truncated the key values to keep things readable.
Notice the options field is always an array, even when there are no options. That makes the jq queries much simpler. Also notice that the quoted command= value with spaces in it is kept intact. Splitting that correctly with cut or awk is not fun.
Let’s get a quick inventory of key types:
% cat ~/.ssh/authorized_keys | jc --authorized-keys | jq -r 'group_by(.type)[] | "\(length) \(.[0].type)"' 1 ecdsa-sha2-nistp256 2 ssh-ed25519 1 ssh-rsa
If you are migrating away from older key types, here’s how to find RSA and DSA keys that are still hanging around:
% cat ~/.ssh/authorized_keys | jc --authorized-keys | jq -r '.[] | select(.type == "ssh-rsa" or .type == "ssh-dss") | .comment // "(no comment)"' backup@nas
Here’s a more interesting security question: which keys have no restrictions at all? That is, no from= source restriction and no forced command=:
% cat ~/.ssh/authorized_keys | jc --authorized-keys | jq -r '.[] | select(any(.options[]; startswith("from=") or startswith("command=")) | not) | "\(.type) \(.comment // "(no comment)")"'
ssh-ed25519 kelly@laptop
ssh-ed25519 (no comment)
That second one is worth a closer look. An unrestricted key with no comment is exactly the kind of thing that sticks around for years because nobody remembers what it’s for.
And for the keys that are restricted, you can review what they’re allowed to do:
% cat ~/.ssh/authorized_keys | jc --authorized-keys | jq -r '.[] | select(.options | length > 0) | "\(.comment):\n \(.options | join("\n "))"'
backup@nas:
command="/usr/bin/rsync --server --sender -logDtpre.iLsfxC . /srv/backup"
no-port-forwarding
no-pty
ci-runner@build01:
from="10.0.8.0/24"
no-agent-forwarding
This really shines when you look across all the users on a system. A quick loop tags each key with its owner, then jq --slurp combines them so you can query the whole box at once. Here we find the same key authorized for more than one user:
% for f in /home/*/.ssh/authorized_keys; do
u=$(echo "$f" | cut -d/ -f3)
jc --authorized-keys < "$f" | jq -c --arg u "$u" '.[] | . + {user: $u}'
done | jq -s -r 'group_by(.key)[] | select(length > 1) | "\(.[0].comment // "(no comment)"): \(map(.user) | join(", "))"'
backup@nas: alice, bob
Swap the last jq filter for any of the queries above and you have a fleet-friendly audit. Combine it with your favorite remote execution tool and the output is already JSON, ready to ship to a SIEM or a dashboard.
Headerless CSV and TSV Files
jc has had a CSV parser for a long time, but it assumes the first row is a header. In the real world, that’s often not the case. Log exports, appliance dumps, and database queries run with mysql -B -N all give you rows of data with no column names. Treat the first row as a header and you lose a row of data and end up with nonsense key names.
Version 1.25.7 added a family of parsers to cover these cases (thanks to Gary Gurlaskie for the contribution!):
--csv-ih: CSV without a header row--tsv: tab-separated files with a header row--tsv-ih: tab-separated files without a header row
The -ih stands for implicit header. Instead of reading names from the first row, jc generates them for you: c0, c1, c2, and so on. Here’s a headerless web access export:
% cat access.csv
2026-10-19 08:01:12,web01,200,GET,/index.html,512
2026-10-19 08:01:13,web02,404,GET,/favicon.ico,0
2026-10-19 08:01:15,web01,500,POST,/api/login,128
2026-10-19 08:01:19,web03,200,GET,/docs/,2048
% cat access.csv | jc --csv-ih --pretty
[
{
"c0": "2026-10-19 08:01:12",
"c1": "web01",
"c2": "200",
"c3": "GET",
"c4": "/index.html",
"c5": "512"
},
{
"c0": "2026-10-19 08:01:13",
"c1": "web02",
"c2": "404",
"c3": "GET",
"c4": "/favicon.ico",
"c5": "0"
},
<snip>
]
Like the regular CSV parser, --csv-ih will try to detect the delimiter for you and falls back to a comma if it can’t. All values come through as strings, so use tonumber in jq when you need to compare numerically. Here are the server errors:
% cat access.csv | jc --csv-ih | jq -r '.[] | select((.c2 | tonumber) >= 500) | "\(.c1) \(.c3) \(.c4)"' web01 POST /api/login
The generic c0, c1 names are fine for a quick look, but if you’re going to save or share the data you probably want real names. Here’s a handy jq trick that applies your own header list by position:
% cat access.csv | jc --csv-ih | jq -c '["time","host","status","method","path","bytes"] as $h | .[] | with_entries(.key |= $h[.[1:] | tonumber])'
{"time":"2026-10-19 08:01:12","host":"web01","status":"200","method":"GET","path":"/index.html","bytes":"512"}
{"time":"2026-10-19 08:01:13","host":"web02","status":"404","method":"GET","path":"/favicon.ico","bytes":"0"}
<snip>
The .[1:] strips the c from the key name, and the remaining number is used as an index into your header array.
The TSV parsers work the same way, just with a tab as the delimiter. Use --tsv when the file has a header and --tsv-ih when it doesn’t:
% cat hosts.tsv | jc --tsv-ih --pretty
[
{
"c0": "web01",
"c1": "10.0.8.11",
"c2": "nginx"
},
<snip>
]
Going big with streaming parsers
The parsers above load the whole file into memory before producing output. That’s fine for most files, but if you’re dealing with a multi-gigabyte export you’ll want the streaming versions: --csv-s, --csv-ih-s, --tsv-s, and --tsv-ih-s. These read the file lazily and output one JSON object per line (JSON Lines), so you can start processing right away and memory stays flat:
% cat access.csv | jc --csv-ih-s
{"c0":"2026-10-19 08:01:12","c1":"web01","c2":"200","c3":"GET","c4":"/index.html","c5":"512"}
{"c0":"2026-10-19 08:01:13","c1":"web02","c2":"404","c3":"GET","c4":"/favicon.ico","c5":"0"}
<snip>
Since each line is its own object, your jq filters drop the leading .[]:
% cat access.csv | jc --csv-ih-s | jq -r 'select(.c2 == "404") | .c4' /favicon.ico
This pairs nicely with tail -f for watching a growing export in real time. Just add the -u (unbuffered) option to jc so output isn’t held back.
Wrapping Up
That’s three more file types that jc can turn into clean, queryable JSON. Calendars, SSH keys, and messy spreadsheet exports might not seem like they have much in common, but they’re all formats that are easy for a human to glance at and annoying to parse in a script. That’s exactly the gap jc is meant to fill.
All of these parsers are available in jc v1.26.1, so grab the latest with pip install --upgrade jc or from the releases page. As always, you can see the full schema and options for any parser with jc -h --<parser-name>.
What files are you still parsing by hand? Let me know and maybe they’ll show up in Part 4!
By Kelly Brazil and Claude Opus 5.5