+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 ๐
+119
-257
exe/rat
+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