Ruby CLI tool for accessing Bluesky API / ATProto

Compare changes

Choose any two refs to compare.

Changed files
+446 -25
exe
rat
lib
ratproto
+11 -1
CHANGELOG.md
··· 1 - ## [Unreleased] 1 + ## [0.1.1] - 2026-01-03 2 + 3 + - fixed rat emoji in the help output :D 4 + 5 + ## [0.1] - 2026-01-03 6 + 7 + - cleaned up / rewritten the code 8 + 9 + ## [0.0.1] - 2026-01-02 10 + 11 + - first working proof of concept
+157 -17
README.md
··· 1 - # Ratproto 1 + # RatProto โ€“ Ruby ATProto Tool ๐Ÿ€ 2 2 3 - TODO: Delete this and the text below, and describe your gem 3 + RatProto (`rat`) is a small command-line tool in Ruby for accessing the Bluesky API / AT Protocol. 4 + 5 + It builds on top of the existing ATProto Ruby gems: 4 6 5 - Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/ratproto`. To experiment with that code, run `bin/console` for an interactive prompt. 7 + - [`minisky`](https://tangled.org/mackuba.eu/minisky/) โ€” XRPC client 8 + - [`skyfall`](https://tangled.org/mackuba.eu/skyfall/) โ€” firehose & Jetstream streaming 9 + - [`didkit`](https://tangled.org/mackuba.eu/didkit/) โ€” DID & handle resolution 10 + 11 + > [!NOTE] 12 + > Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue) 13 + 6 14 7 15 ## Installation 8 16 9 - TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org. 17 + To run this tool, you need some reasonably recent version of Ruby installed โ€“ it should run on Ruby 2.6 and above, although it's recommended to use a version that still gets maintainance updates, i.e. currently 3.2+. 18 + 19 + An older version of Ruby (2.6.x) that should work is shipped with macOS versions 11.0 or later, a recent version of Ruby is also likely to be already installed on most Linux systems, or at least available through the OS's package manager. More installation options can be found on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/). 10 20 11 - Install the gem and add to the application's Gemfile by executing: 21 + To install `rat`, run: 12 22 13 - ```bash 14 - bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG 23 + ``` 24 + [sudo] gem install ratproto 15 25 ``` 16 26 17 - If bundler is not being used to manage dependencies, install the gem by executing: 27 + ## Features 28 + 29 + Currently implemented features/commands: 18 30 19 - ```bash 20 - gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG 31 + - resolving DIDs & handles (`rat resolve`) 32 + - fetching & printing ATProto records (`rat fetch`) 33 + - streaming firehose / Jetstream events with optional filters (`rat stream`) 34 + 35 + 36 + ## Resolving a DID or handle 37 + 21 38 ``` 39 + rat resolve <did>|<handle> 40 + ``` 41 + 42 + Pass a DID or a handle (@ optional) to look up the given account's identity & print the DID document: 43 + 44 + ``` 45 + $ rat resolve atproto.com 22 46 23 - ## Usage 47 + { 48 + "@context": [ 49 + "https://www.w3.org/ns/did/v1", 50 + "https://w3id.org/security/multikey/v1", 51 + "https://w3id.org/security/suites/secp256k1-2019/v1" 52 + ], 53 + "id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 54 + "alsoKnownAs": [ 55 + "at://atproto.com" 56 + ], 57 + "verificationMethod": [ 58 + { 59 + "id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz#atproto", 60 + "type": "Multikey", 61 + "controller": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 62 + "publicKeyMultibase": "zQ3shunBKsXixLxKtC5qeSG9E4J5RkGN57im31pcTzbNQnm5w" 63 + } 64 + ], 65 + "service": [ 66 + { 67 + "id": "#atproto_pds", 68 + "type": "AtprotoPersonalDataServer", 69 + "serviceEndpoint": "https://enoki.us-east.host.bsky.network" 70 + } 71 + ] 72 + } 73 + ``` 24 74 25 - TODO: Write usage instructions here 26 75 27 - ## Development 76 + ## Fetching a record 28 77 29 - After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 78 + ``` 79 + rat fetch at://<did>/<collection>/<rkey> 80 + ``` 30 81 31 - To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 82 + Pass an at:// URI as the argument to fetch a single record from the userโ€™s PDS: 32 83 33 - ## Contributing 84 + ``` 85 + % rat fetch at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.feed.post/3lxxmboqmf22j 34 86 35 - Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/ratproto. 87 + { 88 + "text": "a gatinha gorda", 89 + "$type": "app.bsky.feed.post", 90 + "embed": { 91 + "$type": "app.bsky.embed.images", 92 + "images": [ 93 + { 94 + "alt": "Kit lying on the ground under the chair ", 95 + "image": { 96 + "$type": "blob", 97 + "ref": { 98 + "$link": "bafkreiebzsetrrvymvq6dvwma77zqprnv73ovgeqilanzi453dm6xart4q" 99 + }, 100 + "mimeType": "image/jpeg", 101 + "size": 963750 102 + }, 103 + "aspectRatio": { 104 + "width": 2000, 105 + "height": 1500 106 + } 107 + } 108 + ] 109 + }, 110 + "langs": [ 111 + "en" 112 + ], 113 + "createdAt": "2025-09-03T21:48:05.910Z" 114 + } 115 + ``` 116 + 117 + ## Streaming commit events 118 + 119 + ``` 120 + rat stream <firehose-host> [-j] [-r cursor] [-c collections] [-d dids] 121 + ``` 122 + 123 + Rat can connect to either a relay/PDS firehose: 124 + 125 + ``` 126 + rat stream bsky.network 127 + ``` 128 + 129 + or a Jetstream service (use `-j`): 130 + 131 + ``` 132 + rat stream -j jetstream2.us-east.bsky.network 133 + ``` 134 + 135 + You can also pass a cursor to connect with using `-r` / `--cursor` (tip: pass `-r0` to rewind as far back as the buffer allows). 136 + 137 + Once connected, youโ€™ll see output like: 138 + 139 + ``` 140 + [2025-01-02T12:34:56+01:00] did:plc:xwnehmdpjluz2kv3oh2mfi47 :create app.bsky.feed.post 3mbjs5rgtin2z {"text":"hi", ...} 141 + [2025-01-02T12:34:57+01:00] did:plc:3mfkuhmjxd2wvz2e7nhl4poi :delete app.bsky.graph.follow 3mbjs5rghri23 142 + ``` 143 + 144 + Press Ctrl-C to disconnect. 145 + 146 + You can also apply the filtering options below (for Jetstream, these are passed to the server to apply filtering server-side via `wantedCollections` / `wantedDids` parameters): 147 + 148 + 149 + ### Filtering by DID 150 + 151 + To print only events from given DID(s): 152 + 153 + ``` 154 + rat stream bsky.network -d did:plc:abcd1234,did:plc:xyz9999,... 155 + ``` 156 + 157 + You can either pass a comma-separated list as one parameter, or repeat `-d val` more than once. 158 + 159 + 160 + ### Filtering by collection 161 + 162 + To print only records from given collection(s): 163 + 164 + ``` 165 + rat stream bsky.network -c app.bsky.feed.post -c app.bsky.actor.profile 166 + ``` 167 + 168 + 169 + ## Credits 170 + 171 + Copyright ยฉ 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)). 172 + 173 + The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT). 174 + 175 + Bug reports and pull requests are welcome ๐Ÿ˜Ž
+265
exe/rat
··· 1 + #!/usr/bin/env ruby 2 + 3 + $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 4 + 5 + require 'didkit' 6 + require 'json' 7 + require 'minisky' 8 + require 'optparse' 9 + require 'time' 10 + require 'uri' 11 + 12 + require 'ratproto/version' 13 + 14 + DID_REGEXP = /\Adid:[a-z]+:[a-zA-Z0-9.\-_]+\z/ 15 + NSID_REGEXP = /\A[a-z0-9]+(\.[a-z0-9]+)+\z/ 16 + 17 + def print_help 18 + puts <<~HELP 19 + rat #{RatProto::VERSION} ๐Ÿ€ 20 + 21 + Usage: 22 + rat fetch at://uri 23 + rat stream <firehose-host> [options] 24 + rat resolve <did>|<handle> 25 + 26 + Commands: 27 + fetch Fetch a single record given its at:// URI 28 + stream Stream events from a relay / PDS firehose 29 + resolve Resolve a DID or @handle 30 + help Show this help 31 + version Show version 32 + 33 + Stream options: 34 + -j, --jetstream Use a Jetstream source instead of a CBOR firehose 35 + -r, --cursor CURSOR Start from a given cursor 36 + -d, --did DID[,DID...] Filter only events from given DID(s) 37 + (can be passed multiple times) 38 + -c, --collection NSID[,NSID...] Filter only events of given collection(s) 39 + (can be passed multiple times) 40 + HELP 41 + end 42 + 43 + def abort_with_error(message) 44 + puts message 45 + exit 1 46 + end 47 + 48 + def validate_did(did) 49 + unless did =~ DID_REGEXP 50 + abort_with_error "Error: #{did.inspect} is not a valid DID" 51 + end 52 + end 53 + 54 + def validate_nsid(collection) 55 + unless collection =~ NSID_REGEXP 56 + abort_with_error "Error: #{collection.inspect} is not a valid collection NSID" 57 + end 58 + end 59 + 60 + def parse_at_uri(uri) 61 + unless uri.start_with?('at://') 62 + abort_with_error "Error: not an at:// URI: #{uri.inspect}" 63 + end 64 + 65 + unless uri =~ /\Aat:\/\/([^\/]+)\/([^\/]+)\/([^\/]+)\z/ 66 + abort_with_error "Error: invalid at:// URI: #{uri.inspect}" 67 + end 68 + 69 + [$1, $2, $3] 70 + end 71 + 72 + def run_fetch(args) 73 + uri = args.shift 74 + 75 + if uri.nil? 76 + abort_with_error "Usage: #{$PROGRAM_NAME} fetch <at://uri>" 77 + end 78 + 79 + if !args.empty? 80 + abort_with_error "Error: Unexpected arguments for fetch: #{args.join(' ')}" 81 + end 82 + 83 + repo, collection, rkey = parse_at_uri(uri) 84 + 85 + begin 86 + pds = DID.new(repo).document.pds_host 87 + sky = Minisky.new(pds, nil) 88 + 89 + response = sky.get_request('com.atproto.repo.getRecord', { 90 + repo: repo, collection: collection, rkey: rkey 91 + }) 92 + rescue StandardError => e 93 + abort_with_error "Error loading record: #{e.class}: #{e.message}" 94 + end 95 + 96 + puts JSON.pretty_generate(response['value']) 97 + end 98 + 99 + def run_resolve(args) 100 + target = args.shift 101 + 102 + if target.nil? 103 + abort_with_error "Usage: #{$PROGRAM_NAME} resolve <did>|<handle>" 104 + end 105 + 106 + if !args.empty? 107 + abort_with_error "Error: Unexpected arguments for resolve: #{args.join(' ')}" 108 + end 109 + 110 + did = DID.resolve_handle(target) 111 + 112 + if did.nil? 113 + abort_with_error "Couldn't resolve #{target}" 114 + end 115 + 116 + puts JSON.pretty_generate(did.document.json) 117 + rescue StandardError => e 118 + abort_with_error "Error resolving #{target.inspect}: #{e.class}: #{e.message}" 119 + end 120 + 121 + def parse_stream_options(args) 122 + options = {} 123 + 124 + parser = OptionParser.new do |opts| 125 + opts.banner = "Usage: #{$PROGRAM_NAME} stream <relay.host> [options]" 126 + 127 + opts.on('-j', '--jetstream', 'Use a Jetstream source') do 128 + options[:jetstream] = true 129 + end 130 + 131 + opts.on('-rCURSOR', '--cursor=CURSOR', 'Start from a given cursor') do |cursor| 132 + options[:cursor] = cursor 133 + end 134 + 135 + opts.on('-dLIST', '--did=LIST', 'Filter only events from given DID(s) (comma-separated or repeated)') do |list| 136 + items = list.split(',').map(&:strip).reject(&:empty?) 137 + 138 + if items.empty? 139 + abort_with_error "Error: empty argument to -d/--did" 140 + end 141 + 142 + options[:dids] ||= [] 143 + options[:dids] += items 144 + end 145 + 146 + opts.on('-cLIST', '--collection=LIST', 'Filter only events of given collections') do |list| 147 + items = list.split(',').map(&:strip).reject(&:empty?) 148 + 149 + if items.empty? 150 + abort_with_error "Error: empty argument to -c/--collection" 151 + end 152 + 153 + options[:collections] ||= [] 154 + options[:collections] += items 155 + end 156 + 157 + opts.on('-h', '--help', 'Show stream-specific help') do 158 + puts opts 159 + exit 160 + end 161 + end 162 + 163 + remaining = [] 164 + 165 + begin 166 + parser.order!(args) { |other| remaining << other } 167 + rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e 168 + puts "Error: #{e.message}" 169 + puts parser 170 + exit 1 171 + end 172 + 173 + [options, remaining] 174 + end 175 + 176 + def run_stream(args) 177 + options, arguments = parse_stream_options(args) 178 + 179 + service = arguments.shift 180 + 181 + if service.nil? 182 + abort_with_error "Usage: #{$PROGRAM_NAME} stream <firehose-host> [options]" 183 + end 184 + 185 + if !arguments.empty? 186 + abort_with_error "Error: Unexpected arguments for stream: #{arguments.join(' ')}" 187 + end 188 + 189 + if options[:cursor] && options[:cursor] !~ /\A\d+\z/ 190 + abort_with_error "Error: cursor must be a decimal integer, got: #{options[:cursor].inspect}" 191 + end 192 + 193 + if options[:dids] 194 + options[:dids].each { |did| validate_did(did) } 195 + end 196 + 197 + if options[:collections] 198 + options[:collections].each { |collection| validate_nsid(collection) } 199 + end 200 + 201 + if options[:jetstream] 202 + jet_opts = {} 203 + jet_opts[:cursor] = options[:cursor].to_i if options[:cursor] 204 + 205 + # pass DID/collection filters to Jetstream to filter events server-side 206 + jet_opts[:wanted_dids] = options[:dids] if options[:dids] 207 + jet_opts[:wanted_collections] = options[:collections] if options[:collections] 208 + 209 + sky = Skyfall::Jetstream.new(service, jet_opts) 210 + else 211 + cursor = options[:cursor]&.to_i 212 + sky = Skyfall::Firehose.new(service, :subscribe_repos, cursor) 213 + end 214 + 215 + sky.on_connecting { |url| puts "Connecting to #{url}..." } 216 + sky.on_connect { puts "Connected" } 217 + sky.on_disconnect { puts "Disconnected" } 218 + sky.on_reconnect { puts "Connection lost, trying to reconnect..." } 219 + sky.on_timeout { puts "Connection stalled, triggering a reconnect..." } 220 + sky.on_error { |e| puts "ERROR: #{e}" } 221 + 222 + sky.on_message do |msg| 223 + next unless msg.type == :commit 224 + next if options[:dids] && !options[:dids].include?(msg.repo) 225 + 226 + time = msg.time.getlocal.iso8601 227 + 228 + msg.operations.each do |op| 229 + next if options[:collections] && !options[:collections].include?(op.collection) 230 + 231 + json = op.raw_record && JSON.generate(op.raw_record) 232 + puts "[#{time}] #{msg.repo} #{op.action.inspect} #{op.collection} #{op.rkey} #{json}" 233 + end 234 + end 235 + 236 + trap('SIGINT') do 237 + puts 'Disconnecting...' 238 + sky.disconnect 239 + end 240 + 241 + sky.connect 242 + end 243 + 244 + if ARGV.empty? 245 + print_help 246 + exit 247 + end 248 + 249 + cmd = ARGV.shift 250 + 251 + case cmd 252 + when 'help', '--help', '-h' 253 + print_help 254 + when 'version', '--version' 255 + puts "RatProto #{RatProto::VERSION} ๐Ÿ€" 256 + when 'fetch' 257 + run_fetch(ARGV) 258 + when 'resolve' 259 + run_resolve(ARGV) 260 + when 'stream' 261 + require 'skyfall' 262 + run_stream(ARGV) 263 + else 264 + abort_with_error "Error: unknown command: #{cmd}" 265 + end
+1 -1
lib/ratproto/version.rb
··· 1 1 # frozen_string_literal: true 2 2 3 3 module RatProto 4 - VERSION = "0.0.1" 4 + VERSION = "0.1.1" 5 5 end
+12 -6
ratproto.gemspec
··· 8 8 spec.authors = ["Kuba Suder"] 9 9 spec.email = ["jakub.suder@gmail.com"] 10 10 11 - spec.summary = "TODO: Write a short summary, because RubyGems requires one." 12 - spec.description = "TODO: Write a longer description or delete this line." 13 - spec.homepage = "TODO: Put your gem's website or public repo URL here." 11 + spec.summary = "Ruby CLI tool for accessing Bluesky API / ATProto" 12 + spec.homepage = "https://ruby.sdk.blue" 14 13 15 14 spec.license = "Zlib" 16 15 spec.required_ruby_version = ">= 2.6.0" 17 16 18 - spec.metadata["homepage_uri"] = spec.homepage 19 - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." 20 - spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." 17 + spec.metadata = { 18 + "bug_tracker_uri" => "https://tangled.org/mackuba.eu/ratproto/issues", 19 + "changelog_uri" => "https://tangled.org/mackuba.eu/ratproto/blob/master/CHANGELOG.md", 20 + "source_code_uri" => "https://tangled.org/mackuba.eu/ratproto", 21 + } 21 22 22 23 spec.files = Dir.chdir(__dir__) do 23 24 Dir['*.md'] + Dir['*.txt'] + Dir['exe/*'] + Dir['lib/**/*'] + Dir['sig/**/*'] 24 25 end 25 26 26 27 spec.bindir = 'exe' 28 + spec.executables = ['rat'] 27 29 spec.require_paths = ['lib'] 30 + 31 + spec.add_dependency 'minisky', '>= 0.5', '< 2.0' 32 + spec.add_dependency 'skyfall', '>= 0.6', '< 2.0' 33 + spec.add_dependency 'didkit', '>= 0.3.1', '< 2.0' 28 34 end