+11
-1
CHANGELOG.md
+11
-1
CHANGELOG.md
+157
-17
README.md
+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
+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
lib/ratproto/version.rb
+12
-6
ratproto.gemspec
+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