activerabbit-cli 1.0.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.
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module ActiveRabbit
7
+ module Formatters
8
+ def self.format(payload, format)
9
+ case format.to_s.downcase
10
+ when "json" then Json.format(payload)
11
+ when "md", "markdown" then Markdown.format(payload)
12
+ else Human.format(payload)
13
+ end
14
+ end
15
+
16
+ module Json
17
+ def self.format(payload)
18
+ out = {
19
+ "project" => payload["project"],
20
+ "generated_at" => payload["generated_at"],
21
+ "command" => payload["command"],
22
+ "data" => payload["data"]
23
+ }
24
+ JSON.pretty_generate(out)
25
+ end
26
+ end
27
+
28
+ module Markdown
29
+ def self.format(payload)
30
+ cmd = payload["command"]
31
+ data = payload["data"] || {}
32
+ case cmd
33
+ when "apps" then apps_md(data)
34
+ when "status" then status_md(data)
35
+ when "incidents" then incidents_md(data)
36
+ when "incident_detail" then incident_detail_md(data)
37
+ when "explain" then explain_md(data)
38
+ when "trace" then trace_md(data)
39
+ when "deploy_check" then deploy_check_md(data)
40
+ when "doctor" then doctor_md(data)
41
+ else generic_md(payload)
42
+ end
43
+ end
44
+
45
+ def self.apps_md(data)
46
+ list = data["apps"] || []
47
+ rows = list.map { |a| "| #{a["slug"]} | #{a["name"]} | #{a["environment"]} | #{a["error_count_24h"]} |" }
48
+ <<~MD
49
+ ## Apps
50
+ | Slug | Name | Env | Errors (24h) |
51
+ |------|------|-----|-------------:|
52
+ #{rows.join("\n")}
53
+ MD
54
+ end
55
+
56
+ def self.status_md(data)
57
+ top = data["top_issue"]
58
+ <<~MD
59
+ ## #{data["name"]} (#{data["app"]})
60
+ **Health:** #{data["health"]} · **Errors (24h):** #{data["error_count_24h"]} · **p95:** #{data["p95_latency_ms"]} ms
61
+ **Deploy:** #{data["deploy_status"]} · Last: #{data["last_deploy_at"]}
62
+
63
+ #{top ? "**Top issue:** #{top["title"]} (#{top["count"]} events, #{top["severity"]})" : ""}
64
+ MD
65
+ end
66
+
67
+ def self.incidents_md(data)
68
+ list = data["incidents"] || []
69
+ rows = list.map { |i| "| #{i["id"]} | #{i["severity"]} | #{i["title"]} | #{i["endpoint"]} | #{i["count"]} | #{i["last_seen_at"]} |" }
70
+ <<~MD
71
+ ## Incidents
72
+ | ID | Severity | Title | Endpoint | Count | Last seen |
73
+ |---|---:|---|---|---:|---|
74
+ #{rows.join("\n")}
75
+ MD
76
+ end
77
+
78
+ def self.incident_detail_md(data)
79
+ <<~MD
80
+ ## #{data["exception_class"]}: #{data["message"]}
81
+ **ID:** #{data["id"]} · **Severity:** #{data["severity"]} · **Status:** #{data["status"]}
82
+ **Endpoint:** #{data["endpoint"]} · **Count:** #{data["count"]} · **Affected users:** #{data["affected_users"]}
83
+
84
+ ### Stack trace
85
+ ```
86
+ #{Array(data["backtrace"]).join("\n")}
87
+ ```
88
+
89
+ First seen: #{data["first_seen_at"]} · Last seen: #{data["last_seen_at"]}
90
+ MD
91
+ end
92
+
93
+ def self.explain_md(data)
94
+ <<~MD
95
+ ## #{data["title"]} (#{data["incident_id"]})
96
+ **Severity:** #{data["severity"]} · **Confidence:** #{(data["confidence_score"] * 100).to_i}%
97
+
98
+ ### Root cause
99
+ #{data["root_cause"]}
100
+
101
+ ### Suggested fix
102
+ #{data["suggested_fix"]}
103
+
104
+ ### Affected endpoints
105
+ #{Array(data["affected_endpoints"]).join(", ")}
106
+
107
+ **Regression risk:** #{data["regression_risk"]} · **Tests to run:** #{Array(data["tests_to_run"]).join(", ")}
108
+ MD
109
+ end
110
+
111
+ def self.trace_md(data)
112
+ spans = (data["spans"] || []).map { |s| "- #{s["name"]}: #{s["duration_ms"]} ms (#{s["percent"]}%)" }
113
+ bottlenecks = Array(data["bottlenecks"]).map { |b| "- #{b}" }
114
+ <<~MD
115
+ ## Trace: #{data["endpoint"]} (#{data["trace_id"]})
116
+ **Duration:** #{data["duration_ms"]} ms
117
+
118
+ ### Spans
119
+ #{spans.join("\n")}
120
+
121
+ ### Bottlenecks
122
+ #{bottlenecks.join("\n")}
123
+ MD
124
+ end
125
+
126
+ def self.deploy_check_md(data)
127
+ warnings = Array(data["warnings"]).map { |w| "- #{w}" }
128
+ <<~MD
129
+ ## Deploy check
130
+ **Ready:** #{data["ready"]} · **Last deploy:** #{data["last_deploy_at"]}
131
+ **New errors since deploy:** #{data["new_errors_since_deploy"]}
132
+ #{warnings.any? ? "**Warnings:**\n#{warnings.join("\n")}" : ""}
133
+ MD
134
+ end
135
+
136
+ def self.doctor_md(data)
137
+ <<~MD
138
+ ## Doctor
139
+ **Config:** #{data["config_ok"]} · **API:** #{data["api_ok"]} · **App:** #{data["app_ok"]}
140
+ #{data["message"]}
141
+ MD
142
+ end
143
+
144
+ def self.generic_md(payload)
145
+ "## #{payload["command"]}\n\n```json\n#{JSON.pretty_generate(payload["data"])}\n```"
146
+ end
147
+ end
148
+
149
+ module Human
150
+ def self.format(payload)
151
+ cmd = payload["command"]
152
+ data = payload["data"] || {}
153
+ case cmd
154
+ when "apps" then apps_human(data)
155
+ when "status" then status_human(data)
156
+ when "incidents" then incidents_human(data)
157
+ when "incident_detail" then incident_detail_human(data)
158
+ when "explain" then explain_human(data)
159
+ when "trace" then trace_human(data)
160
+ when "deploy_check" then deploy_check_human(data)
161
+ when "doctor" then doctor_human(data)
162
+ when "login" then "Logged in. Config saved to #{payload["config_path"]}"
163
+ else JSON.pretty_generate(data)
164
+ end
165
+ end
166
+
167
+ def self.apps_human(data)
168
+ list = data["apps"] || []
169
+ return "No apps found." if list.empty?
170
+
171
+ lines = [UI.c("Apps:", :bold), ""]
172
+ list.each do |app|
173
+ env_badge = app["environment"] == "production" ? UI.c("prod", :green) : UI.c(app["environment"], :gray)
174
+ errors = app["error_count_24h"].to_i > 0 ? UI.c("#{app["error_count_24h"]} errors", :red) : UI.c("0 errors", :gray)
175
+ lines << " #{UI.c(app["slug"], :cyan)} #{app["name"]} #{env_badge} #{errors}"
176
+ end
177
+ lines << ""
178
+ lines << "Use: activerabbit use-app <slug>"
179
+ lines.join("\n")
180
+ end
181
+
182
+ def self.status_human(data)
183
+ top = data["top_issue"]
184
+ health_color = data["health"] == "ok" ? :green : :red
185
+ lines = [
186
+ "#{UI.c(data["name"], :bold)} (#{data["app"]})",
187
+ "",
188
+ "Health: #{UI.c(data["health"], health_color)} Errors (24h): #{data["error_count_24h"]} p95: #{data["p95_latency_ms"]} ms",
189
+ "Deploy: #{data["deploy_status"]} Last: #{UI.relative_time(data["last_deploy_at"])}"
190
+ ]
191
+ if top
192
+ lines << ""
193
+ lines << UI.c("Top issue:", :bold)
194
+ lines << " #{UI.severity(top["severity"])} #{top["title"]}"
195
+ lines << " #{top["count"]} events · #{top["id"]}"
196
+ end
197
+ lines.join("\n")
198
+ end
199
+
200
+ def self.incidents_human(data)
201
+ list = data["incidents"] || []
202
+ return "No incidents found." if list.empty?
203
+
204
+ lines = []
205
+ lines << ""
206
+ list.each_with_index do |inc, idx|
207
+ severity = UI.severity(inc["severity"])
208
+ title = UI.truncate(inc["title"], 55)
209
+ time_ago = UI.relative_time(inc["last_seen_at"])
210
+ count = inc["count"].to_s.rjust(5)
211
+ status_icon = case inc["status"]
212
+ when "wip" then UI.c("◐", :yellow)
213
+ when "closed" then UI.c("✓", :green)
214
+ else UI.c("●", :red)
215
+ end
216
+
217
+ lines << "#{status_icon} #{severity} #{UI.c(inc["id"], :cyan)}"
218
+ lines << " #{title}"
219
+ lines << " #{UI.c(inc["endpoint"], :gray)} #{UI.c(count, :bold)} events #{UI.c(time_ago, :gray)}"
220
+ lines << "" if idx < list.size - 1
221
+ end
222
+ lines << ""
223
+ lines << UI.c("Use: activerabbit show <id> or activerabbit explain <id>", :gray)
224
+ lines.join("\n")
225
+ end
226
+
227
+ def self.incident_detail_human(data)
228
+ severity = UI.severity(data["severity"])
229
+ status_color = { "open" => :red, "wip" => :yellow, "closed" => :green }[data["status"]] || :gray
230
+
231
+ lines = []
232
+ lines << UI.box("#{data["exception_class"]}", [
233
+ "#{UI.c(data["message"], :bold)}",
234
+ "",
235
+ "#{severity} #{UI.c(data["status"].upcase, status_color)} #{UI.c(data["id"], :cyan)}",
236
+ "",
237
+ "#{UI.c("Endpoint:", :gray)} #{data["endpoint"]}",
238
+ "#{UI.c("Count:", :gray)} #{data["count"]} #{UI.c("Affected users:", :gray)} #{data["affected_users"]}",
239
+ "#{UI.c("First:", :gray)} #{UI.relative_time(data["first_seen_at"])} #{UI.c("Last:", :gray)} #{UI.relative_time(data["last_seen_at"])}"
240
+ ])
241
+
242
+ lines << ""
243
+ lines << UI.c("Stack trace:", :bold)
244
+ Array(data["backtrace"]).first(8).each do |frame|
245
+ if frame.include?("app/")
246
+ lines << " #{UI.c(frame, :cyan)}"
247
+ else
248
+ lines << " #{UI.c(frame, :gray)}"
249
+ end
250
+ end
251
+ if Array(data["backtrace"]).size > 8
252
+ lines << " #{UI.c("... #{data["backtrace"].size - 8} more frames", :gray)}"
253
+ end
254
+
255
+ if data["recent_events"]&.any?
256
+ lines << ""
257
+ lines << UI.c("Recent events:", :bold)
258
+ data["recent_events"].first(5).each do |evt|
259
+ lines << " #{UI.relative_time(evt["at"])} user:#{evt["user_id"]} req:#{evt["request_id"]}"
260
+ end
261
+ end
262
+
263
+ lines << ""
264
+ lines << UI.c("Run: activerabbit explain #{data["id"]}", :gray)
265
+ lines.join("\n")
266
+ end
267
+
268
+ def self.explain_human(data)
269
+ lines = [
270
+ UI.c("#{data["title"]} (#{data["incident_id"]})", :bold),
271
+ "#{UI.severity(data["severity"])} Confidence: #{(data["confidence_score"] * 100).to_i}%",
272
+ "",
273
+ UI.c("Root cause:", :bold),
274
+ " #{data["root_cause"]}",
275
+ "",
276
+ UI.c("Suggested fix:", :bold),
277
+ " #{data["suggested_fix"]}",
278
+ "",
279
+ "#{UI.c("Affected:", :gray)} #{Array(data["affected_endpoints"]).join(", ")}",
280
+ "#{UI.c("Regression risk:", :gray)} #{data["regression_risk"]}",
281
+ "#{UI.c("Tests to run:", :gray)} #{Array(data["tests_to_run"]).join(", ")}"
282
+ ]
283
+ lines.join("\n")
284
+ end
285
+
286
+ def self.trace_human(data)
287
+ lines = [
288
+ UI.c("Trace: #{data["endpoint"]}", :bold) + " (#{data["trace_id"]})",
289
+ "Duration: #{data["duration_ms"]} ms",
290
+ "",
291
+ UI.c("Spans:", :bold)
292
+ ]
293
+ (data["spans"] || []).each do |s|
294
+ bar_len = [s["percent"].to_i / 5, 1].max
295
+ bar = UI.c("█" * bar_len, :cyan) + UI.c("░" * (20 - bar_len), :gray)
296
+ lines << " #{bar} #{s["duration_ms"].to_s.rjust(5)} ms #{s["name"]}"
297
+ end
298
+ lines << ""
299
+ lines << UI.c("Bottlenecks:", :bold)
300
+ Array(data["bottlenecks"]).each { |b| lines << " #{UI.c("·", :red)} #{b}" }
301
+ lines.join("\n")
302
+ end
303
+
304
+ def self.deploy_check_human(data)
305
+ ready_icon = data["ready"] ? UI.c("✓", :green) : UI.c("✗", :red)
306
+ lines = [
307
+ "Deploy check: #{ready_icon} #{data["ready"] ? "ready" : "not ready"}",
308
+ "Last deploy: #{UI.relative_time(data["last_deploy_at"])}",
309
+ "New errors since deploy: #{data["new_errors_since_deploy"]}"
310
+ ]
311
+ Array(data["warnings"]).each { |w| lines << " #{UI.c("⚠", :yellow)} #{w}" }
312
+ lines.join("\n")
313
+ end
314
+
315
+ def self.doctor_human(data)
316
+ cfg_icon = data["config_ok"] ? UI.c("✓", :green) : UI.c("✗", :red)
317
+ api_icon = data["api_ok"] ? UI.c("✓", :green) : UI.c("✗", :red)
318
+ app_icon = data["app_ok"] ? UI.c("✓", :green) : UI.c("✗", :red)
319
+ [
320
+ "Config: #{cfg_icon} API: #{api_icon} App: #{app_icon}",
321
+ "",
322
+ data["message"]
323
+ ].join("\n")
324
+ end
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRabbit
4
+ # Terminal UI helpers: colors, relative time, box drawing
5
+ module UI
6
+ COLORS = {
7
+ red: "\e[31m",
8
+ green: "\e[32m",
9
+ yellow: "\e[33m",
10
+ blue: "\e[34m",
11
+ magenta: "\e[35m",
12
+ cyan: "\e[36m",
13
+ gray: "\e[90m",
14
+ bold: "\e[1m",
15
+ reset: "\e[0m"
16
+ }.freeze
17
+
18
+ SEVERITY_COLORS = {
19
+ "critical" => :red,
20
+ "high" => :red,
21
+ "medium" => :yellow,
22
+ "low" => :cyan,
23
+ "info" => :gray
24
+ }.freeze
25
+
26
+ class << self
27
+ def color_enabled?
28
+ return @color_enabled if defined?(@color_enabled)
29
+
30
+ @color_enabled = $stdout.tty? && ENV["NO_COLOR"].nil?
31
+ end
32
+
33
+ def color_enabled=(val)
34
+ @color_enabled = val
35
+ end
36
+
37
+ def c(text, *styles)
38
+ return text.to_s unless color_enabled?
39
+
40
+ codes = styles.map { |s| COLORS[s] }.compact.join
41
+ "#{codes}#{text}#{COLORS[:reset]}"
42
+ end
43
+
44
+ def severity(level)
45
+ color = SEVERITY_COLORS[level.to_s.downcase] || :gray
46
+ padded = level.to_s.upcase.ljust(8)
47
+ c(padded, color, :bold)
48
+ end
49
+
50
+ def relative_time(iso_time)
51
+ return "—" if iso_time.nil? || iso_time.to_s.strip == ""
52
+
53
+ begin
54
+ t = Time.parse(iso_time.to_s)
55
+ rescue ArgumentError
56
+ return iso_time.to_s[0, 16]
57
+ end
58
+
59
+ diff = Time.now - t
60
+ if diff < 60
61
+ "just now"
62
+ elsif diff < 3600
63
+ "#{(diff / 60).to_i}m ago"
64
+ elsif diff < 86400
65
+ "#{(diff / 3600).to_i}h ago"
66
+ elsif diff < 604800
67
+ "#{(diff / 86400).to_i}d ago"
68
+ else
69
+ t.strftime("%b %d")
70
+ end
71
+ end
72
+
73
+ def truncate(str, len)
74
+ s = str.to_s
75
+ s.length > len ? "#{s[0, len - 1]}…" : s
76
+ end
77
+
78
+ def box(title, lines)
79
+ width = [title.length + 4, lines.map { |l| strip_ansi(l).length }.max || 40].max + 2
80
+ bar = "─" * width
81
+ out = []
82
+ out << "┌#{bar}┐"
83
+ out << "│ #{c(title, :bold)}#{' ' * (width - title.length - 1)}│"
84
+ out << "├#{bar}┤"
85
+ lines.each do |line|
86
+ plain_len = strip_ansi(line).length
87
+ padding = width - plain_len - 1
88
+ out << "│ #{line}#{' ' * [padding, 0].max}│"
89
+ end
90
+ out << "└#{bar}┘"
91
+ out.join("\n")
92
+ end
93
+
94
+ def strip_ansi(str)
95
+ str.to_s.gsub(/\e\[[0-9;]*m/, "")
96
+ end
97
+
98
+ def prompt_choice(message, options)
99
+ $stderr.puts message
100
+ options.each_with_index do |opt, idx|
101
+ $stderr.puts " #{c("#{idx + 1})", :cyan)} #{opt}"
102
+ end
103
+ $stderr.print c("Choice [1-#{options.size}]: ", :bold)
104
+ input = $stdin.gets&.to_s&.strip
105
+ idx = input.to_i - 1
106
+ return nil if idx < 0 || idx >= options.size
107
+
108
+ options[idx]
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRabbit
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "activerabbit/version"
4
+ require "activerabbit/ui"
5
+ require "activerabbit/config"
6
+ require "activerabbit/api_client"
7
+ require "activerabbit/formatters"
8
+ require "activerabbit/cli"
9
+ require "activerabbit/commands"
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerabbit-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - ActiveRabbit
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-02-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: 'Production CLI for ActiveRabbit: status, incidents, explain, trace.
42
+ Human and machine-readable output (JSON, Markdown). Designed for developers and
43
+ AI agents.'
44
+ email:
45
+ - support@activerabbit.ai
46
+ executables:
47
+ - activerabbit
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - CHANGELOG.md
52
+ - LICENSE.txt
53
+ - README.md
54
+ - exe/activerabbit
55
+ - lib/activerabbit.rb
56
+ - lib/activerabbit/api_client.rb
57
+ - lib/activerabbit/cli.rb
58
+ - lib/activerabbit/commands.rb
59
+ - lib/activerabbit/config.rb
60
+ - lib/activerabbit/formatters.rb
61
+ - lib/activerabbit/ui.rb
62
+ - lib/activerabbit/version.rb
63
+ homepage: https://activerabbit.ai
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ homepage_uri: https://activerabbit.ai
68
+ source_code_uri: https://github.com/activerabbit/activerabbit-cli
69
+ changelog_uri: https://github.com/activerabbit/activerabbit-cli/blob/main/CHANGELOG.md
70
+ rubygems_mfa_required: 'true'
71
+ post_install_message:
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 3.0.0
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 3.5.15
87
+ signing_key:
88
+ specification_version: 4
89
+ summary: CLI for ActiveRabbit.ai — monitoring and issue analysis
90
+ test_files: []