nostr event kinds

kinds

A flat file of every nostr event kind, and fzf over it, because nobody remembers that long-form content is 30023.

$ kinds longfor name Long-form Content kind 30023 nip 23 link https://github.com/nostr-protocol/nips/blob/master/23.md

The demo

Type a few letters, get the number

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.

Terminal recording: typing longfor into kinds narrows 278 event kinds down to two, then Enter prints kind 30023 with its NIP link.

Recorded straight off the tool with tui-test, played back by asciinema-player.

Output

Tab-separated, so it composes

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

All 278 of them, right here

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

    Both of them, in full

    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.

    kinds the lookup — fzf, then split the chosen line into fields
    #!/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"
    kinds-update regenerates kinds.txt from both upstream sources
    #!/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