luciq-cli 0.1.0 → 0.3.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,179 @@
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
+ if @options[:clear_tags] && @options[:tag_action]
105
+ raise '--clear-tags cannot be combined with --tag-action (--clear-tags already replaces with an empty set)'
106
+ end
107
+
108
+ changes = {
109
+ status_id: map_one(BUG_STATUS_IDS, @options[:status]),
110
+ priority_id: map_one(BUG_PRIORITY_IDS, @options[:priority]),
111
+ tags: (@options[:clear_tags] ? [] : @options[:tags]),
112
+ action: action,
113
+ original_bug_number: @options[:duplicate_of]
114
+ }.compact
115
+
116
+ if changes.empty?
117
+ raise 'Provide at least one change (--status/--priority/--tags/--clear-tags/--duplicate-of/--action)'
118
+ end
119
+
120
+ # tag_action only modifies how --tags is applied; on its own it is not a change
121
+ if @options[:clear_tags]
122
+ changes[:tag_action] = 'replace'
123
+ elsif @options[:tag_action]
124
+ changes[:tag_action] = @options[:tag_action]
125
+ end
126
+
127
+ changes
128
+ end
129
+
130
+ def bug_duplicate_action
131
+ return @options[:action] if @options[:action]
132
+
133
+ 'mark_as_duplicate' if @options[:duplicate_of]
134
+ end
135
+
136
+ def filters_arg(filters)
137
+ filters.empty? ? {} : { filters: filters }
138
+ end
139
+
140
+ def json_arg(key, raw)
141
+ return {} if raw.nil?
142
+
143
+ { key => parse_json(raw, "--#{key}") }
144
+ end
145
+
146
+ def raw_filters
147
+ return {} unless @options[:filters]
148
+
149
+ parsed = parse_json(@options[:filters], '--filters')
150
+ raise '--filters must be a JSON object' unless parsed.is_a?(Hash)
151
+
152
+ parsed
153
+ end
154
+
155
+ def alert_payload
156
+ return {} unless @options[:payload]
157
+
158
+ parsed = parse_json(@options[:payload], '--payload')
159
+ raise '--payload must be a JSON object' unless parsed.is_a?(Hash)
160
+
161
+ parsed.transform_keys(&:to_sym)
162
+ end
163
+
164
+ def parse_json(str, label)
165
+ JSON.parse(str)
166
+ rescue JSON::ParserError => e
167
+ raise "Invalid #{label} JSON: #{e.message}"
168
+ end
169
+
170
+ def map_ids(mapping, values)
171
+ Array(values).filter_map { |v| mapping[v] }
172
+ end
173
+
174
+ def map_one(mapping, value)
175
+ value && mapping[value]
176
+ end
177
+ end
178
+ end
179
+ end
@@ -5,33 +5,83 @@ 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('ios-dsym', file_path, 'iOS dSYM file', format: :zip)
17
+ end
18
+
13
19
  def android_mapping(file_path)
20
+ run_upload('android-mapping', file_path, 'Android mapping file')
21
+ end
22
+
23
+ def android_ndk(file_path)
24
+ run_upload('android-ndk', file_path, 'Android NDK .so files', format: :zip, arch: true)
25
+ end
26
+
27
+ def react_native_ios_dsym(file_path)
28
+ run_upload('react-native-ios-dsym', file_path, 'React Native iOS dSYM', format: :zip)
29
+ end
30
+
31
+ def react_native_ios_sourcemap(file_path)
32
+ run_upload('react-native-ios-sourcemap', file_path, 'React Native iOS source map', format: :sourcemap)
33
+ end
34
+
35
+ def react_native_android_mapping(file_path)
36
+ run_upload('react-native-android-mapping', file_path, 'React Native Android mapping file')
37
+ end
38
+
39
+ def react_native_android_sourcemap(file_path)
40
+ run_upload('react-native-android-sourcemap', file_path, 'React Native Android source map', format: :sourcemap)
41
+ end
42
+
43
+ def react_native_ndk(file_path)
44
+ run_upload('react-native-ndk', file_path, 'React Native NDK .so files', format: :zip, arch: true)
45
+ end
46
+
47
+ def flutter_ios_dsym(file_path)
48
+ run_upload('flutter-ios-dsym', file_path, 'Flutter iOS dSYM', format: :zip)
49
+ end
50
+
51
+ def flutter_ios_sourcemap(file_path)
52
+ run_upload('flutter-ios-sourcemap', file_path, 'Flutter iOS Dart sourcemap', format: :zip)
53
+ end
54
+
55
+ def flutter_android_mapping(file_path)
56
+ run_upload('flutter-android-mapping', file_path, 'Flutter Android mapping file')
57
+ end
58
+
59
+ def flutter_android_sourcemap(file_path)
60
+ run_upload('flutter-android-sourcemap', file_path, 'Flutter Android Dart sourcemap', format: :zip)
61
+ end
62
+
63
+ def flutter_ndk(file_path)
64
+ run_upload('flutter-ndk', file_path, 'Flutter NDK .so files', format: :zip, arch: true)
65
+ end
66
+
67
+ private
68
+
69
+ def run_upload(command, file_path, label, format: nil, arch: false)
14
70
  validate_file!(file_path)
15
- validate_zip_extension!(file_path)
71
+ validate_format!(file_path, format)
72
+ validate_arch! if arch
16
73
 
17
- puts "Uploading Android mapping file: #{File.basename(file_path)}"
74
+ puts "Uploading #{label}: #{File.basename(file_path)}"
18
75
  puts
19
76
 
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
- )
77
+ @client.upload(command, file_path, @options)
26
78
 
27
- puts 'Android mapping file uploaded successfully!'
79
+ puts "#{label} uploaded successfully!"
28
80
  rescue StandardError => e
29
81
  puts "✗ Upload failed: #{e.message}"
30
82
  exit 1
31
83
  end
32
84
 
33
- private
34
-
35
85
  def validate_file!(file_path)
36
86
  unless File.exist?(file_path)
37
87
  puts "✗ File not found: #{file_path}"
@@ -44,11 +94,32 @@ module Luciq
44
94
  exit 1
45
95
  end
46
96
 
97
+ def validate_format!(file_path, format)
98
+ case format
99
+ when :zip then validate_zip_extension!(file_path)
100
+ when :sourcemap then validate_sourcemap_extension!(file_path)
101
+ end
102
+ end
103
+
47
104
  def validate_zip_extension!(file_path)
48
105
  return if file_path.downcase.end_with?('.zip')
49
106
 
50
107
  puts "✗ File must be a .zip archive: #{file_path}"
51
- puts ' The ZIP should contain a single text file.'
108
+ exit 1
109
+ end
110
+
111
+ def validate_sourcemap_extension!(file_path)
112
+ return if file_path.downcase.end_with?('.json', '.txt')
113
+
114
+ puts "✗ Source map must be a .json or .txt file: #{file_path}"
115
+ exit 1
116
+ end
117
+
118
+ def validate_arch!
119
+ return if ALLOWED_ARCHITECTURES.include?(@options[:arch])
120
+
121
+ puts "✗ Invalid architecture: #{@options[:arch]}"
122
+ puts " Allowed values: #{ALLOWED_ARCHITECTURES.join(', ')}"
52
123
  exit 1
53
124
  end
54
125
  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)