nostr event kinds
A flat file of every nostr event kind, and fzf over it, because
nobody remembers that long-form content is 30023.
The demo
17 seconds, four lookups. It starts with the whole file and narrows as each character lands — 278 → 153 → … → 2 — then three queries given up front: a bare number, a kind citing two NIPs, and a name.
Recorded straight off the tool with tui-test, played back by asciinema-player.
Output
Picking a kind prints key/value pairs rather than the raw line, which means
cut -f2 and grep ^link work on it. A kind citing two NIPs gets two
link lines; a kind whose spec lives outside the nips repo gets a nip
line and no link; and a kind that isn't NIP-specified at all gets just the first two fields.
$ kinds 10002 name Relay List Metadata kind 10002 nip 65, 51 link https://github.com/nostr-protocol/nips/blob/master/65.md link https://github.com/nostr-protocol/nips/blob/master/51.md $ kinds 443 name KeyPackage kind 443 nip Marmot # not in the nips repo, so no link $ kinds 3063 name Software Asset kind 3063 # registry-only, not NIP-specified
Browse
Fuzzy, the way the tool is: the letters have to turn up in order but not
together, so longform finds Long-form Content. Numbers sort
to the top. Click a row to copy it.
It is one text file, so grep 30023 kinds.txt and
fzf < kinds.txt work just as well without any of this.
87 of them carry no NIP reference — those come from the registry
rather than from a NIP.
The scripts
Two files and a text file. Drop them in a directory, put it on your PATH, and
run kinds -u once to fetch the data.
#!/usr/bin/env bash
# kinds -- find a nostr event kind.
#
# kinds browse them all
# kinds longform search
# kinds 30023 the other direction
# kinds -u refresh kinds.txt from the nips repo
set -euo pipefail
# resolve symlinks, so `ln -s .../kinds ~/bin/kinds` still finds kinds.txt
self=${BASH_SOURCE[0]}
while [ -L "$self" ]; do
link=$(readlink "$self")
case $link in
/*) self=$link ;;
*) self=$(dirname "$self")/$link ;;
esac
done
here=$(cd "$(dirname "$self")" && pwd)
if [ "${1:-}" = -u ]; then
exec "$here/kinds-update"
fi
# --no-exact: FZF_DEFAULT_OPTS is -e here, and exact matching means "longform"
# never finds "Long-form Content"
# --tiebreak: fzf's default tiebreak is by match length, which for a query like
# `kinds 1` ranks every equally-good match by how short the line is
# -- so kind 1 lands somewhere below 12473. `begin` floats matches
# in the kind column (start of line) to the top, and `index` then
# keeps them in kinds.txt order, which is numeric.
sel=$(fzf --no-exact --tiebreak=begin,index \
--height=40% --reverse --select-1 --exit-0 --query="$*" < "$here/kinds.txt") || exit
# kinds.txt is fixed-column (see the printf in kinds-update), so split the
# selected line back apart by offset -- exact, unlike guessing at the runs of
# padding, which breaks on the descriptions long enough to fill their column.
awk -v OFS='\t' '{
print "name", trim(substr($0, 15, 45))
print "kind", trim(substr($0, 1, 13))
refs = trim(substr($0, 61))
if (refs == "") exit
nip = refs; gsub(/NIP-/, "", nip)
print "nip", nip
# NIP-23 is 23.md upstream. A few lines cite two NIPs, some carry a
# trailing (deprecated), and some cite a non-NIP spec with no link at all.
while (match(refs, /NIP-[0-9A-Z]+/)) {
print "link", "https://github.com/nostr-protocol/nips/blob/master/" \
substr(refs, RSTART + 4, RLENGTH - 4) ".md"
refs = substr(refs, RSTART + RLENGTH)
}
}
function trim(s) { gsub(/^ +| +$/, "", s); return s }' <<<"$sel"
#!/usr/bin/env bash
# Regenerate kinds.txt from the nostr NIPs repo and the registry-of-kinds.
#
# Two upstream sources, merged:
# nips/README.md -- the "Event Kinds" table; has NIP references, and covers
# kind *ranges* (9000-9030, 1630-1633, ...)
# registry-of-kinds/schema.yaml
# -- machine-readable registry; wider coverage, no NIP refs
#
# The NIPs table wins on description where both know a kind. Parsed with awk
# rather than a YAML library: the `kinds:` section is two flat lines per entry,
# so there's nothing worth taking a dependency for.
set -euo pipefail
NIPS_URL=${NIPS_URL:-https://raw.githubusercontent.com/nostr-protocol/nips/master/README.md}
REGISTRY_URL=${REGISTRY_URL:-https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/master/schema.yaml}
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
out=$here/kinds.txt
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
fetch() {
curl -sSfL "$1" -o "$2" || { echo "kinds-update: failed to fetch $1" >&2; exit 1; }
}
echo "fetching nips README..." >&2
fetch "$NIPS_URL" "$tmp/README.md"
echo "fetching registry schema..." >&2
fetch "$REGISTRY_URL" "$tmp/schema.yaml"
# --- nips README.md: | `30023` | Long-form Content | [23](23.md) | ------------
awk -F'|' '
/^## Event Kinds/ { inkinds = 1; next }
inkinds && /^## / { inkinds = 0 }
!inkinds || !/^\|/ { next }
function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); return s }
{
kind = trim($2); desc = trim($3); nip = trim($4)
gsub(/`/, "", kind)
if (kind !~ /^[0-9]+(-[0-9]+)?$/) next # skips the header rule and
# the message-type tables
# `39000-9` is shorthand for 39000-39009: re-attach the elided prefix
if (split(kind, r, "-") == 2 && length(r[2]) < length(r[1]))
kind = r[1] "-" substr(r[1], 1, length(r[1]) - length(r[2])) r[2]
# nip column: collect [label]s, "51" -> NIP-51, dedupe repeats like
# [nostrocket][nostrocket], and keep a trailing (deprecated)
refs = ""; s = nip
while (match(s, /\[[^]]*\]/)) {
lab = substr(s, RSTART + 1, RLENGTH - 2)
s = substr(s, RSTART + RLENGTH)
if (lab ~ /^[0-9A-F]+$/) lab = "NIP-" lab
if (lab != "" && index(" " refs " ", " " lab ",") == 0 && refs !~ ("(^|, )" lab "$"))
refs = refs (refs ? ", " : "") lab
}
if (nip ~ /deprecated/) refs = refs (refs ? " " : "") "(deprecated)"
print kind "\t" desc "\t" refs
}
' "$tmp/README.md" > "$tmp/nips.tsv"
# --- registry schema.yaml: " 30023:" then " description: ..." -------------
awk '
/^kinds:/ { inkinds = 1; next }
inkinds && /^[a-z_]/ { inkinds = 0 }
!inkinds { next }
/^ [0-9]+:$/ { k = $1; sub(/:$/, "", k); next }
/^ description: / && k != "" {
sub(/^ description: */, "")
print k "\t" $0 "\t"
k = ""
}
' "$tmp/schema.yaml" > "$tmp/registry.tsv"
# --- merge: nips first so it wins the dedupe, then sort numerically -----------
# the numeric sort is load-bearing: `kinds` ranks fzf ties by --tiebreak=index,
# i.e. by position in this file, which is what makes `kinds 1` find kind 1.
cat "$tmp/nips.tsv" "$tmp/registry.tsv" \
| awk -F'\t' '!seen[$1]++' \
| sort -s -t$'\t' -k1,1n > "$tmp/merged.tsv"
# the registry has no NIP column, but the nips table describes whole ranges --
# so a registry-only kind inside one of those ranges inherits its reference
awk -F'\t' -v OFS='\t' '
NR == FNR {
if (split($1, r, "-") == 2) { n++; lo[n] = r[1] + 0; hi[n] = r[2] + 0; ref[n] = $3 }
next
}
{
if ($3 == "" && $1 !~ /-/)
for (i = 1; i <= n; i++)
if ($1 + 0 >= lo[i] && $1 + 0 <= hi[i]) { $3 = ref[i]; break }
print
}
' "$tmp/nips.tsv" "$tmp/merged.tsv" \
| awk -F'\t' '{
# fixed columns, and the description truncated to fit one, so that
# `kinds` can split a selected line back apart by offset alone. One
# upstream description is 108 chars (a Hitchwiki URL) and three others
# overflow 33, which is what used to collapse the gap before the NIP
# column down to a single space and make the line ambiguous to parse.
d = $2
if (length(d) > 45) d = substr(d, 1, 44) "+"
printf "%-13s %-45s %s\n", $1, d, $3
}' \
| sed 's/[ \t]*$//' > "$tmp/kinds.txt"
if [ ! -s "$tmp/kinds.txt" ]; then
echo "kinds-update: parsed 0 kinds, refusing to clobber $out" >&2
exit 1
fi
before=$([ -f "$out" ] && wc -l < "$out" || echo 0)
mv "$tmp/kinds.txt" "$out"
printf 'wrote %s (%d kinds, was %d)\n' "$out" "$(wc -l < "$out" | tr -d ' ')" "$before" >&2