Atproto and the Command Line

Categories: Technology

I’m a very old-school UNIX geek; I’m capable of writing Python code using the ATProto library to get at my records, but I like iterating over shell commands too. So I embarked on a journey with the goal of doing something useful with my Bookhive ATProto records. I have two use cases; the first is generating a list of books I want to read with links to my local library search page, for ease of borrowing, and the second is posting to Bluesky every time I write a review. I wanna document because otherwise I’ll wind up with a shell script that I don’t remember building, and maybe someone will enjoy the peek into how I worked through this. Anyhow, let’s see how far I get in one post.

Searching for atproto pds cli gets me a bunch of results, including three CLIs and a few Personal Data Server implementations. The obvious choice for the CLI is the one Bluesky wrote, goat. It’s likely to be well-supported since Bluesky is invested in ATProto, to put it mildly, and it has a relatively long development history. Also it’s available via Homebrew which I prefer. So I install it with a quick brew install goat. Let’s start with the README.

goat resolve innocence.com seems like a good start. That’s my ATProto handle. That gets me back a JSON blob, most of which is more or less meaningless to me, but it’s working!

I follow up with goat ls -c innocence.com. Ah, yeah, that’s my list of collections. I see buzz.bookhive.book in there, as well as my own com.innocence.atproto.commenterTag (which is a total hack I slapped together so I could use Bluesky as the database for my newspaper commenter tagging userscript). Cool.

That’s about as far as the README is gonna take me. It claims that goat is self-documenting; playing around with –help a bit gets me to goat ls --collection buzz.bookhive.book innocence.com. I get 18 lines like this:

buzz.bookhive.book 3mzse7c5um2qz bafyreiebhw6d5twwzzljcge56aajf2bgrvtvz5zrhuzp65td6fggiuagt4

And I have 18 total books listed in Bookhive, so that’s promising. Let’s look at goat get --help to figure out how I get from there to a full record. It says I want goat get [options] <at-uri>. If I were an experienced ATProto geek I’d know how to derive an at-uri from the output above, but I’m just gonna experiment.

Can’t be any of the bare fields – a URI needs a prefix, like https://. Back to the README, which has goat get at://dril.bsky.social/app.bsky.feed.post/3kkreaz3amd27 as another example command. OK, so:

goat get at://innocence.com/buzz.bookhive.book/3mzse7c5um2qz

Yep!

{
  "$type": "buzz.bookhive.book",
  "authors": "Sarah Maria Griffin",
  "cover": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreicby77oouyczkgdafnunmteleio4hzzvwqjdcmxuawkwa7pifnvqq"
    },
    "mimeType": "image/jpeg",
    "size": 130135
  },
  "createdAt": "2026-11-08T11:56:52.328Z",
  "hiveBookUri": "at://did:plc:enu2j5xjlqsjaylv3du4myh4/buzz.bookhive.catalogBook/bk_qyEFC4dmgtR9oLNwq5rR",
  "hiveId": "bk_qyEFC4dmgtR9oLNwq5rR",
  "identifiers": {
    "goodreadsId": "244947367",
    "hiveId": "bk_qyEFC4dmgtR9oLNwq5rR"
  },
  "owned": true,
  "status": "buzz.bookhive.defs#wantToRead",
  "title": "Strange Is the Light"
}

So that’s interesting. The first record returned in the list is the last book I entered into Bookhive. I don’t think I’m guaranteed any particular sorting order here; if I want to sort I’m gonna have to do it myself. This also has implications for my posting use case; to avoid pulling a million billion records every time, I will need to save the IDs of records I’ve already posted and only retrieve new ones.

But that’s tomorrow’s thought. Today, let’s just make sure we understand how to turn that JSON into something useful.

goat get at://innocence.com/buzz.bookhive.book/3mzse7c5um2qz | jq -r '"\(.title) by \(.authors)"'

That produces: "Strange Is the Light by Sarah Maria Griffin". Lovely.

OK, let’s move over to the library side. I want to generate links to search pages, so I just go over to my web browser and… do a search. Then I remove query parameters from the URL until I get the minimum that’ll work. Short story shorter, I go from https://sppl.bibliocommons.com/v2/search?custom_edit=false&query=title%3A(Strange%20is%20the%20Light)&searchType=bl&suppress=true to https://sppl.bibliocommons.com/v2/search?query=title%3A(Strange%20is%20the%20Light)&searchType=bl.

I’m not sure why, but when I remove the searchType parameter it defaults to a Music & Sound search. Cool that it returns a big Billie Holiday CD collection but not what I’m looking for.

The keen-eyed may notice I didn’t add the author to the search. That’s because this doesn’t have to be accurate to be useful, just mostly accurate, so I’m willing to wade through a few false positives, and because I noticed the original ATProto field was named “authors” plural. I don’t want to build this and then have searches start failing when I enter a book with multiple authors, by which time I’ll have forgotten that I was being sloppy and casual about that particular edge case. If I was a sophisticated engineer I’d call this “architecture.”

The other thing here is that I’m gonna have to escape the title to turn spaces into %20 and so on. This is exactly the point at which I think maybe I should write this in Python. But no, I’m not afraid, I just slam cli unix url escape into my search engine and apparently jq will handle this for me? Neat. Over-engineered but neat.

At this point it’s about time to stop with stringing commands together and migrate this into a proper bash script. After another half an hour or so of messing around and building this up one chunk at a time:

#!/bin/bash

GOAT=/opt/homebrew/bin/goat
USER=innocence.com
COLLECTION=buzz.bookhive.book
TEMPLATE=~/etc/bookhive-library.j2

# No idea why I can't just pipe the output from the for loop to j2 but it
# fails that way and works when I have a temp file step in the middle, so.
TMPFILE=$( mktemp )

${GOAT} ls --collection ${COLLECTION} ${USER} | while read -r book; do
 id=$( echo ${book} | awk '{ print $2 }' )
 record=$( ${GOAT} get at://${USER}/${COLLECTION}/${id} )
 if [[ $( echo ${record} | jq -r '.status' ) == "buzz.bookhive.defs#wantToRead" ]]; then
  echo ${record} | jq '{title: .title, author: .authors, url: @uri "https://sppl.bibliocommons.com/v2/search?query=title:(\(.title))&searchType=bl"}'
 fi
done | jq -s '{books: .}' > ${TMPFILE}
uvx --from jinjanator j2 -f json ${TEMPLATE} ${TMPFILE}

rm ${TMPFILE}

jinjanator is a command line package that saves me from writing 10 lines of Python every time I want to do something with Jinja templates. New tool to me; one reason I do this sort of exercise is to find useful new tools. Really easy to use.

And for completeness, here’s the quick template I wrote. I may or may not polish this with CSS and so on but it’s functional now which is what I care about. Future improvements also would include deeplinking into Libby/Overdrive for ebook loans.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Bryant's TBR Pile</title>
  </head>
  <body>
    <h1>Bryant's TBR Pile</h1>
    <ul>
      {% for book in books %}
      <li>
          <a href="{{ book.url }}"><cite>{{ book.title }}</cite></a> ({{ book.author }})
      </li>
      {% endfor %}
  </body>
</html>