agentgif 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/bin/agentgif +7 -0
- data/lib/agentgif/cli.rb +522 -0
- data/lib/agentgif/client.rb +232 -0
- data/lib/agentgif/config.rb +49 -0
- metadata +66 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 38350f34e4505421da41f46084837dd32f4f974945ac39975b25aafc954b0bf8
|
|
4
|
+
data.tar.gz: 1c4677ec2565fd4b206a4119833c2e2548aa8c022c76dffc05add30754e50cda
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 121c69206afd5327e4f287d327021e688affb48bbeab430bcef8e7e8b24782e3bd7726a9ed7fd7e9f808f65d5dfac72a08b53ac15c5b0ff56d305e989134700e
|
|
7
|
+
data.tar.gz: 9e6951b9977b210074a530aab5dd6070a145b8a0996d423f547eecb1461bef8b2ad4f5cdf4a4e834f09735f3fb2fd0d841793c773c61165e42bd32391a41fe7f
|
data/bin/agentgif
ADDED
data/lib/agentgif/cli.rb
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# AgentGIF CLI (Ruby) — GIF for humans. Cast for agents.
|
|
4
|
+
#
|
|
5
|
+
# Install: gem install agentgif
|
|
6
|
+
# Usage: agentgif login | upload | search | badge
|
|
7
|
+
#
|
|
8
|
+
# Full documentation: https://agentgif.com/docs/cli/
|
|
9
|
+
|
|
10
|
+
require_relative "config"
|
|
11
|
+
require_relative "client"
|
|
12
|
+
|
|
13
|
+
module AgentGIF
|
|
14
|
+
VERSION = "0.2.0"
|
|
15
|
+
|
|
16
|
+
module CLI
|
|
17
|
+
HELP = <<~TEXT
|
|
18
|
+
AgentGIF — GIF for humans. Cast for agents.
|
|
19
|
+
|
|
20
|
+
Usage: agentgif <command> [options]
|
|
21
|
+
|
|
22
|
+
Commands:
|
|
23
|
+
login Open browser to authenticate
|
|
24
|
+
logout Remove stored credentials
|
|
25
|
+
whoami Show current user info
|
|
26
|
+
|
|
27
|
+
upload <gif> Upload a GIF
|
|
28
|
+
search <query> Search public GIFs
|
|
29
|
+
list List your GIFs
|
|
30
|
+
info <gifId> Show GIF details (JSON)
|
|
31
|
+
embed <gifId> Show embed codes
|
|
32
|
+
update <gifId> Update GIF metadata
|
|
33
|
+
delete <gifId> Delete a GIF
|
|
34
|
+
|
|
35
|
+
generate <url> Generate GIFs from a README or package docs
|
|
36
|
+
generate-status <id> Check status of a generate job
|
|
37
|
+
record <tape> Record a VHS tape and upload the GIF
|
|
38
|
+
|
|
39
|
+
badge url Generate badge URL + embed codes
|
|
40
|
+
badge themes List available terminal themes
|
|
41
|
+
|
|
42
|
+
version Show version
|
|
43
|
+
|
|
44
|
+
Docs: https://agentgif.com/docs/cli/
|
|
45
|
+
TEXT
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
def run(args)
|
|
50
|
+
command = args.shift
|
|
51
|
+
case command
|
|
52
|
+
when "login" then cmd_login
|
|
53
|
+
when "logout" then cmd_logout
|
|
54
|
+
when "whoami" then cmd_whoami
|
|
55
|
+
when "upload" then cmd_upload(args)
|
|
56
|
+
when "search" then cmd_search(args)
|
|
57
|
+
when "list" then cmd_list(args)
|
|
58
|
+
when "info" then cmd_info(args)
|
|
59
|
+
when "embed" then cmd_embed(args)
|
|
60
|
+
when "update" then cmd_update(args)
|
|
61
|
+
when "delete" then cmd_delete(args)
|
|
62
|
+
when "generate" then cmd_generate(args)
|
|
63
|
+
when "generate-status" then cmd_generate_status(args)
|
|
64
|
+
when "record" then cmd_record(args)
|
|
65
|
+
when "badge" then cmd_badge(args)
|
|
66
|
+
when "version", "--version", "-v"
|
|
67
|
+
puts "agentgif #{VERSION}"
|
|
68
|
+
when "help", "--help", "-h", nil
|
|
69
|
+
puts HELP
|
|
70
|
+
else
|
|
71
|
+
warn "Unknown command: #{command}"
|
|
72
|
+
warn "Run 'agentgif help' for usage."
|
|
73
|
+
exit 1
|
|
74
|
+
end
|
|
75
|
+
rescue AgentGIF::ApiError => e
|
|
76
|
+
warn "Error: #{e.message}"
|
|
77
|
+
exit 1
|
|
78
|
+
rescue Errno::ECONNREFUSED, SocketError => e
|
|
79
|
+
warn "Connection error: #{e.message}"
|
|
80
|
+
exit 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# --- Authentication ---
|
|
84
|
+
|
|
85
|
+
def cmd_login
|
|
86
|
+
client = Client.new
|
|
87
|
+
data = client.device_auth
|
|
88
|
+
code = data["user_code"]
|
|
89
|
+
url = data["verification_url"]
|
|
90
|
+
device_code = data["device_code"]
|
|
91
|
+
interval = (data["interval"] || 5).to_i
|
|
92
|
+
|
|
93
|
+
puts " Code: #{code}"
|
|
94
|
+
puts " Open: #{url}"
|
|
95
|
+
puts ""
|
|
96
|
+
|
|
97
|
+
open_browser(url)
|
|
98
|
+
|
|
99
|
+
puts "Waiting for authentication..."
|
|
100
|
+
loop do
|
|
101
|
+
sleep(interval)
|
|
102
|
+
body, status = client.device_token(device_code)
|
|
103
|
+
if status == 200 && body && body["api_key"]
|
|
104
|
+
Config.save_credentials(body["api_key"], body["username"] || "")
|
|
105
|
+
puts "Logged in as #{body['username']}"
|
|
106
|
+
return
|
|
107
|
+
elsif status == 400
|
|
108
|
+
detail = body && body["detail"]
|
|
109
|
+
if detail == "authorization_pending"
|
|
110
|
+
next
|
|
111
|
+
else
|
|
112
|
+
warn "Auth failed: #{detail || 'unknown error'}"
|
|
113
|
+
exit 1
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
warn "Unexpected response (#{status})"
|
|
117
|
+
exit 1
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def cmd_logout
|
|
123
|
+
Config.clear_credentials
|
|
124
|
+
puts "Logged out."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def cmd_whoami
|
|
128
|
+
require_auth
|
|
129
|
+
data = client.whoami
|
|
130
|
+
puts " Username: #{data['username']}"
|
|
131
|
+
puts " Email: #{data['email']}" if data["email"]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# --- GIF Management ---
|
|
135
|
+
|
|
136
|
+
def cmd_upload(args)
|
|
137
|
+
require_auth
|
|
138
|
+
opts = parse_opts(args, %w[-t --title -d --description -c --command --tags --cast --theme], %w[--unlisted --no-repo])
|
|
139
|
+
gif_path = opts[:positional].first
|
|
140
|
+
abort "Usage: agentgif upload <gif> [options]" unless gif_path
|
|
141
|
+
abort "File not found: #{gif_path}" unless File.exist?(gif_path)
|
|
142
|
+
|
|
143
|
+
fields = {}
|
|
144
|
+
fields["title"] = opts["-t"] || opts["--title"] if opts["-t"] || opts["--title"]
|
|
145
|
+
fields["description"] = opts["-d"] || opts["--description"] if opts["-d"] || opts["--description"]
|
|
146
|
+
fields["command"] = opts["-c"] || opts["--command"] if opts["-c"] || opts["--command"]
|
|
147
|
+
fields["tags"] = opts["--tags"] if opts["--tags"]
|
|
148
|
+
fields["cast_path"] = opts["--cast"] if opts["--cast"]
|
|
149
|
+
fields["theme"] = opts["--theme"] if opts["--theme"]
|
|
150
|
+
fields["unlisted"] = "true" if opts["--unlisted"]
|
|
151
|
+
|
|
152
|
+
unless opts["--no-repo"]
|
|
153
|
+
repo = detect_repo
|
|
154
|
+
fields["repo"] = repo if repo
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
data = client.upload(gif_path, fields)
|
|
158
|
+
gif_id = data["id"]
|
|
159
|
+
puts "Uploaded: #{Client::BASE_URL}/g/#{gif_id}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def cmd_search(args)
|
|
163
|
+
query = args.join(" ")
|
|
164
|
+
abort "Usage: agentgif search <query>" if query.empty?
|
|
165
|
+
data = Client.new.search(query)
|
|
166
|
+
results = data["results"] || []
|
|
167
|
+
if results.empty?
|
|
168
|
+
puts "No results."
|
|
169
|
+
return
|
|
170
|
+
end
|
|
171
|
+
results.each do |gif|
|
|
172
|
+
cmd = gif["command"] ? " (#{gif['command']})" : ""
|
|
173
|
+
puts " #{gif['id']} #{gif['title']}#{cmd}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def cmd_list(args)
|
|
178
|
+
require_auth
|
|
179
|
+
opts = parse_opts(args, %w[--repo], [])
|
|
180
|
+
repo = opts["--repo"] || ""
|
|
181
|
+
data = client.list_gifs(repo: repo)
|
|
182
|
+
gifs = data.is_a?(Array) ? data : (data["results"] || [])
|
|
183
|
+
if gifs.empty?
|
|
184
|
+
puts "No GIFs found."
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
gifs.each do |gif|
|
|
188
|
+
puts " #{gif['id']} #{gif['title']}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def cmd_info(args)
|
|
193
|
+
gif_id = args.first
|
|
194
|
+
abort "Usage: agentgif info <gifId>" unless gif_id
|
|
195
|
+
data = client.get_gif(gif_id)
|
|
196
|
+
puts JSON.pretty_generate(data)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def cmd_embed(args)
|
|
200
|
+
opts = parse_opts(args, %w[-f --format], [])
|
|
201
|
+
gif_id = opts[:positional].first
|
|
202
|
+
abort "Usage: agentgif embed <gifId> [-f format]" unless gif_id
|
|
203
|
+
fmt = opts["-f"] || opts["--format"] || "all"
|
|
204
|
+
|
|
205
|
+
data = client.embed_codes(gif_id)
|
|
206
|
+
if fmt == "all"
|
|
207
|
+
data.each do |key, code|
|
|
208
|
+
puts "--- #{key} ---"
|
|
209
|
+
puts code
|
|
210
|
+
puts ""
|
|
211
|
+
end
|
|
212
|
+
else
|
|
213
|
+
code = data[fmt]
|
|
214
|
+
if code
|
|
215
|
+
puts code
|
|
216
|
+
else
|
|
217
|
+
warn "Unknown format: #{fmt}. Available: #{data.keys.join(', ')}"
|
|
218
|
+
exit 1
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def cmd_update(args)
|
|
224
|
+
require_auth
|
|
225
|
+
opts = parse_opts(args, %w[-t --title -d --description -c --command --tags], [])
|
|
226
|
+
gif_id = opts[:positional].first
|
|
227
|
+
abort "Usage: agentgif update <gifId> [options]" unless gif_id
|
|
228
|
+
|
|
229
|
+
fields = {}
|
|
230
|
+
fields["title"] = opts["-t"] || opts["--title"] if opts["-t"] || opts["--title"]
|
|
231
|
+
fields["description"] = opts["-d"] || opts["--description"] if opts["-d"] || opts["--description"]
|
|
232
|
+
fields["command"] = opts["-c"] || opts["--command"] if opts["-c"] || opts["--command"]
|
|
233
|
+
fields["tags"] = opts["--tags"] if opts["--tags"]
|
|
234
|
+
|
|
235
|
+
if fields.empty?
|
|
236
|
+
warn "No fields to update. Use -t, -d, -c, or --tags."
|
|
237
|
+
exit 1
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
data = client.update_gif(gif_id, fields)
|
|
241
|
+
puts "Updated: #{data['id']}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def cmd_delete(args)
|
|
245
|
+
opts = parse_opts(args, [], %w[-y --yes])
|
|
246
|
+
gif_id = opts[:positional].first
|
|
247
|
+
abort "Usage: agentgif delete <gifId> [-y]" unless gif_id
|
|
248
|
+
require_auth
|
|
249
|
+
|
|
250
|
+
unless opts["-y"] || opts["--yes"]
|
|
251
|
+
print "Delete #{gif_id}? [y/N] "
|
|
252
|
+
answer = $stdin.gets&.strip
|
|
253
|
+
unless answer&.downcase == "y"
|
|
254
|
+
puts "Cancelled."
|
|
255
|
+
return
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
client.delete_gif(gif_id)
|
|
260
|
+
puts "Deleted: #{gif_id}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# --- Generate ---
|
|
264
|
+
|
|
265
|
+
def cmd_generate(args)
|
|
266
|
+
require_auth
|
|
267
|
+
opts = parse_opts(args, %w[--max --max-gifs --source-type --pypi --npm], %w[--no-wait])
|
|
268
|
+
|
|
269
|
+
source_url = opts[:positional].first || ""
|
|
270
|
+
source_type = opts["--source-type"] || ""
|
|
271
|
+
max_gifs = (opts["--max"] || opts["--max-gifs"] || "5").to_i
|
|
272
|
+
|
|
273
|
+
if opts["--pypi"]
|
|
274
|
+
pkg = opts["--pypi"]
|
|
275
|
+
source_url = "https://pypi.org/project/#{pkg}/"
|
|
276
|
+
source_type = "pypi"
|
|
277
|
+
elsif opts["--npm"]
|
|
278
|
+
pkg = opts["--npm"]
|
|
279
|
+
source_url = "https://www.npmjs.com/package/#{pkg}"
|
|
280
|
+
source_type = "npm"
|
|
281
|
+
elsif !source_url.empty? && source_type.empty?
|
|
282
|
+
source_type = detect_source_type(source_url)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
abort "Usage: agentgif generate <url> [--pypi PKG] [--npm PKG] [--max N] [--no-wait]" if source_url.empty?
|
|
286
|
+
|
|
287
|
+
job = client.generate_tape(source_url: source_url, source_type: source_type, max_gifs: max_gifs)
|
|
288
|
+
job_id = job["job_id"]
|
|
289
|
+
|
|
290
|
+
if opts["--no-wait"]
|
|
291
|
+
puts "Job created: #{job_id}"
|
|
292
|
+
puts " Check: agentgif generate-status #{job_id}"
|
|
293
|
+
return
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
puts "Generating GIFs..."
|
|
297
|
+
poll_generate_job(job_id)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def cmd_generate_status(args)
|
|
301
|
+
require_auth
|
|
302
|
+
opts = parse_opts(args, [], %w[--poll])
|
|
303
|
+
job_id = opts[:positional].first
|
|
304
|
+
abort "Usage: agentgif generate-status <job_id> [--poll]" unless job_id
|
|
305
|
+
|
|
306
|
+
if opts["--poll"]
|
|
307
|
+
poll_generate_job(job_id)
|
|
308
|
+
else
|
|
309
|
+
data = client.generate_status(job_id)
|
|
310
|
+
puts " Status: #{data['status']}"
|
|
311
|
+
puts " Commands found: #{data['commands_found']}" if data["commands_found"]
|
|
312
|
+
puts " GIFs created: #{data['gifs_created']}" if data["gifs_created"]
|
|
313
|
+
puts " Error: #{data['error_message']}" if data["error_message"]
|
|
314
|
+
gifs = data["gifs"] || []
|
|
315
|
+
unless gifs.empty?
|
|
316
|
+
puts " GIFs:"
|
|
317
|
+
gifs.each do |gif|
|
|
318
|
+
puts " #{gif['id']} #{gif['title']} #{gif['url']}"
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def cmd_record(args)
|
|
325
|
+
require_auth
|
|
326
|
+
opts = parse_opts(args, %w[-t --title -d --description -c --command --tags --theme], %w[--unlisted --no-repo])
|
|
327
|
+
tape_file = opts[:positional].first
|
|
328
|
+
abort "Usage: agentgif record <tape_file> [upload options...]" unless tape_file
|
|
329
|
+
abort "File not found: #{tape_file}" unless File.exist?(tape_file)
|
|
330
|
+
|
|
331
|
+
unless system("which", "vhs", out: File::NULL, err: File::NULL)
|
|
332
|
+
abort "Error: VHS not found. Install from https://github.com/charmbracelet/vhs"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
puts "Running VHS: #{tape_file}"
|
|
336
|
+
unless system("vhs", tape_file)
|
|
337
|
+
abort "VHS recording failed."
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Parse tape file for Output line to find GIF path
|
|
341
|
+
gif_path = nil
|
|
342
|
+
File.readlines(tape_file).each do |line|
|
|
343
|
+
if line.strip.match(/\AOutput\s+(.+)/i)
|
|
344
|
+
gif_path = $1.strip.gsub(/["']/, "")
|
|
345
|
+
break
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
gif_path ||= tape_file.sub(/\.[^.]+\z/, ".gif")
|
|
350
|
+
abort "GIF not found: #{gif_path}" unless File.exist?(gif_path)
|
|
351
|
+
|
|
352
|
+
puts "Uploading: #{gif_path}"
|
|
353
|
+
# Reuse upload logic by building args and calling cmd_upload
|
|
354
|
+
upload_args = [gif_path]
|
|
355
|
+
upload_args.push("-t", opts["-t"] || opts["--title"]) if opts["-t"] || opts["--title"]
|
|
356
|
+
upload_args.push("-d", opts["-d"] || opts["--description"]) if opts["-d"] || opts["--description"]
|
|
357
|
+
upload_args.push("-c", opts["-c"] || opts["--command"]) if opts["-c"] || opts["--command"]
|
|
358
|
+
upload_args.push("--tags", opts["--tags"]) if opts["--tags"]
|
|
359
|
+
upload_args.push("--theme", opts["--theme"]) if opts["--theme"]
|
|
360
|
+
upload_args.push("--unlisted") if opts["--unlisted"]
|
|
361
|
+
upload_args.push("--no-repo") if opts["--no-repo"]
|
|
362
|
+
cmd_upload(upload_args)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# --- Badge ---
|
|
366
|
+
|
|
367
|
+
def cmd_badge(args)
|
|
368
|
+
subcmd = args.shift
|
|
369
|
+
case subcmd
|
|
370
|
+
when "url" then cmd_badge_url(args)
|
|
371
|
+
when "themes" then cmd_badge_themes
|
|
372
|
+
else
|
|
373
|
+
puts "Usage: agentgif badge <url|themes>"
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def cmd_badge_url(args)
|
|
378
|
+
opts = parse_opts(args, %w[-p --provider -k --package -m --metric --theme --style -f --format], [])
|
|
379
|
+
provider = opts["-p"] || opts["--provider"]
|
|
380
|
+
package = opts["-k"] || opts["--package"]
|
|
381
|
+
abort "Usage: agentgif badge url -p <provider> -k <package>" unless provider && package
|
|
382
|
+
|
|
383
|
+
extra = {}
|
|
384
|
+
extra["metric"] = opts["-m"] || opts["--metric"] if opts["-m"] || opts["--metric"]
|
|
385
|
+
extra["theme"] = opts["--theme"] if opts["--theme"]
|
|
386
|
+
extra["style"] = opts["--style"] if opts["--style"]
|
|
387
|
+
fmt = opts["-f"] || opts["--format"] || "all"
|
|
388
|
+
|
|
389
|
+
data = Client.new.badge_url(provider, package, extra)
|
|
390
|
+
if fmt == "all"
|
|
391
|
+
data.each do |key, val|
|
|
392
|
+
puts "#{key}: #{val}"
|
|
393
|
+
end
|
|
394
|
+
else
|
|
395
|
+
val = data[fmt] || data["url"]
|
|
396
|
+
puts val if val
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def cmd_badge_themes
|
|
401
|
+
data = Client.new.badge_themes
|
|
402
|
+
themes = data.is_a?(Array) ? data : (data["themes"] || [])
|
|
403
|
+
themes.each { |t| puts " #{t}" }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# --- Helpers ---
|
|
407
|
+
|
|
408
|
+
def require_auth
|
|
409
|
+
key = Config.get_api_key
|
|
410
|
+
if key.empty?
|
|
411
|
+
warn "Not logged in. Run: agentgif login"
|
|
412
|
+
exit 1
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def client
|
|
417
|
+
@client ||= Client.new
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def detect_source_type(url)
|
|
421
|
+
case url
|
|
422
|
+
when /github\.com/ then "github"
|
|
423
|
+
when /pypi\.org/ then "pypi"
|
|
424
|
+
when /npmjs\.com/ then "npm"
|
|
425
|
+
else ""
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def poll_generate_job(job_id)
|
|
430
|
+
start = Time.now
|
|
431
|
+
prev_status = ""
|
|
432
|
+
loop do
|
|
433
|
+
if Time.now - start > 300
|
|
434
|
+
warn "Timed out after 5 minutes. Check status:"
|
|
435
|
+
warn " agentgif generate-status #{job_id}"
|
|
436
|
+
exit 1
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
sleep(2)
|
|
440
|
+
|
|
441
|
+
begin
|
|
442
|
+
data = client.generate_status(job_id)
|
|
443
|
+
rescue AgentGIF::ApiError => e
|
|
444
|
+
next if e.status >= 500
|
|
445
|
+
raise
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
current = data["status"] || ""
|
|
449
|
+
if current != prev_status
|
|
450
|
+
puts " Status: #{current}"
|
|
451
|
+
prev_status = current
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
case current
|
|
455
|
+
when "completed"
|
|
456
|
+
gifs = data["gifs"] || []
|
|
457
|
+
count = data["gifs_created"] || gifs.length
|
|
458
|
+
puts "Done! #{count} GIFs generated."
|
|
459
|
+
gifs.each do |gif|
|
|
460
|
+
puts " #{gif['id']} #{gif['title']} #{gif['url']}"
|
|
461
|
+
end
|
|
462
|
+
return
|
|
463
|
+
when "failed"
|
|
464
|
+
warn "Generation failed: #{data['error_message'] || 'Unknown error'}"
|
|
465
|
+
exit 1
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def detect_repo
|
|
471
|
+
remote = `git remote get-url origin 2>/dev/null`.strip
|
|
472
|
+
return nil if remote.empty?
|
|
473
|
+
|
|
474
|
+
# git@github.com:user/repo.git → user/repo
|
|
475
|
+
if remote.match?(%r{git@github\.com:(.+?)(?:\.git)?$})
|
|
476
|
+
remote.match(%r{git@github\.com:(.+?)(?:\.git)?$})[1]
|
|
477
|
+
elsif remote.match?(%r{github\.com/(.+?)(?:\.git)?$})
|
|
478
|
+
remote.match(%r{github\.com/(.+?)(?:\.git)?$})[1]
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def open_browser(url)
|
|
483
|
+
case RUBY_PLATFORM
|
|
484
|
+
when /darwin/ then system("open", url)
|
|
485
|
+
when /linux/ then system("xdg-open", url)
|
|
486
|
+
when /mswin|mingw/ then system("start", url)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def parse_opts(args, value_flags, bool_flags)
|
|
491
|
+
result = { positional: [] }
|
|
492
|
+
i = 0
|
|
493
|
+
while i < args.length
|
|
494
|
+
arg = args[i]
|
|
495
|
+
if value_flags.include?(arg)
|
|
496
|
+
i += 1
|
|
497
|
+
result[arg] = args[i]
|
|
498
|
+
elsif bool_flags.include?(arg)
|
|
499
|
+
result[arg] = true
|
|
500
|
+
else
|
|
501
|
+
result[:positional] << arg
|
|
502
|
+
end
|
|
503
|
+
i += 1
|
|
504
|
+
end
|
|
505
|
+
result
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def check_for_updates
|
|
509
|
+
data = Client.new.cli_version
|
|
510
|
+
latest = data["latest"]
|
|
511
|
+
return unless latest
|
|
512
|
+
|
|
513
|
+
current_parts = VERSION.split(".").map(&:to_i)
|
|
514
|
+
latest_parts = latest.split(".").map(&:to_i)
|
|
515
|
+
return unless (latest_parts <=> current_parts) == 1
|
|
516
|
+
|
|
517
|
+
warn "Update available: #{VERSION} → #{latest} (gem install agentgif)"
|
|
518
|
+
rescue StandardError
|
|
519
|
+
# Silently ignore update check failures
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# HTTP client for the AgentGIF API.
|
|
4
|
+
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module AgentGIF
|
|
10
|
+
class ApiError < StandardError
|
|
11
|
+
attr_reader :status
|
|
12
|
+
|
|
13
|
+
def initialize(message, status)
|
|
14
|
+
@status = status
|
|
15
|
+
super("API error #{status}: #{message}")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Client
|
|
20
|
+
BASE_URL = "https://agentgif.com"
|
|
21
|
+
|
|
22
|
+
def initialize(base_url: nil, api_key: nil)
|
|
23
|
+
@base_url = base_url || BASE_URL
|
|
24
|
+
@api_key = api_key || Config.get_api_key
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# --- Auth ---
|
|
28
|
+
|
|
29
|
+
def whoami
|
|
30
|
+
get("/users/me/")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def device_auth
|
|
34
|
+
post("/auth/device/", {})
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def device_token(device_code)
|
|
38
|
+
uri = URI("#{@base_url}/api/v1/auth/device/token/")
|
|
39
|
+
req = Net::HTTP::Post.new(uri)
|
|
40
|
+
req["Content-Type"] = "application/json"
|
|
41
|
+
req.body = JSON.generate({ device_code: device_code })
|
|
42
|
+
resp = execute_raw(uri, req)
|
|
43
|
+
body = parse_body(resp.body)
|
|
44
|
+
[body, resp.code.to_i]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# --- GIFs ---
|
|
48
|
+
|
|
49
|
+
def search(query)
|
|
50
|
+
get("/search/?q=#{encode(query)}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def list_gifs(repo: nil)
|
|
54
|
+
path = repo && !repo.empty? ? "/gifs/me/?repo=#{encode(repo)}" : "/gifs/me/"
|
|
55
|
+
get(path)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get_gif(gif_id)
|
|
59
|
+
get("/gifs/#{gif_id}/")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def embed_codes(gif_id)
|
|
63
|
+
data = get_gif(gif_id)
|
|
64
|
+
data["embed"] || {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def update_gif(gif_id, fields)
|
|
68
|
+
patch("/gifs/#{gif_id}/", fields)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def delete_gif(gif_id)
|
|
72
|
+
response = request(:delete, "/gifs/#{gif_id}/")
|
|
73
|
+
return if response.code.to_i < 400
|
|
74
|
+
|
|
75
|
+
raise ApiError.new(response.body, response.code.to_i)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def upload(gif_path, opts = {})
|
|
79
|
+
boundary = "AgentGIF#{rand(10**16)}"
|
|
80
|
+
body = build_multipart(gif_path, opts, boundary)
|
|
81
|
+
|
|
82
|
+
uri = URI("#{@base_url}/api/v1/gifs/")
|
|
83
|
+
req = Net::HTTP::Post.new(uri)
|
|
84
|
+
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
85
|
+
req["Authorization"] = "Token #{@api_key}" unless @api_key.empty?
|
|
86
|
+
req.body = body
|
|
87
|
+
|
|
88
|
+
resp = execute_raw(uri, req)
|
|
89
|
+
handle_response(resp)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# --- Badges ---
|
|
93
|
+
|
|
94
|
+
def badge_url(provider, package, opts = {})
|
|
95
|
+
params = ["provider=#{encode(provider)}", "package=#{encode(package)}"]
|
|
96
|
+
opts.each { |k, v| params << "#{encode(k.to_s)}=#{encode(v)}" unless v.to_s.empty? }
|
|
97
|
+
get("/badge-url/?#{params.join('&')}")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def badge_themes
|
|
101
|
+
get("/themes/badges/")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- Generate ---
|
|
105
|
+
|
|
106
|
+
def generate_tape(source_url: "", source_type: "", max_gifs: 5, raw_markdown: "")
|
|
107
|
+
payload = { "max_gifs" => max_gifs }
|
|
108
|
+
payload["source_url"] = source_url unless source_url.empty?
|
|
109
|
+
payload["source_type"] = source_type unless source_type.empty?
|
|
110
|
+
if !raw_markdown.empty?
|
|
111
|
+
payload["source_type"] = "raw"
|
|
112
|
+
payload["raw_markdown"] = raw_markdown
|
|
113
|
+
end
|
|
114
|
+
post("/gifs/generate/", payload)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def generate_status(job_id)
|
|
118
|
+
get("/gifs/generate/#{job_id}/")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# --- Version ---
|
|
122
|
+
|
|
123
|
+
def cli_version
|
|
124
|
+
get("/cli/version/")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def get(path)
|
|
130
|
+
resp = request(:get, path)
|
|
131
|
+
handle_response(resp)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def post(path, body)
|
|
135
|
+
resp = request(:post, path, JSON.generate(body))
|
|
136
|
+
handle_response(resp)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def patch(path, body)
|
|
140
|
+
resp = request(:patch, path, JSON.generate(body))
|
|
141
|
+
handle_response(resp)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def request(method, path, body = nil)
|
|
145
|
+
uri = URI("#{@base_url}/api/v1#{path}")
|
|
146
|
+
klass = {
|
|
147
|
+
get: Net::HTTP::Get,
|
|
148
|
+
post: Net::HTTP::Post,
|
|
149
|
+
patch: Net::HTTP::Patch,
|
|
150
|
+
delete: Net::HTTP::Delete
|
|
151
|
+
}.fetch(method)
|
|
152
|
+
|
|
153
|
+
req = klass.new(uri)
|
|
154
|
+
req["Content-Type"] = "application/json"
|
|
155
|
+
req["Authorization"] = "Token #{@api_key}" unless @api_key.empty?
|
|
156
|
+
req.body = body if body
|
|
157
|
+
|
|
158
|
+
execute_raw(uri, req)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def execute_raw(uri, req)
|
|
162
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
163
|
+
http.use_ssl = uri.scheme == "https"
|
|
164
|
+
http.open_timeout = 30
|
|
165
|
+
http.read_timeout = 60
|
|
166
|
+
http.request(req)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def handle_response(resp)
|
|
170
|
+
status = resp.code.to_i
|
|
171
|
+
body = resp.body || ""
|
|
172
|
+
if status >= 400
|
|
173
|
+
msg = begin
|
|
174
|
+
obj = JSON.parse(body)
|
|
175
|
+
obj["error"] || obj["detail"] || body
|
|
176
|
+
rescue JSON::ParserError
|
|
177
|
+
body
|
|
178
|
+
end
|
|
179
|
+
raise ApiError.new(msg, status)
|
|
180
|
+
end
|
|
181
|
+
parse_body(body)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def parse_body(body)
|
|
185
|
+
return nil if body.nil? || body.empty?
|
|
186
|
+
|
|
187
|
+
JSON.parse(body)
|
|
188
|
+
rescue JSON::ParserError
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def encode(s)
|
|
193
|
+
URI.encode_www_form_component(s.to_s)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def build_multipart(gif_path, opts, boundary)
|
|
197
|
+
parts = []
|
|
198
|
+
|
|
199
|
+
# GIF file part
|
|
200
|
+
file_data = File.binread(gif_path)
|
|
201
|
+
file_name = File.basename(gif_path)
|
|
202
|
+
parts << "--#{boundary}\r\n" \
|
|
203
|
+
"Content-Disposition: form-data; name=\"gif\"; filename=\"#{file_name}\"\r\n" \
|
|
204
|
+
"Content-Type: image/gif\r\n\r\n" \
|
|
205
|
+
"#{file_data}\r\n"
|
|
206
|
+
|
|
207
|
+
# Text fields
|
|
208
|
+
opts.each do |key, value|
|
|
209
|
+
next if value.to_s.empty?
|
|
210
|
+
next if key.to_s == "cast_path"
|
|
211
|
+
|
|
212
|
+
parts << "--#{boundary}\r\n" \
|
|
213
|
+
"Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n" \
|
|
214
|
+
"#{value}\r\n"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Cast file (optional)
|
|
218
|
+
cast_path = opts["cast_path"] || opts[:cast_path]
|
|
219
|
+
if cast_path && !cast_path.empty? && File.exist?(cast_path)
|
|
220
|
+
cast_data = File.binread(cast_path)
|
|
221
|
+
cast_name = File.basename(cast_path)
|
|
222
|
+
parts << "--#{boundary}\r\n" \
|
|
223
|
+
"Content-Disposition: form-data; name=\"cast\"; filename=\"#{cast_name}\"\r\n" \
|
|
224
|
+
"Content-Type: application/octet-stream\r\n\r\n" \
|
|
225
|
+
"#{cast_data}\r\n"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
parts << "--#{boundary}--\r\n"
|
|
229
|
+
parts.join
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Config storage at ~/.config/agentgif/config.json
|
|
4
|
+
|
|
5
|
+
require "json"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module AgentGIF
|
|
9
|
+
module Config
|
|
10
|
+
CONFIG_DIR = File.join(
|
|
11
|
+
ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")),
|
|
12
|
+
"agentgif"
|
|
13
|
+
)
|
|
14
|
+
CONFIG_PATH = File.join(CONFIG_DIR, "config.json")
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def load_config
|
|
19
|
+
return {} unless File.exist?(CONFIG_PATH)
|
|
20
|
+
|
|
21
|
+
JSON.parse(File.read(CONFIG_PATH))
|
|
22
|
+
rescue JSON::ParserError
|
|
23
|
+
{}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def save_config(cfg)
|
|
27
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
28
|
+
File.write(CONFIG_PATH, "#{JSON.pretty_generate(cfg)}\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get_api_key
|
|
32
|
+
load_config["api_key"] || ""
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def save_credentials(api_key, username)
|
|
36
|
+
cfg = load_config
|
|
37
|
+
cfg["api_key"] = api_key
|
|
38
|
+
cfg["username"] = username
|
|
39
|
+
save_config(cfg)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def clear_credentials
|
|
43
|
+
cfg = load_config
|
|
44
|
+
cfg.delete("api_key")
|
|
45
|
+
cfg.delete("username")
|
|
46
|
+
save_config(cfg)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: agentgif
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- AgentGIF
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-17 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: json
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0'
|
|
27
|
+
description: Upload, search, and manage terminal demo GIFs on AgentGIF.com. Generate
|
|
28
|
+
terminal-themed package badges.
|
|
29
|
+
email:
|
|
30
|
+
- hello@agentgif.com
|
|
31
|
+
executables:
|
|
32
|
+
- agentgif
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- bin/agentgif
|
|
37
|
+
- lib/agentgif/cli.rb
|
|
38
|
+
- lib/agentgif/client.rb
|
|
39
|
+
- lib/agentgif/config.rb
|
|
40
|
+
homepage: https://agentgif.com
|
|
41
|
+
licenses:
|
|
42
|
+
- MIT
|
|
43
|
+
metadata:
|
|
44
|
+
source_code_uri: https://github.com/agentgif/cli-ruby
|
|
45
|
+
bug_tracker_uri: https://github.com/agentgif/cli-ruby/issues
|
|
46
|
+
documentation_uri: https://agentgif.com/docs/cli/
|
|
47
|
+
post_install_message:
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '3.0'
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 3.0.3.1
|
|
63
|
+
signing_key:
|
|
64
|
+
specification_version: 4
|
|
65
|
+
summary: CLI for AgentGIF — upload, manage, and share terminal GIFs
|
|
66
|
+
test_files: []
|