cli · Sep 22, 2026
jcount: A CLI for JSON Lines in 42 Lines
Most log questions are 'how many of each'. A 42-line Node script answers them from the terminal, with flags, files or stdin, and no dependencies beyond the standard library.
I keep asking my logs the same question in different clothes: how many of each? Requests per path, errors per status, signups per plan. For a long time I answered with a pipeline of grep, cut, sort and uniq -c, which works until the lines are JSON and the field I want is buried in the middle.
So I wrote jcount. It's 42 lines, it has no dependencies, and I use it most days. It's also a decent template for any small CLI you want to keep tidy.
The whole thing
#!/usr/bin/env node
import { parseArgs } from "node:util";
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
key: { type: "string", short: "k" },
top: { type: "string", short: "n", default: "10" },
help: { type: "boolean", short: "h" },
},
});
if (values.help || !values.key) {
console.error("usage: jcount -k <key> [-n <top>] [file ...] (stdin if no files)");
process.exit(values.help ? 0 : 2);
}
const counts = new Map();
let bad = 0;
async function eat(stream) {
for await (const line of createInterface({ input: stream })) {
if (!line.trim()) continue;
try {
const v = JSON.parse(line)[values.key];
if (v !== undefined) counts.set(String(v), (counts.get(String(v)) ?? 0) + 1);
} catch {
bad++;
}
}
}
if (positionals.length === 0) await eat(process.stdin);
for (const f of positionals) await eat(createReadStream(f));
[...counts]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, Number(values.top))
.forEach(([k, n]) => console.log(String(n).padStart(6), k));
if (bad) console.error(`${bad} unparseable line(s) skipped`);Save it as jcount.mjs and make it executable. The .mjs extension is what lets it use import and top-level await without a build step.
Why it's shaped this way
Arguments first, work second. parseArgs comes from Node's standard library and does short flags, defaults and positional arguments. The usage check sits straight after it, so a mistake costs no time and prints a message to stderr, not stdout. Exit code 2 for misuse, 0 for a deliberate --help, so a script can tell them apart.
Streams, not files in memory. readline over a stream reads one line at a time, so a multi-gigabyte log is no harder than a short one. The same eat function takes a file stream or stdin, which is why the tool works in a pipe.
Bad lines are counted, not fatal. Real logs contain truncated writes and stray text. A parser that dies on the first one is a parser you stop using. Here the bad line is skipped and reported once at the end, on stderr, where it can't contaminate the output you're piping onward.
Deterministic output. Ties sort by name. The same input always gives the same order, which makes the output safe to diff and to put in a test.
Running it
Here's the eight-line file I tested with, access.jsonl: one deliberately broken line, and one record with no status.
{"path":"/","status":200}
{"path":"/pricing","status":200}
{"path":"/api/export","status":500}
not json at all
{"path":"/","status":200}
{"path":"/api/export","status":500}
{"path":"/pricing","status":200}
{"path":"/"}jcount -k path access.jsonl printed 3 /, then 2 /api/export and 2 /pricing, followed by a note that one line was skipped (the real output is right-aligned in a six-character column). Feeding the same file on stdin with -k status -n 2 printed 4 200 and 2 500; the record without a status simply isn't counted. Running it with no arguments printed the usage line and exited with status 2.
Where I'd take it
Nested keys with a dot path, --where status=500 to filter, and a --json switch for the output. Error handling is also thin: an unknown flag or a missing file ends in a raw Node stack trace, which I tolerate in a tool only I use. I'll add each when I need it. At 42 lines I can still hold the whole tool in my head, and that's the feature I care about most.
No comments yet
Comments are open. Have a thought or a question? Share it below.