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 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
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/agentgif/cli"
5
+
6
+ AgentGIF::CLI.check_for_updates
7
+ AgentGIF::CLI.run(ARGV.dup)
@@ -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: []