Ruby CLI tool for accessing Bluesky API / ATProto

first usable version (disclaimer: 100% vibe-coded)

Changed files
+415 -6
exe
rat
+403
exe/rat
··· 1 + #!/usr/bin/env ruby 2 + # frozen_string_literal: true 3 + 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 18 + 19 + require 'optparse' 20 + require 'json' 21 + require 'time' 22 + require 'uri' 23 + 24 + require 'minisky' 25 + require 'skyfall' 26 + require 'didkit' # defines DIDKit::DID and top-level DID alias 27 + 28 + VERSION = '0.0.1' 29 + 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} 37 + 38 + 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 44 + 45 + Commands: 46 + 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 49 + help Show this help 50 + version Show version 51 + 52 + 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) 57 + -d, --did DID[,DID...] Filter only events from given DID(s) 58 + (can be passed multiple times) 59 + -c, --collection NSID[,NSID...] Filter only events of given collection(s) 60 + (can be passed multiple times) 61 + HELP 62 + end 63 + 64 + def abort_with_help(message = nil) 65 + warn "Error: #{message}" if message 66 + warn 67 + warn global_help 68 + exit 1 69 + end 70 + 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}") 87 + end 88 + s 89 + end 90 + 91 + def validate_did!(s) 92 + unless valid_did?(s) 93 + abort_with_help("#{s.inspect} doesn't look like a valid DID") 94 + end 95 + s 96 + end 97 + 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}") 108 + end 109 + 110 + m = /\Aat:\/\/([^\/]+)\/([^\/]+)\/([^\/]+)\z/.match(str) 111 + unless m 112 + abort_with_help("invalid at:// URI: expected at://<repo>/<collection>/<rkey>") 113 + end 114 + 115 + repo, collection, rkey = m.captures 116 + [repo, collection, rkey] 117 + end 118 + 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 128 + 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") 133 + end 134 + 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(' ')}") 145 + end 146 + 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 } 156 + 157 + begin 158 + res = client.get_request('com.atproto.repo.getRecord', params) 159 + rescue StandardError => e 160 + warn "Error calling com.atproto.repo.getRecord: #{e.class}: #{e.message}" 161 + exit 1 162 + end 163 + 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 171 + end 172 + 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(' ')}") 177 + end 178 + 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 204 + end 205 + end 206 + 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 224 + end 225 + 226 + host 227 + rescue URI::InvalidURIError 228 + abort_with_help("invalid relay URL: #{host.inspect}") 229 + end 230 + 231 + def parse_stream_options(argv) 232 + options = { 233 + use_jetstream: false, 234 + cursor: nil, 235 + dids: [], 236 + collections: [] 237 + } 238 + 239 + parser = OptionParser.new do |opts| 240 + opts.banner = "Usage: rat stream <relay.host> [options]" 241 + 242 + opts.on('-j', '--jetstream', 'Use Skyfall::Jetstream (JSON)') do 243 + options[:use_jetstream] = true 244 + end 245 + 246 + opts.on('-rCURSOR', '--cursor=CURSOR', 'Start from cursor (seq or time_us)') do |cursor| 247 + options[:cursor] = cursor 248 + end 249 + 250 + opts.on('-dLIST', '--did=LIST', 251 + 'Filter only events from DID(s) (comma-separated or repeated)') do |list| 252 + items = list.split(',').map(&:strip).reject(&:empty?) 253 + if items.empty? 254 + abort_with_help("empty argument to -d/--did") 255 + end 256 + options[:dids].concat(items) 257 + end 258 + 259 + opts.on('-cLIST', '--collection=LIST', 260 + 'Filter only events of given collection NSID(s)') do |list| 261 + items = list.split(',').map(&:strip).reject(&:empty?) 262 + if items.empty? 263 + abort_with_help("empty argument to -c/--collection") 264 + end 265 + options[:collections].concat(items) 266 + end 267 + 268 + opts.on('-h', '--help', 'Show stream-specific help') do 269 + puts opts 270 + exit 0 271 + end 272 + end 273 + 274 + remaining = [] 275 + begin 276 + parser.order!(argv) { |nonopt| remaining << nonopt } 277 + rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e 278 + warn "Error: #{e.message}" 279 + warn parser 280 + exit 1 281 + end 282 + 283 + [options, remaining] 284 + end 285 + 286 + def do_stream(argv) 287 + options, remaining = parse_stream_options(argv) 288 + 289 + host = remaining.shift or abort_with_help("stream requires a relay hostname") 290 + validate_relay_host!(host) 291 + 292 + unless remaining.empty? 293 + abort_with_help("unexpected extra arguments for stream: #{remaining.join(' ')}") 294 + end 295 + 296 + # validate cursor (if given) 297 + if options[:cursor] && options[:cursor] !~ /\A\d+\z/ 298 + abort_with_help("cursor must be a decimal integer, got #{options[:cursor].inspect}") 299 + end 300 + 301 + # validate DIDs 302 + options[:dids].each do |did| 303 + validate_did!(did) 304 + end 305 + 306 + # validate collections 307 + options[:collections].each do |nsid| 308 + validate_nsid!(nsid) 309 + end 310 + 311 + # Build Skyfall client 312 + if options[:use_jetstream] 313 + jet_opts = {} 314 + jet_opts[:cursor] = options[:cursor].to_i if options[:cursor] 315 + 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? 319 + 320 + sky = Skyfall::Jetstream.new(host, jet_opts) 321 + else 322 + cursor = options[:cursor]&.to_i 323 + sky = Skyfall::Firehose.new(host, :subscribe_repos, cursor) 324 + end 325 + 326 + # Lifecycle logging 327 + sky.on_connecting { |url| puts "Connecting to #{url}..." } 328 + sky.on_connect { puts "Connected" } 329 + sky.on_disconnect { puts "Disconnected" } 330 + 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}" } 333 + 334 + # Message handler 335 + sky.on_message do |msg| 336 + 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 349 + 350 + ts = msg.time ? msg.time.getlocal.iso8601 : 'unknown-time' 351 + 352 + 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 365 + 366 + puts line 367 + end 368 + end 369 + 370 + # Clean disconnect on Ctrl+C 371 + trap('SIGINT') do 372 + puts 'Disconnecting...' 373 + sky.disconnect 374 + end 375 + 376 + sky.connect 377 + end 378 + 379 + # ---- main dispatcher ---- 380 + 381 + if ARGV.empty? 382 + puts global_help 383 + exit 0 384 + end 385 + 386 + cmd = ARGV.shift 387 + 388 + case cmd 389 + when 'help', '--help', '-h' 390 + puts global_help 391 + exit 0 392 + when 'version', '--version' 393 + puts VERSION 394 + exit 0 395 + when 'fetch' 396 + do_fetch(ARGV) 397 + when 'resolve' 398 + do_resolve(ARGV) 399 + when 'stream' 400 + do_stream(ARGV) 401 + else 402 + abort_with_help("unknown command: #{cmd}") 403 + 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