Ruby CLI tool for accessing Bluesky API / ATProto

Compare changes

Choose any two refs to compare.

Changed files
+288 -276
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 ๐Ÿ˜Ž
+119 -257
exe/rat
··· 1 1 #!/usr/bin/env ruby 2 - # frozen_string_literal: true 3 2 4 - # rat โ€“ Ruby ATProto Tool 5 - # 6 - # Simple CLI for Bluesky AT Protocol using: 7 - # - Minisky (XRPC client) 8 - # - Skyfall (firehose / Jetstream streaming) 9 - # - DIDKit (DID / handle resolution) 10 - # 11 - # Usage: 12 - # rat fetch at://<did-or-handle>/<collection-nsid>/<rkey> 13 - # rat stream <relay.host> [-j|--jetstream] [-r|--cursor CURSOR] \ 14 - # [-d|--did DID,...] [-c|--collection NSID,...] 15 - # rat resolve <did-or-handle> 16 - # rat help | --help 17 - # rat version | --version 3 + $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 18 4 19 - require 'optparse' 5 + require 'didkit' 20 6 require 'json' 7 + require 'minisky' 8 + require 'optparse' 21 9 require 'time' 22 10 require 'uri' 23 11 24 - require 'minisky' 25 - require 'skyfall' 26 - require 'didkit' # defines DIDKit::DID and top-level DID alias 12 + require 'ratproto/version' 27 13 28 - VERSION = '0.0.1' 14 + DID_REGEXP = /\Adid:[a-z]+:[a-zA-Z0-9.\-_]+\z/ 15 + NSID_REGEXP = /\A[a-z0-9]+(\.[a-z0-9]+)+\z/ 29 16 30 - DID_REGEX = /\Adid:[a-z0-9]+:[a-zA-Z0-9.\-_:]+\z/ 31 - DOMAIN_REGEX = /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+\z/i 32 - HOST_PORT_REGEX = /\A#{DOMAIN_REGEX.source}(?::\d+)?\z/i 33 - 34 - def global_help 35 - <<~HELP 36 - rat #{VERSION} 17 + def print_help 18 + puts <<~HELP 19 + rat #{RatProto::VERSION} ๐Ÿ€ 37 20 38 21 Usage: 39 - rat fetch at://<did-or-handle>/<collection-nsid>/<rkey> 40 - rat stream <relay.host> [options] 41 - rat resolve <did-or-handle> 42 - rat help | --help 43 - rat version | --version 22 + rat fetch at://uri 23 + rat stream <firehose-host> [options] 24 + rat resolve <did>|<handle> 44 25 45 26 Commands: 46 27 fetch Fetch a single record given its at:// URI 47 - stream Stream commit events from a relay / PDS firehose 48 - resolve Resolve a DID or @handle using DIDKit 28 + stream Stream events from a relay / PDS firehose 29 + resolve Resolve a DID or @handle 49 30 help Show this help 50 31 version Show version 51 32 52 33 Stream options: 53 - -j, --jetstream Use Skyfall::Jetstream (JSON) 54 - (uses Jetstream wanted_dids / wanted_collections 55 - when -d/-c are provided) 56 - -r, --cursor CURSOR Start from cursor (seq or time_us) 34 + -j, --jetstream Use a Jetstream source instead of a CBOR firehose 35 + -r, --cursor CURSOR Start from a given cursor 57 36 -d, --did DID[,DID...] Filter only events from given DID(s) 58 37 (can be passed multiple times) 59 38 -c, --collection NSID[,NSID...] Filter only events of given collection(s) ··· 61 40 HELP 62 41 end 63 42 64 - def abort_with_help(message = nil) 65 - warn "Error: #{message}" if message 66 - warn 67 - warn global_help 43 + def abort_with_error(message) 44 + puts message 68 45 exit 1 69 46 end 70 47 71 - def valid_did?(s) 72 - DID_REGEX.match?(s) 73 - end 74 - 75 - def valid_handle?(s) 76 - DOMAIN_REGEX.match?(s) 77 - end 78 - 79 - def valid_nsid?(s) 80 - # NSIDs look like domain names (reverse DNS), so reuse DOMAIN_REGEX 81 - DOMAIN_REGEX.match?(s) 82 - end 83 - 84 - def validate_handle!(s, context: 'handle') 85 - unless valid_handle?(s) 86 - abort_with_help("#{s.inspect} doesn't look like a valid #{context}") 48 + def validate_did(did) 49 + unless did =~ DID_REGEXP 50 + abort_with_error "Error: #{did.inspect} is not a valid DID" 87 51 end 88 - s 89 52 end 90 53 91 - def validate_did!(s) 92 - unless valid_did?(s) 93 - abort_with_help("#{s.inspect} doesn't look like a valid DID") 54 + def validate_nsid(collection) 55 + unless collection =~ NSID_REGEXP 56 + abort_with_error "Error: #{collection.inspect} is not a valid collection NSID" 94 57 end 95 - s 96 58 end 97 59 98 - def validate_nsid!(nsid) 99 - unless valid_nsid?(nsid) 100 - abort_with_help("#{nsid.inspect} doesn't look like a valid collection NSID") 101 - end 102 - nsid 103 - end 104 - 105 - def parse_at_uri(str) 106 - unless str.start_with?('at://') 107 - abort_with_help("not an at:// URI: #{str.inspect}") 60 + def parse_at_uri(uri) 61 + unless uri.start_with?('at://') 62 + abort_with_error "Error: not an at:// URI: #{uri.inspect}" 108 63 end 109 64 110 - m = /\Aat:\/\/([^\/]+)\/([^\/]+)\/([^\/]+)\z/.match(str) 111 - unless m 112 - abort_with_help("invalid at:// URI: expected at://<repo>/<collection>/<rkey>") 65 + unless uri =~ /\Aat:\/\/([^\/]+)\/([^\/]+)\/([^\/]+)\z/ 66 + abort_with_error "Error: invalid at:// URI: #{uri.inspect}" 113 67 end 114 68 115 - repo, collection, rkey = m.captures 116 - [repo, collection, rkey] 69 + [$1, $2, $3] 117 70 end 118 71 119 - def resolve_repo_to_did_and_pds(repo_str) 120 - if repo_str.start_with?('did:') 121 - validate_did!(repo_str) 122 - did_obj = DID.new(repo_str) 123 - else 124 - handle = repo_str.sub(/\A@/, '') 125 - validate_handle!(handle, context: 'handle in at:// URI') 126 - did_obj = DID.resolve_handle(handle) 127 - end 72 + def run_fetch(args) 73 + uri = args.shift 128 74 129 - doc = did_obj.document 130 - pds_host = doc.pds_host 131 - unless pds_host && !pds_host.empty? 132 - abort_with_help("DID document for #{did_obj} does not contain a pds_host") 75 + if uri.nil? 76 + abort_with_error "Usage: #{$PROGRAM_NAME} fetch <at://uri>" 133 77 end 134 78 135 - [did_obj.to_s, pds_host] 136 - rescue StandardError => e 137 - warn "Error resolving repo #{repo_str.inspect}: #{e.class}: #{e.message}" 138 - exit 1 139 - end 140 - 141 - def do_fetch(argv) 142 - uri = argv.shift or abort_with_help("fetch requires an at:// URI") 143 - unless argv.empty? 144 - abort_with_help("unexpected extra arguments for fetch: #{argv.join(' ')}") 79 + if !args.empty? 80 + abort_with_error "Error: Unexpected arguments for fetch: #{args.join(' ')}" 145 81 end 146 82 147 - repo_str, collection, rkey = parse_at_uri(uri) 148 - 149 - # basic validation of collection (NSID-ish) 150 - validate_nsid!(collection) 151 - 152 - did, pds_host = resolve_repo_to_did_and_pds(repo_str) 153 - 154 - client = Minisky.new(pds_host, nil) 155 - params = { repo: did, collection: collection, rkey: rkey } 83 + repo, collection, rkey = parse_at_uri(uri) 156 84 157 85 begin 158 - res = client.get_request('com.atproto.repo.getRecord', params) 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 + }) 159 92 rescue StandardError => e 160 - warn "Error calling com.atproto.repo.getRecord: #{e.class}: #{e.message}" 161 - exit 1 93 + abort_with_error "Error loading record: #{e.class}: #{e.message}" 162 94 end 163 95 164 - value = res['value'] 165 - if value.nil? 166 - warn "Warning: response did not contain a 'value' field" 167 - puts JSON.pretty_generate(res) 168 - else 169 - puts JSON.pretty_generate(value) 170 - end 96 + puts JSON.pretty_generate(response['value']) 171 97 end 172 98 173 - def do_resolve(argv) 174 - target = argv.shift or abort_with_help("resolve requires a DID or handle") 175 - unless argv.empty? 176 - abort_with_help("unexpected extra arguments for resolve: #{argv.join(' ')}") 99 + def run_resolve(args) 100 + target = args.shift 101 + 102 + if target.nil? 103 + abort_with_error "Usage: #{$PROGRAM_NAME} resolve <did>|<handle>" 177 104 end 178 105 179 - if target.start_with?('did:') 180 - validate_did!(target) 181 - begin 182 - did_obj = DID.new(target) 183 - doc = did_obj.document 184 - json = doc.respond_to?(:json) ? doc.json : doc 185 - puts did_obj.to_s 186 - puts JSON.pretty_generate(json) 187 - rescue StandardError => e 188 - warn "Error resolving DID #{target.inspect}: #{e.class}: #{e.message}" 189 - exit 1 190 - end 191 - else 192 - handle = target.sub(/\A@/, '') 193 - validate_handle!(handle) 194 - begin 195 - did_obj = DID.resolve_handle(handle) 196 - doc = did_obj.document 197 - json = doc.respond_to?(:json) ? doc.json : doc 198 - puts did_obj.to_s 199 - puts JSON.pretty_generate(json) 200 - rescue StandardError => e 201 - warn "Error resolving handle #{target.inspect}: #{e.class}: #{e.message}" 202 - exit 1 203 - end 106 + if !args.empty? 107 + abort_with_error "Error: Unexpected arguments for resolve: #{args.join(' ')}" 204 108 end 205 - end 206 109 207 - def validate_relay_host!(host) 208 - # Accept: 209 - # - bare hostname or hostname:port 210 - # - ws://host[:port] 211 - # - wss://host[:port] 212 - if host =~ /\Aws:\/\//i || host =~ /\Awss:\/\//i 213 - uri = URI(host) 214 - if uri.path && uri.path != '' && uri.path != '/' 215 - abort_with_help("relay URL must not contain a path: #{host.inspect}") 216 - end 217 - unless uri.host && DOMAIN_REGEX.match?(uri.host) 218 - abort_with_help("invalid relay hostname in URL: #{host.inspect}") 219 - end 220 - else 221 - unless HOST_PORT_REGEX.match?(host) 222 - abort_with_help("invalid relay hostname: #{host.inspect}") 223 - end 110 + did = DID.resolve_handle(target) 111 + 112 + if did.nil? 113 + abort_with_error "Couldn't resolve #{target}" 224 114 end 225 115 226 - host 227 - rescue URI::InvalidURIError 228 - abort_with_help("invalid relay URL: #{host.inspect}") 116 + puts JSON.pretty_generate(did.document.json) 117 + rescue StandardError => e 118 + abort_with_error "Error resolving #{target.inspect}: #{e.class}: #{e.message}" 229 119 end 230 120 231 - def parse_stream_options(argv) 232 - options = { 233 - use_jetstream: false, 234 - cursor: nil, 235 - dids: [], 236 - collections: [] 237 - } 121 + def parse_stream_options(args) 122 + options = {} 238 123 239 124 parser = OptionParser.new do |opts| 240 - opts.banner = "Usage: rat stream <relay.host> [options]" 125 + opts.banner = "Usage: #{$PROGRAM_NAME} stream <relay.host> [options]" 241 126 242 - opts.on('-j', '--jetstream', 'Use Skyfall::Jetstream (JSON)') do 243 - options[:use_jetstream] = true 127 + opts.on('-j', '--jetstream', 'Use a Jetstream source') do 128 + options[:jetstream] = true 244 129 end 245 130 246 - opts.on('-rCURSOR', '--cursor=CURSOR', 'Start from cursor (seq or time_us)') do |cursor| 131 + opts.on('-rCURSOR', '--cursor=CURSOR', 'Start from a given cursor') do |cursor| 247 132 options[:cursor] = cursor 248 133 end 249 134 250 - opts.on('-dLIST', '--did=LIST', 251 - 'Filter only events from DID(s) (comma-separated or repeated)') do |list| 135 + opts.on('-dLIST', '--did=LIST', 'Filter only events from given DID(s) (comma-separated or repeated)') do |list| 252 136 items = list.split(',').map(&:strip).reject(&:empty?) 137 + 253 138 if items.empty? 254 - abort_with_help("empty argument to -d/--did") 139 + abort_with_error "Error: empty argument to -d/--did" 255 140 end 256 - options[:dids].concat(items) 141 + 142 + options[:dids] ||= [] 143 + options[:dids] += items 257 144 end 258 145 259 - opts.on('-cLIST', '--collection=LIST', 260 - 'Filter only events of given collection NSID(s)') do |list| 146 + opts.on('-cLIST', '--collection=LIST', 'Filter only events of given collections') do |list| 261 147 items = list.split(',').map(&:strip).reject(&:empty?) 148 + 262 149 if items.empty? 263 - abort_with_help("empty argument to -c/--collection") 150 + abort_with_error "Error: empty argument to -c/--collection" 264 151 end 265 - options[:collections].concat(items) 152 + 153 + options[:collections] ||= [] 154 + options[:collections] += items 266 155 end 267 156 268 157 opts.on('-h', '--help', 'Show stream-specific help') do 269 158 puts opts 270 - exit 0 159 + exit 271 160 end 272 161 end 273 162 274 163 remaining = [] 164 + 275 165 begin 276 - parser.order!(argv) { |nonopt| remaining << nonopt } 166 + parser.order!(args) { |other| remaining << other } 277 167 rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e 278 - warn "Error: #{e.message}" 279 - warn parser 168 + puts "Error: #{e.message}" 169 + puts parser 280 170 exit 1 281 171 end 282 172 283 173 [options, remaining] 284 174 end 285 175 286 - def do_stream(argv) 287 - options, remaining = parse_stream_options(argv) 176 + def run_stream(args) 177 + options, arguments = parse_stream_options(args) 288 178 289 - host = remaining.shift or abort_with_help("stream requires a relay hostname") 290 - validate_relay_host!(host) 179 + service = arguments.shift 291 180 292 - unless remaining.empty? 293 - abort_with_help("unexpected extra arguments for stream: #{remaining.join(' ')}") 181 + if service.nil? 182 + abort_with_error "Usage: #{$PROGRAM_NAME} stream <firehose-host> [options]" 294 183 end 295 184 296 - # validate cursor (if given) 185 + if !arguments.empty? 186 + abort_with_error "Error: Unexpected arguments for stream: #{arguments.join(' ')}" 187 + end 188 + 297 189 if options[:cursor] && options[:cursor] !~ /\A\d+\z/ 298 - abort_with_help("cursor must be a decimal integer, got #{options[:cursor].inspect}") 190 + abort_with_error "Error: cursor must be a decimal integer, got: #{options[:cursor].inspect}" 299 191 end 300 192 301 - # validate DIDs 302 - options[:dids].each do |did| 303 - validate_did!(did) 193 + if options[:dids] 194 + options[:dids].each { |did| validate_did(did) } 304 195 end 305 196 306 - # validate collections 307 - options[:collections].each do |nsid| 308 - validate_nsid!(nsid) 197 + if options[:collections] 198 + options[:collections].each { |collection| validate_nsid(collection) } 309 199 end 310 200 311 - # Build Skyfall client 312 - if options[:use_jetstream] 201 + if options[:jetstream] 313 202 jet_opts = {} 314 203 jet_opts[:cursor] = options[:cursor].to_i if options[:cursor] 315 204 316 - # Pass filters through to Jetstream server-side 317 - jet_opts[:wanted_dids] = options[:dids] unless options[:dids].empty? 318 - jet_opts[:wanted_collections] = options[:collections] unless options[:collections].empty? 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] 319 208 320 - sky = Skyfall::Jetstream.new(host, jet_opts) 209 + sky = Skyfall::Jetstream.new(service, jet_opts) 321 210 else 322 211 cursor = options[:cursor]&.to_i 323 - sky = Skyfall::Firehose.new(host, :subscribe_repos, cursor) 212 + sky = Skyfall::Firehose.new(service, :subscribe_repos, cursor) 324 213 end 325 214 326 - # Lifecycle logging 327 215 sky.on_connecting { |url| puts "Connecting to #{url}..." } 328 216 sky.on_connect { puts "Connected" } 329 217 sky.on_disconnect { puts "Disconnected" } 330 218 sky.on_reconnect { puts "Connection lost, trying to reconnect..." } 331 - sky.on_timeout { puts "Connection stalled, triggering a reconnect..." } if sky.respond_to?(:on_timeout) 332 - sky.on_error { |e| warn "ERROR: #{e}" } 219 + sky.on_timeout { puts "Connection stalled, triggering a reconnect..." } 220 + sky.on_error { |e| puts "ERROR: #{e}" } 333 221 334 - # Message handler 335 222 sky.on_message do |msg| 336 223 next unless msg.type == :commit 337 - 338 - did = if msg.respond_to?(:repo) && msg.repo 339 - msg.repo 340 - elsif msg.respond_to?(:did) 341 - msg.did 342 - else 343 - nil 344 - end 345 - 346 - if !options[:dids].empty? && (!did || !options[:dids].include?(did)) 347 - next 348 - end 224 + next if options[:dids] && !options[:dids].include?(msg.repo) 349 225 350 - ts = msg.time ? msg.time.getlocal.iso8601 : 'unknown-time' 226 + time = msg.time.getlocal.iso8601 351 227 352 228 msg.operations.each do |op| 353 - # collection filter (still applied client-side, in case server doesn't) 354 - if !options[:collections].empty? && 355 - !options[:collections].include?(op.collection) 356 - next 357 - end 358 - 359 - line = "[#{ts}] #{did || '-'} :#{op.action} #{op.collection} #{op.rkey}" 360 - 361 - if op.respond_to?(:raw_record) && op.raw_record && !op.raw_record.empty? 362 - # compact JSON on one line 363 - line << " " << JSON.generate(op.raw_record) 364 - end 229 + next if options[:collections] && !options[:collections].include?(op.collection) 365 230 366 - puts line 231 + json = op.raw_record && JSON.generate(op.raw_record) 232 + puts "[#{time}] #{msg.repo} #{op.action.inspect} #{op.collection} #{op.rkey} #{json}" 367 233 end 368 234 end 369 235 370 - # Clean disconnect on Ctrl+C 371 236 trap('SIGINT') do 372 237 puts 'Disconnecting...' 373 238 sky.disconnect ··· 376 241 sky.connect 377 242 end 378 243 379 - # ---- main dispatcher ---- 380 - 381 244 if ARGV.empty? 382 - puts global_help 383 - exit 0 245 + print_help 246 + exit 384 247 end 385 248 386 249 cmd = ARGV.shift 387 250 388 251 case cmd 389 252 when 'help', '--help', '-h' 390 - puts global_help 391 - exit 0 253 + print_help 392 254 when 'version', '--version' 393 - puts VERSION 394 - exit 0 255 + puts "RatProto #{RatProto::VERSION} ๐Ÿ€" 395 256 when 'fetch' 396 - do_fetch(ARGV) 257 + run_fetch(ARGV) 397 258 when 'resolve' 398 - do_resolve(ARGV) 259 + run_resolve(ARGV) 399 260 when 'stream' 400 - do_stream(ARGV) 261 + require 'skyfall' 262 + run_stream(ARGV) 401 263 else 402 - abort_with_help("unknown command: #{cmd}") 264 + abort_with_error "Error: unknown command: #{cmd}" 403 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