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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +21 -0
- data/README.md +170 -0
- data/exe/activerabbit +6 -0
- data/lib/activerabbit/api_client.rb +306 -0
- data/lib/activerabbit/cli.rb +323 -0
- data/lib/activerabbit/commands.rb +50 -0
- data/lib/activerabbit/config.rb +87 -0
- data/lib/activerabbit/formatters.rb +327 -0
- data/lib/activerabbit/ui.rb +112 -0
- data/lib/activerabbit/version.rb +5 -0
- data/lib/activerabbit.rb +9 -0
- metadata +90 -0
|
@@ -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
|