luciq-cli 0.1.0 → 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.
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'luciq/api/client'
5
+ require 'luciq/config'
6
+ require 'luciq/commands/query_arguments'
7
+
8
+ module Luciq
9
+ module Commands
10
+ # Read/query commands. Each public method maps one-to-one to an MCP tool:
11
+ # it builds that tool's arguments from typed CLI flags (see QueryArguments)
12
+ # and proxies to the MCP server's CLI endpoint.
13
+ class Query
14
+ include QueryArguments
15
+
16
+ def initialize(options = {})
17
+ @options = options
18
+ @client = API::Client.new
19
+ end
20
+
21
+ # --- Crashes -----------------------------------------------------------
22
+ def crashes_list
23
+ execute('list_crashes') { base_args.merge(page_args, sort_args, filters_arg(crash_filters)) }
24
+ end
25
+
26
+ def crash_show(number)
27
+ execute('crash_details') { base_args.merge(number: number) }
28
+ end
29
+
30
+ def crash_patterns(number)
31
+ pattern = { pattern_key: @options[:pattern_key], sort_by: @options[:sort_by],
32
+ direction: @options[:direction] }.compact
33
+ execute('crash_patterns') do
34
+ base_args.merge(number: number).merge(pattern).merge(filters_arg(raw_filters))
35
+ end
36
+ end
37
+
38
+ def crash_diagnostics(number)
39
+ execute('crash_diagnostics') { base_args.merge(number: number) }
40
+ end
41
+
42
+ def app_hangs
43
+ execute('list_app_hangs') { base_args.merge(page_args, sort_args, filters_arg(hang_filters)) }
44
+ end
45
+
46
+ def occurrence_tokens(number)
47
+ cursor = { current_token: @options[:current_token], direction: @options[:direction] }.compact
48
+ execute('list_occurrences_tokens') do
49
+ base_args.merge(number: number).merge(cursor).merge(filters_arg(raw_filters))
50
+ end
51
+ end
52
+
53
+ def occurrence_details(number, ulid)
54
+ execute('get_occurrence_details') { base_args.merge(number: number, ulid: ulid) }
55
+ end
56
+
57
+ # --- Bugs --------------------------------------------------------------
58
+ def bugs_list
59
+ execute('list_bugs') { base_args.merge(page_args, sort_args, filters_arg(bug_filters)) }
60
+ end
61
+
62
+ def bug_show(number)
63
+ execute('bug_details') { base_args.merge(number: number) }
64
+ end
65
+
66
+ def bug_update(number)
67
+ execute('update_bug') { base_args.merge(number: number).merge(bug_update_changes) }
68
+ end
69
+
70
+ # --- APM ---------------------------------------------------------------
71
+ def apm_groups
72
+ execute('apm_list_groups') do
73
+ base_args.merge({ metric: @options[:metric] }.compact, page_args)
74
+ .merge(apm_sort_arg, filters_arg(raw_filters))
75
+ end
76
+ end
77
+
78
+ def apm_group
79
+ group = { metric: @options[:metric], group_uuid: @options[:group_uuid],
80
+ group_url: @options[:group_url], method: @options[:method] }.compact
81
+ views = @options[:views] ? json_arg(:views, @options[:views]) : { views: ['summary'] }
82
+ execute('apm_group_view') do
83
+ base_args.merge(group).merge(views, filters_arg(raw_filters))
84
+ end
85
+ end
86
+
87
+ def apm_occurrence
88
+ selector = { metric: @options[:metric], selector: @options[:selector],
89
+ group_uuid: @options[:group_uuid], group_url: @options[:group_url],
90
+ method: @options[:method], token: @options[:token],
91
+ current_token: @options[:current_token],
92
+ direction: @options[:direction], limit: @options[:limit] }.compact
93
+ execute('apm_occurrence') { base_args.merge(selector).merge(filters_arg(raw_filters)) }
94
+ end
95
+
96
+ def apm_funnel_events
97
+ params = { event_type: @options[:event_type], q: @options[:q], limit: @options[:limit] }.compact
98
+ execute('apm_funnel_events') { base_args.merge(params) }
99
+ end
100
+
101
+ def apm_funnel_create
102
+ execute('apm_funnel_write') do
103
+ base_args.merge(operation: 'create', name: @options[:name]).merge(json_arg(:events, @options[:events]))
104
+ end
105
+ end
106
+
107
+ def apm_funnel_update
108
+ changes = { ulid: @options[:ulid], name: @options[:name] }.compact
109
+ execute('apm_funnel_write') do
110
+ base_args.merge(operation: 'update').merge(changes, json_arg(:events, @options[:events]))
111
+ end
112
+ end
113
+
114
+ def apm_funnel_delete
115
+ execute('apm_funnel_write') { base_args.merge(operation: 'delete', ulid: @options[:ulid]) }
116
+ end
117
+
118
+ # --- Reviews -----------------------------------------------------------
119
+ def reviews_list
120
+ execute('list_reviews') do
121
+ base_args.merge(page_args, sort_dir_args).merge(filters_arg(review_filters))
122
+ end
123
+ end
124
+
125
+ # --- Surveys -----------------------------------------------------------
126
+ def surveys_list
127
+ execute('list_surveys') { base_args.merge(page_args, filters_arg(survey_filters)) }
128
+ end
129
+
130
+ def survey_show(id)
131
+ page = { page: @options[:page] }.compact
132
+ execute('survey_details') { base_args.merge(id: id).merge(page, filters_arg(raw_filters)) }
133
+ end
134
+
135
+ # --- Applications & health --------------------------------------------
136
+ def apps_list
137
+ execute('list_applications') { page_args.merge({ platform: @options[:platform] }.compact) }
138
+ end
139
+
140
+ def insights
141
+ execute('app_insights') { base_args.merge(filters_arg(raw_filters)) }
142
+ end
143
+
144
+ # --- Issues ------------------------------------------------------------
145
+ def issues_list
146
+ top = { top_issues: @options[:top_issues] }.compact
147
+ execute('list_issues') do
148
+ base_args.merge({ limit: @options[:limit] }.compact, sort_dir_args, top)
149
+ .merge(json_arg(:pagination, @options[:pagination]), filters_arg(raw_filters))
150
+ end
151
+ end
152
+
153
+ # --- Opportunities -----------------------------------------------------
154
+ def opportunities_list
155
+ execute('list_opportunities') { base_args.merge(page_args, filters_arg(opportunity_filters)) }
156
+ end
157
+
158
+ def opportunity_show(id)
159
+ execute('opportunity_details') { base_args.merge(id: id) }
160
+ end
161
+
162
+ # --- Alerts (rules) ----------------------------------------------------
163
+ def alerts_list
164
+ execute('read_alerts') { base_args.merge(action: 'list').merge(sort_dir_args) }
165
+ end
166
+
167
+ def alert_show(ulid)
168
+ execute('read_alerts') { base_args.merge(action: 'details', ulid: ulid) }
169
+ end
170
+
171
+ def alerts_init
172
+ execute('read_alerts') { base_args.merge(action: 'init') }
173
+ end
174
+
175
+ def alert_create
176
+ execute('write_alerts') { alert_payload.merge(base_args).merge(action: 'create') }
177
+ end
178
+
179
+ def alert_update(ulid)
180
+ execute('write_alerts') { alert_payload.merge(base_args).merge(action: 'update', ulid: ulid) }
181
+ end
182
+
183
+ def alert_delete(ulid)
184
+ execute('write_alerts') { base_args.merge(action: 'delete', ulid: ulid) }
185
+ end
186
+
187
+ # --- Incidents (triggered alerts) --------------------------------------
188
+ def incidents_list
189
+ execute('read_incidents') do
190
+ base_args.merge(action: 'list').merge(page_args, sort_dir_args, filters_arg(incident_filters))
191
+ end
192
+ end
193
+
194
+ def incident_show(ulid)
195
+ execute('read_incidents') { base_args.merge(action: 'details', ulid: ulid) }
196
+ end
197
+
198
+ def incident_resolve(ulid)
199
+ execute('write_incidents') { base_args.merge(action: 'resolve', ulid: ulid) }
200
+ end
201
+
202
+ def incident_reopen(ulid)
203
+ execute('write_incidents') { base_args.merge(action: 'reopen', ulid: ulid) }
204
+ end
205
+
206
+ private
207
+
208
+ def execute(tool_name)
209
+ ensure_authenticated!
210
+ print_result(@client.invoke_tool(tool_name, yield))
211
+ rescue StandardError => e
212
+ puts "✗ #{e.message}"
213
+ exit 1
214
+ end
215
+
216
+ def ensure_authenticated!
217
+ return if Config.load_token
218
+
219
+ puts '✗ Not authenticated. Run: luciq login'
220
+ exit 1
221
+ end
222
+
223
+ def print_result(response)
224
+ puts(response.is_a?(String) ? response : JSON.pretty_generate(response))
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Luciq
6
+ module Commands
7
+ # Translates CLI flags (@options) into the argument/filter hashes the MCP
8
+ # tools expect. Mixed into Commands::Query; kept separate so the command
9
+ # dispatch stays thin and the flag→argument mapping is easy to test.
10
+ module QueryArguments
11
+ CRASH_STATUS_IDS = { 'open' => 1, 'closed' => 2, 'in_progress' => 3 }.freeze
12
+ BUG_STATUS_IDS = { 'new' => 1, 'closed' => 2, 'in_progress' => 3 }.freeze
13
+ BUG_PRIORITY_IDS = { 'na' => -1, 'trivial' => 1, 'minor' => 2, 'major' => 3, 'blocker' => 4 }.freeze
14
+
15
+ private
16
+
17
+ def base_args
18
+ { slug: @options[:slug], mode: @options[:mode] }
19
+ end
20
+
21
+ def page_args
22
+ { offset: @options[:offset], limit: @options[:limit] }.compact
23
+ end
24
+
25
+ def sort_args
26
+ { sort_by: @options[:sort_by], direction: @options[:direction] }.compact
27
+ end
28
+
29
+ def sort_dir_args
30
+ { sort_by: @options[:sort_by], sort_direction: @options[:sort_direction] }.compact
31
+ end
32
+
33
+ def apm_sort_arg
34
+ return {} unless @options[:sort]
35
+
36
+ { sort: [parse_json(@options[:sort], '--sort')] }
37
+ end
38
+
39
+ def crash_filters
40
+ f = raw_filters
41
+ ids = map_ids(CRASH_STATUS_IDS, @options[:status])
42
+ f['status_id'] = ids unless ids.empty?
43
+ f['platform'] = Array(@options[:platform]) if @options[:platform]
44
+ f['type'] = Array(@options[:type]) if @options[:type]
45
+ f['app_versions'] = Array(@options[:app_version]) if @options[:app_version]
46
+ f
47
+ end
48
+
49
+ def hang_filters
50
+ f = raw_filters
51
+ ids = map_ids(CRASH_STATUS_IDS, @options[:status])
52
+ f['status_id'] = ids unless ids.empty?
53
+ f['platform'] = Array(@options[:platform]) if @options[:platform]
54
+ f['app_versions'] = Array(@options[:app_version]) if @options[:app_version]
55
+ f
56
+ end
57
+
58
+ def bug_filters
59
+ f = raw_filters
60
+ ids = map_ids(BUG_STATUS_IDS, @options[:status])
61
+ f['status_id'] = ids unless ids.empty?
62
+ pri = map_ids(BUG_PRIORITY_IDS, @options[:priority])
63
+ f['priority_id'] = pri unless pri.empty?
64
+ f['app_version'] = Array(@options[:app_version]) if @options[:app_version]
65
+ f
66
+ end
67
+
68
+ def review_filters
69
+ f = raw_filters
70
+ f['rating'] = Array(@options[:rating]).map(&:to_i) if @options[:rating]
71
+ f['country'] = Array(@options[:country]) if @options[:country]
72
+ f['os'] = Array(@options[:os]) if @options[:os]
73
+ f
74
+ end
75
+
76
+ def survey_filters
77
+ f = raw_filters
78
+ f['type'] = Array(@options[:type]).map(&:to_i) if @options[:type]
79
+ f['status'] = Array(@options[:status]).map(&:to_i) if @options[:status]
80
+ f
81
+ end
82
+
83
+ def opportunity_filters
84
+ f = raw_filters
85
+ f['status'] = Array(@options[:status]) if @options[:status]
86
+ f['priority'] = Array(@options[:priority]) if @options[:priority]
87
+ f['team_id'] = @options[:team_id] if @options[:team_id]
88
+ f
89
+ end
90
+
91
+ def incident_filters
92
+ f = raw_filters
93
+ f['status'] = Array(@options[:status]) if @options[:status]
94
+ f['type'] = Array(@options[:type]) if @options[:type]
95
+ f
96
+ end
97
+
98
+ # Top-level (non-filter) changes for `bugs update`.
99
+ def bug_update_changes
100
+ action = bug_duplicate_action
101
+ if action && (@options[:status] || @options[:priority])
102
+ raise 'Duplicate marking cannot be combined with --status/--priority'
103
+ end
104
+
105
+ changes = {
106
+ status_id: map_one(BUG_STATUS_IDS, @options[:status]),
107
+ priority_id: map_one(BUG_PRIORITY_IDS, @options[:priority]),
108
+ tags: (@options[:clear_tags] ? [] : @options[:tags]),
109
+ action: action,
110
+ original_bug_number: @options[:duplicate_of]
111
+ }.compact
112
+ return changes unless changes.empty?
113
+
114
+ raise 'Provide at least one change (--status/--priority/--tags/--clear-tags/--duplicate-of/--action)'
115
+ end
116
+
117
+ def bug_duplicate_action
118
+ return @options[:action] if @options[:action]
119
+
120
+ 'mark_as_duplicate' if @options[:duplicate_of]
121
+ end
122
+
123
+ def filters_arg(filters)
124
+ filters.empty? ? {} : { filters: filters }
125
+ end
126
+
127
+ def json_arg(key, raw)
128
+ return {} if raw.nil?
129
+
130
+ { key => parse_json(raw, "--#{key}") }
131
+ end
132
+
133
+ def raw_filters
134
+ return {} unless @options[:filters]
135
+
136
+ parsed = parse_json(@options[:filters], '--filters')
137
+ raise '--filters must be a JSON object' unless parsed.is_a?(Hash)
138
+
139
+ parsed
140
+ end
141
+
142
+ def alert_payload
143
+ return {} unless @options[:payload]
144
+
145
+ parsed = parse_json(@options[:payload], '--payload')
146
+ raise '--payload must be a JSON object' unless parsed.is_a?(Hash)
147
+
148
+ parsed.transform_keys(&:to_sym)
149
+ end
150
+
151
+ def parse_json(str, label)
152
+ JSON.parse(str)
153
+ rescue JSON::ParserError => e
154
+ raise "Invalid #{label} JSON: #{e.message}"
155
+ end
156
+
157
+ def map_ids(mapping, values)
158
+ Array(values).filter_map { |v| mapping[v] }
159
+ end
160
+
161
+ def map_one(mapping, value)
162
+ value && mapping[value]
163
+ end
164
+ end
165
+ end
166
+ end
@@ -5,33 +5,159 @@ require 'luciq/api/client'
5
5
  module Luciq
6
6
  module Commands
7
7
  class Upload
8
+ ALLOWED_ARCHITECTURES = %w[armeabi-v7a arm64-v8a x86 x86_64].freeze
9
+
8
10
  def initialize(options = {})
9
11
  @options = options
10
12
  @client = API::Client.new
11
13
  end
12
14
 
15
+ def ios_dsym(file_path)
16
+ run_upload(file_path, 'iOS dSYM file', format: :zip) do
17
+ @client.upload_ios_dsym(file_path: file_path, app_token: @options[:app_token])
18
+ end
19
+ end
20
+
13
21
  def android_mapping(file_path)
22
+ run_upload(file_path, 'Android mapping file') do
23
+ @client.upload_android_mapping(
24
+ file_path: file_path,
25
+ app_token: @options[:app_token],
26
+ version_code: @options[:version_code],
27
+ version_name: @options[:version_name]
28
+ )
29
+ end
30
+ end
31
+
32
+ def android_ndk(file_path)
33
+ run_upload(file_path, 'Android NDK .so files', format: :zip) do
34
+ validate_arch!
35
+ @client.upload_android_ndk(
36
+ file_path: file_path,
37
+ app_token: @options[:app_token],
38
+ app_version: @options[:version_name],
39
+ arch: @options[:arch]
40
+ )
41
+ end
42
+ end
43
+
44
+ def react_native_ios_dsym(file_path)
45
+ run_upload(file_path, 'React Native iOS dSYM', format: :zip) do
46
+ @client.upload_react_native_ios_dsym(file_path: file_path, app_token: @options[:app_token])
47
+ end
48
+ end
49
+
50
+ def react_native_ios_sourcemap(file_path)
51
+ run_upload(file_path, 'React Native iOS source map', format: :sourcemap) do
52
+ @client.upload_react_native_ios_sourcemap(
53
+ file_path: file_path,
54
+ app_token: @options[:app_token],
55
+ app_version: build_app_version
56
+ )
57
+ end
58
+ end
59
+
60
+ def react_native_android_mapping(file_path)
61
+ run_upload(file_path, 'React Native Android mapping file') do
62
+ @client.upload_react_native_android_mapping(
63
+ file_path: file_path,
64
+ app_token: @options[:app_token],
65
+ version_code: @options[:version_code],
66
+ version_name: @options[:version_name]
67
+ )
68
+ end
69
+ end
70
+
71
+ def react_native_android_sourcemap(file_path)
72
+ run_upload(file_path, 'React Native Android source map', format: :sourcemap) do
73
+ @client.upload_react_native_android_sourcemap(
74
+ file_path: file_path,
75
+ app_token: @options[:app_token],
76
+ app_version: build_app_version
77
+ )
78
+ end
79
+ end
80
+
81
+ def react_native_ndk(file_path)
82
+ run_upload(file_path, 'React Native NDK .so files', format: :zip) do
83
+ validate_arch!
84
+ @client.upload_react_native_ndk(
85
+ file_path: file_path,
86
+ app_token: @options[:app_token],
87
+ app_version: @options[:version_name],
88
+ arch: @options[:arch]
89
+ )
90
+ end
91
+ end
92
+
93
+ def flutter_ios_dsym(file_path)
94
+ run_upload(file_path, 'Flutter iOS dSYM', format: :zip) do
95
+ @client.upload_flutter_ios_dsym(file_path: file_path, app_token: @options[:app_token])
96
+ end
97
+ end
98
+
99
+ def flutter_ios_sourcemap(file_path)
100
+ run_upload(file_path, 'Flutter iOS Dart sourcemap', format: :zip) do
101
+ @client.upload_flutter_ios_sourcemap(
102
+ file_path: file_path,
103
+ app_token: @options[:app_token],
104
+ version_name: @options[:version_name],
105
+ version_code: @options[:version_code]
106
+ )
107
+ end
108
+ end
109
+
110
+ def flutter_android_mapping(file_path)
111
+ run_upload(file_path, 'Flutter Android mapping file') do
112
+ @client.upload_flutter_android_mapping(
113
+ file_path: file_path,
114
+ app_token: @options[:app_token],
115
+ version_code: @options[:version_code],
116
+ version_name: @options[:version_name]
117
+ )
118
+ end
119
+ end
120
+
121
+ def flutter_android_sourcemap(file_path)
122
+ run_upload(file_path, 'Flutter Android Dart sourcemap', format: :zip) do
123
+ @client.upload_flutter_android_sourcemap(
124
+ file_path: file_path,
125
+ app_token: @options[:app_token],
126
+ version_name: @options[:version_name],
127
+ version_code: @options[:version_code]
128
+ )
129
+ end
130
+ end
131
+
132
+ def flutter_ndk(file_path)
133
+ run_upload(file_path, 'Flutter NDK .so files', format: :zip) do
134
+ validate_arch!
135
+ @client.upload_flutter_ndk(
136
+ file_path: file_path,
137
+ app_token: @options[:app_token],
138
+ app_version: @options[:version_name],
139
+ arch: @options[:arch]
140
+ )
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ def run_upload(file_path, label, format: nil)
14
147
  validate_file!(file_path)
15
- validate_zip_extension!(file_path)
148
+ validate_format!(file_path, format)
16
149
 
17
- puts "Uploading Android mapping file: #{File.basename(file_path)}"
150
+ puts "Uploading #{label}: #{File.basename(file_path)}"
18
151
  puts
19
152
 
20
- @client.upload_android_mapping(
21
- file_path: file_path,
22
- app_token: @options[:app_token],
23
- version_code: @options[:version_code],
24
- version_name: @options[:version_name]
25
- )
153
+ yield
26
154
 
27
- puts 'Android mapping file uploaded successfully!'
155
+ puts "#{label} uploaded successfully!"
28
156
  rescue StandardError => e
29
157
  puts "✗ Upload failed: #{e.message}"
30
158
  exit 1
31
159
  end
32
160
 
33
- private
34
-
35
161
  def validate_file!(file_path)
36
162
  unless File.exist?(file_path)
37
163
  puts "✗ File not found: #{file_path}"
@@ -44,13 +170,42 @@ module Luciq
44
170
  exit 1
45
171
  end
46
172
 
173
+ def validate_format!(file_path, format)
174
+ case format
175
+ when :zip then validate_zip_extension!(file_path)
176
+ when :sourcemap then validate_sourcemap_extension!(file_path)
177
+ end
178
+ end
179
+
47
180
  def validate_zip_extension!(file_path)
48
181
  return if file_path.downcase.end_with?('.zip')
49
182
 
50
183
  puts "✗ File must be a .zip archive: #{file_path}"
51
- puts ' The ZIP should contain a single text file.'
52
184
  exit 1
53
185
  end
186
+
187
+ def validate_sourcemap_extension!(file_path)
188
+ return if file_path.downcase.end_with?('.json', '.txt')
189
+
190
+ puts "✗ Source map must be a .json or .txt file: #{file_path}"
191
+ exit 1
192
+ end
193
+
194
+ def validate_arch!
195
+ return if ALLOWED_ARCHITECTURES.include?(@options[:arch])
196
+
197
+ puts "✗ Invalid architecture: #{@options[:arch]}"
198
+ puts " Allowed values: #{ALLOWED_ARCHITECTURES.join(', ')}"
199
+ exit 1
200
+ end
201
+
202
+ def build_app_version
203
+ {
204
+ code: @options[:version_code],
205
+ name: @options[:version_name],
206
+ codepush: @options[:codepush]
207
+ }.compact
208
+ end
54
209
  end
55
210
  end
56
211
  end
data/lib/luciq/config.rb CHANGED
@@ -11,7 +11,8 @@ module Luciq
11
11
  end
12
12
 
13
13
  def load_token
14
- ENV['LUCIQ_AUTH_TOKEN'] || load_value('token')
14
+ token = ENV['LUCIQ_AUTH_TOKEN'] || load_value('token')
15
+ token unless token.to_s.strip.empty?
15
16
  end
16
17
 
17
18
  def save_token(token)