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,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module ActiveRabbit
6
+ # Main CLI entry: parses global options (--json, --md, --app, etc.) and dispatches to commands.
7
+ class CLI
8
+ def initialize(argv = ARGV)
9
+ @argv = argv.dup
10
+ @format = "human"
11
+ @app = nil
12
+ @base_url = nil
13
+ parse_global_options!
14
+ end
15
+
16
+ def run
17
+ cmd = @argv.shift
18
+ case cmd
19
+ when "login" then run_login
20
+ when "apps" then run_apps
21
+ when "use-app", "use" then run_use_app
22
+ when "status" then run_status
23
+ when "incidents", "errors" then run_incidents
24
+ when "show" then run_show_incident
25
+ when "explain" then run_explain
26
+ when "trace" then run_trace
27
+ when "deploy-check" then run_deploy_check
28
+ when "doctor" then run_doctor
29
+ when "version", "-v", "--version" then run_version
30
+ when "help", "-h", "--help", nil then run_help
31
+ else
32
+ $stderr.puts "activerabbit: unknown command '#{cmd}'"
33
+ $stderr.puts "Run 'activerabbit help' for usage."
34
+ exit 1
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def parse_global_options!
41
+ parser = OptionParser.new do |opts|
42
+ opts.on("--json", "Machine-readable JSON output") { @format = "json" }
43
+ opts.on("--md", "Markdown output (Slack/GitHub)") { @format = "md" }
44
+ opts.on("--app=NAME", "App slug (overrides config)") { |v| @app = v }
45
+ opts.on("--project-id=ID", "Project ID (legacy, same as --app)") { |v| @app = v }
46
+ opts.on("--base-url=URL", "API base URL") { |v| @base_url = v }
47
+ end
48
+ parser.order!(@argv)
49
+ end
50
+
51
+ def config
52
+ @config ||= Config.new(default_app: @app, base_url: @base_url)
53
+ end
54
+
55
+ def client
56
+ @client ||= ApiClient.new(config)
57
+ end
58
+
59
+ def output(payload)
60
+ puts Formatters.format(payload, @format)
61
+ end
62
+
63
+ # --- Login ---
64
+
65
+ def run_login
66
+ opts = {}
67
+ parser = OptionParser.new do |o|
68
+ o.on("--api-key=KEY", "API key (project token)") { |v| opts[:api_key] = v }
69
+ o.on("--base-url=URL") { |v| opts[:base_url] = v }
70
+ o.on("--app=NAME") { |v| opts[:default_app] = v }
71
+ o.on("--project-id=ID") { |v| opts[:default_app] = v }
72
+ end
73
+ parser.parse!(@argv)
74
+
75
+ api_key = opts[:api_key] || ENV["ACTIVERABBIT_API_KEY"]
76
+ if api_key.to_s.strip == ""
77
+ $stderr.print "API key (X-Project-Token): "
78
+ api_key = $stdin.gets&.to_s&.strip
79
+ end
80
+ if api_key.to_s.strip == ""
81
+ $stderr.puts "Login aborted (no API key)."
82
+ exit 1
83
+ end
84
+
85
+ config.save!(api_key: api_key, base_url: opts[:base_url], default_app: opts[:default_app])
86
+
87
+ puts UI.c("Logged in.", :green)
88
+ puts "Config saved to #{config.config_path}"
89
+ puts "Default app: #{config.default_app || "(not set)"}"
90
+ end
91
+
92
+ # --- Apps ---
93
+
94
+ def run_apps
95
+ require_auth!
96
+ payload = client.list_apps
97
+ output(payload)
98
+ rescue ApiClient::Unauthorized, ApiClient::NotFound, ApiClient::NetworkError => e
99
+ error_exit(e.message)
100
+ end
101
+
102
+ def run_use_app
103
+ app_name = @argv.shift
104
+ if app_name.to_s.strip == ""
105
+ $stderr.puts "Usage: activerabbit use-app <app-slug>"
106
+ $stderr.puts "Run 'activerabbit apps' to see available apps."
107
+ exit 1
108
+ end
109
+
110
+ config.save!(default_app: app_name)
111
+ puts UI.c("Default app set to #{app_name}", :green)
112
+ end
113
+
114
+ # --- Status ---
115
+
116
+ def run_status
117
+ ensure_app_selected!
118
+ payload = client.project_status
119
+ output(payload)
120
+ rescue ApiClient::Unauthorized, ApiClient::NotFound, ApiClient::NetworkError => e
121
+ error_exit(e.message)
122
+ end
123
+
124
+ # --- Incidents ---
125
+
126
+ def run_incidents
127
+ ensure_app_selected!
128
+ limit = 10
129
+ OptionParser.new do |o|
130
+ o.on("--limit=N", Integer) { |n| limit = n }
131
+ end.parse!(@argv)
132
+ payload = client.incidents(limit: limit)
133
+ output(payload)
134
+ rescue ApiClient::Unauthorized, ApiClient::NotFound, ApiClient::NetworkError => e
135
+ error_exit(e.message)
136
+ end
137
+
138
+ def run_show_incident
139
+ ensure_app_selected!
140
+ incident_id = @argv.shift
141
+ if incident_id.to_s.strip == ""
142
+ $stderr.puts "Usage: activerabbit show <incident-id>"
143
+ $stderr.puts "Example: activerabbit show inc_abc123"
144
+ exit 1
145
+ end
146
+ payload = client.incident_detail(incident_id)
147
+ output(payload)
148
+ rescue ApiClient::Unauthorized, ApiClient::NotFound, ApiClient::NetworkError => e
149
+ error_exit(e.message)
150
+ end
151
+
152
+ # --- Explain ---
153
+
154
+ def run_explain
155
+ ensure_app_selected!
156
+ incident_id = @argv.shift
157
+ if incident_id.to_s.strip == ""
158
+ $stderr.puts "Usage: activerabbit explain <incident-id>"
159
+ $stderr.puts "Example: activerabbit explain inc_abc123"
160
+ exit 1
161
+ end
162
+ payload = client.explain_incident(incident_id)
163
+ output(payload)
164
+ rescue ApiClient::Unauthorized, ApiClient::NotFound, ApiClient::NetworkError => e
165
+ error_exit(e.message)
166
+ end
167
+
168
+ # --- Trace ---
169
+
170
+ def run_trace
171
+ ensure_app_selected!
172
+ endpoint_or_id = @argv.shift
173
+ if endpoint_or_id.to_s.strip == ""
174
+ $stderr.puts "Usage: activerabbit trace <endpoint-or-trace-id>"
175
+ $stderr.puts "Example: activerabbit trace /jobs"
176
+ $stderr.puts "Example: activerabbit trace tr_xyz789"
177
+ exit 1
178
+ end
179
+ payload = client.trace(endpoint_or_id)
180
+ output(payload)
181
+ rescue ApiClient::Unauthorized, ApiClient::NotFound, ApiClient::NetworkError => e
182
+ error_exit(e.message)
183
+ end
184
+
185
+ # --- Deploy check ---
186
+
187
+ def run_deploy_check
188
+ ensure_app_selected!
189
+ payload = client.deploy_check
190
+ output(payload)
191
+ rescue ApiClient::Unauthorized, ApiClient::NotFound, ApiClient::NetworkError => e
192
+ error_exit(e.message)
193
+ end
194
+
195
+ # --- Doctor ---
196
+
197
+ def run_doctor
198
+ payload = Commands::Doctor.run(config, client)
199
+ output(payload)
200
+ rescue ApiClient::NetworkError => e
201
+ payload = {
202
+ "project" => config.effective_app,
203
+ "generated_at" => Time.now.utc.iso8601,
204
+ "command" => "doctor",
205
+ "data" => {
206
+ "config_ok" => config.configured?,
207
+ "api_ok" => false,
208
+ "app_ok" => config.app_configured?,
209
+ "message" => e.message
210
+ }
211
+ }
212
+ output(payload)
213
+ end
214
+
215
+ # --- Version / Help ---
216
+
217
+ def run_version
218
+ puts "activerabbit #{ActiveRabbit::VERSION}"
219
+ end
220
+
221
+ def run_help
222
+ puts <<~HELP
223
+ #{UI.c("activerabbit", :bold)} — CLI for ActiveRabbit.ai
224
+
225
+ #{UI.c("Commands:", :bold)}
226
+ login Save API key (interactive or --api-key)
227
+ apps List available apps
228
+ use-app <slug> Set default app
229
+ status App health snapshot (errors, p95, deploy, top issue)
230
+ incidents [--limit N] List incidents (alias: errors)
231
+ show <id> Show incident details
232
+ explain <id> AI summary and suggested fix for an incident
233
+ trace <endpoint|trace-id> Trace analysis
234
+ deploy-check Check if safe to deploy
235
+ doctor Verify config and API connectivity
236
+ version Show version
237
+ help This message
238
+
239
+ #{UI.c("Global options (before command):", :bold)}
240
+ --json Machine-readable JSON (for scripts/agents)
241
+ --md Markdown (Slack, GitHub comments)
242
+ --app=NAME Override default app
243
+ --base-url=URL Override API base URL
244
+
245
+ #{UI.c("Environment:", :bold)}
246
+ ACTIVERABBIT_API_KEY API token
247
+ ACTIVERABBIT_BASE_URL API base URL (default: https://app.activerabbit.ai)
248
+ ACTIVERABBIT_APP Default app slug
249
+
250
+ #{UI.c("Examples:", :bold)}
251
+ activerabbit login --api-key YOUR_TOKEN
252
+ activerabbit apps
253
+ activerabbit use-app jobsgpt-prod
254
+ activerabbit status
255
+ activerabbit incidents --limit 5
256
+ activerabbit show inc_abc123
257
+ activerabbit --json explain inc_abc123
258
+ activerabbit trace /jobs
259
+ HELP
260
+ end
261
+
262
+ # --- Helpers ---
263
+
264
+ def require_auth!
265
+ return if config.configured?
266
+
267
+ $stderr.puts "Not logged in. Run 'activerabbit login' or set ACTIVERABBIT_API_KEY."
268
+ exit 1
269
+ end
270
+
271
+ def ensure_app_selected!
272
+ require_auth!
273
+ return if config.app_configured?
274
+
275
+ prompt_app_selection
276
+ end
277
+
278
+ def prompt_app_selection
279
+ payload = client.list_apps
280
+ apps = payload.dig("data", "apps") || []
281
+ if apps.empty?
282
+ $stderr.puts "No apps found. Create one at https://app.activerabbit.ai"
283
+ exit 1
284
+ end
285
+
286
+ if apps.size == 1
287
+ app = apps.first
288
+ config.save!(default_app: app["slug"])
289
+ $stderr.puts UI.c("Using app: #{app["name"]} (#{app["slug"]})", :cyan)
290
+ return
291
+ end
292
+
293
+ $stderr.puts ""
294
+ $stderr.puts UI.c("Multiple apps found. Select one:", :bold)
295
+ apps.each_with_index do |app, idx|
296
+ env_badge = app["environment"] == "production" ? UI.c("prod", :green) : UI.c(app["environment"], :gray)
297
+ errors = app["error_count_24h"].to_i > 0 ? UI.c("#{app["error_count_24h"]} errors", :red) : UI.c("0 errors", :gray)
298
+ $stderr.puts " #{UI.c("#{idx + 1})", :cyan)} #{app["name"]} #{env_badge} — #{errors}"
299
+ end
300
+ $stderr.puts ""
301
+ $stderr.print UI.c("Choice [1-#{apps.size}]: ", :bold)
302
+
303
+ input = $stdin.gets&.to_s&.strip
304
+ idx = input.to_i - 1
305
+ if idx < 0 || idx >= apps.size
306
+ $stderr.puts "Invalid choice."
307
+ exit 1
308
+ end
309
+
310
+ selected = apps[idx]
311
+ config.save!(default_app: selected["slug"])
312
+ $stderr.puts UI.c("Default app set to #{selected["slug"]}", :green)
313
+ $stderr.puts ""
314
+ rescue ApiClient::Unauthorized, ApiClient::NetworkError => e
315
+ error_exit(e.message)
316
+ end
317
+
318
+ def error_exit(message)
319
+ $stderr.puts "activerabbit: #{message}"
320
+ exit 1
321
+ end
322
+ end
323
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module ActiveRabbit
6
+ module Commands
7
+ # doctor: verify config, API connectivity, and app access.
8
+ module Doctor
9
+ def self.run(config, client)
10
+ config_ok = config.configured?
11
+ app_ok = config.app_configured?
12
+ api_ok = false
13
+ message = ""
14
+
15
+ if !config_ok
16
+ message = "Run 'activerabbit login' or set ACTIVERABBIT_API_KEY."
17
+ else
18
+ begin
19
+ client.list_apps
20
+ api_ok = true
21
+ if app_ok
22
+ message = "All checks passed."
23
+ else
24
+ message = "API connected. Run 'activerabbit apps' to select an app."
25
+ end
26
+ rescue ApiClient::Unauthorized
27
+ message = "API key invalid or revoked. Check token in settings."
28
+ rescue ApiClient::NotFound
29
+ api_ok = true
30
+ message = "API key valid. CLI endpoints may not be deployed yet (mock data used)."
31
+ rescue ApiClient::NetworkError => e
32
+ message = "Network error: #{e.message}"
33
+ end
34
+ end
35
+
36
+ {
37
+ "project" => config.effective_app,
38
+ "generated_at" => Time.now.utc.iso8601,
39
+ "command" => "doctor",
40
+ "data" => {
41
+ "config_ok" => config_ok,
42
+ "api_ok" => api_ok,
43
+ "app_ok" => app_ok,
44
+ "message" => message
45
+ }
46
+ }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module ActiveRabbit
7
+ # Config resolution: CLI flags > env vars > config file.
8
+ # Config file: ~/.config/activerabbit/config.yml
9
+ # Stores: api_key, base_url, default_app (slug), project_id (legacy/override)
10
+ class Config
11
+ CONFIG_DIR = ENV.fetch("ACTIVERABBIT_CONFIG_DIR", nil) || File.join(Dir.home, ".config", "activerabbit")
12
+ CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
13
+
14
+ attr_accessor :api_key, :base_url, :project_id, :default_app
15
+
16
+ def initialize(api_key: nil, base_url: nil, project_id: nil, default_app: nil)
17
+ @api_key = api_key || ENV["ACTIVERABBIT_API_KEY"]
18
+ @base_url = base_url || ENV["ACTIVERABBIT_BASE_URL"]
19
+ @project_id = project_id || ENV["ACTIVERABBIT_PROJECT_ID"]
20
+ @default_app = default_app || ENV["ACTIVERABBIT_APP"]
21
+ load_file!
22
+ @base_url = normalize_base_url(@base_url) if @base_url
23
+ end
24
+
25
+ def load_file!
26
+ return unless File.file?(CONFIG_FILE)
27
+
28
+ data = YAML.safe_load_file(CONFIG_FILE)
29
+ return unless data.is_a?(Hash)
30
+
31
+ @api_key ||= data["api_key"]
32
+ @base_url ||= data["base_url"]
33
+ @project_id ||= data["project_id"]
34
+ @default_app ||= data["default_app"]
35
+ @base_url = normalize_base_url(@base_url) if @base_url
36
+ end
37
+
38
+ def save!(api_key: nil, base_url: nil, project_id: nil, default_app: nil)
39
+ @api_key = api_key if api_key
40
+ @base_url = base_url if base_url
41
+ @project_id = project_id if project_id
42
+ @default_app = default_app if default_app
43
+ @base_url = normalize_base_url(@base_url) if @base_url
44
+
45
+ FileUtils.mkdir_p(File.dirname(CONFIG_FILE))
46
+ File.write(CONFIG_FILE, to_yaml, perm: 0o600)
47
+ end
48
+
49
+ def to_yaml
50
+ {
51
+ "api_key" => @api_key,
52
+ "base_url" => @base_url,
53
+ "project_id" => @project_id,
54
+ "default_app" => @default_app
55
+ }.compact.to_yaml
56
+ end
57
+
58
+ def configured?
59
+ api_key.to_s.strip != ""
60
+ end
61
+
62
+ def app_configured?
63
+ configured? && (default_app.to_s.strip != "" || project_id.to_s.strip != "")
64
+ end
65
+
66
+ # For backward compat: project_configured? = app_configured?
67
+ alias project_configured? app_configured?
68
+
69
+ def effective_app
70
+ default_app.to_s.strip != "" ? default_app : project_id
71
+ end
72
+
73
+ def config_path
74
+ CONFIG_FILE
75
+ end
76
+
77
+ private
78
+
79
+ def normalize_base_url(url)
80
+ return nil if url.nil? || url.to_s.strip == ""
81
+
82
+ u = url.to_s.strip
83
+ u = "https://#{u}" unless u.start_with?("http://", "https://")
84
+ u.chomp("/")
85
+ end
86
+ end
87
+ end