ruby_native 0.17.2 → 0.18.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,546 @@
1
+ require "set"
2
+ require "yaml"
3
+
4
+ module RubyNative
5
+ class CLI
6
+ class Mcp
7
+ # Static validation of config/ruby_native.yml.
8
+ #
9
+ # Every rule mirrors something that already happens at runtime: a value
10
+ # `RubyNative.load_config` normalizes away with a log line nobody reads,
11
+ # or one that fails the config decode on device and drops the user on the
12
+ # error screen instead of the app. The severities follow that split --
13
+ # `error` means the app does not boot, `warning` means the setting is
14
+ # quietly ignored.
15
+ #
16
+ # Rails is never booted: the CLI runs outside it, and the point is to
17
+ # answer before a server does.
18
+ class ConfigValidator
19
+ DEFAULT_PATH = "config/ruby_native.yml".freeze
20
+
21
+ # The whole top-level vocabulary. `app`, `appearance`, `tabs`, `auth`,
22
+ # `errors`, `analytics`, and `android.firebase` are decoded on device;
23
+ # `ios`, the rest of `android`, and `linked_paths` are served as
24
+ # universal-link metadata by the engine; `ruby_native` holds the app_id
25
+ # `deploy` writes.
26
+ TOP_LEVEL_KEYS = %w[app appearance tabs auth errors analytics ios android linked_paths ruby_native].freeze
27
+ APP_KEYS = %w[name mode entry_path].freeze
28
+ # `android` is the per-platform chrome block, read only by the Android app.
29
+ APPEARANCE_KEYS = %w[tint_color background_color theme landscape navbar splash android].freeze
30
+ NAVBAR_KEYS = %w[logo background_color foreground_color status_bar].freeze
31
+ SPLASH_KEYS = %w[enabled spinner_color status_bar].freeze
32
+ TAB_KEYS = %w[title titles path icon icons key eager search badge auto_route].freeze
33
+ ANDROID_KEYS = %w[package cert_fingerprint firebase].freeze
34
+ FIREBASE_KEYS = %w[app_id api_key project_id sender_id].freeze
35
+ PUSH_DOCS = "https://rubynative.com/docs/android/push-notifications".freeze
36
+
37
+ MODES = %w[normal advanced].freeze
38
+ THEMES = %w[auto light dark].freeze
39
+ STATUS_BARS = %w[light dark].freeze
40
+
41
+ # UIColor(hex:) takes exactly six hex digits and an optional `#`. A
42
+ # three-digit shorthand parses as nil, which reads on device as the
43
+ # color simply not applying.
44
+ HEX_COLOR = /\A#?\h{6}\z/
45
+
46
+ # The file is rendered as ERB before Rails parses it, so a navbar logo
47
+ # can interpolate `image_url`. There is no Rails here to render against,
48
+ # so tags become a sentinel: the document stays valid YAML, and no rule
49
+ # fires on a value this file cannot know.
50
+ ERB_TAG = /<%=?-?.*?-?%>/m
51
+ ERB_SENTINEL = "ruby-native-erb-value".freeze
52
+
53
+ # Generated by `script/icon_names`. A name missing here draws the
54
+ # placeholder on device, so these are warnings: the app still boots.
55
+ ICONS_DIR = File.expand_path("../../icons", __dir__)
56
+ IOS_FLOOR = Gem::Version.new("16.0")
57
+ ICON_DOCS = "https://rubynative.com/docs/icons".freeze
58
+
59
+ Offense = Struct.new(:key, :line, :severity, :message, keyword_init: true)
60
+
61
+ def self.run(path: DEFAULT_PATH)
62
+ new(path).run
63
+ end
64
+
65
+ def self.sf_symbols
66
+ @sf_symbols ||= File.readlines("#{ICONS_DIR}/sf_symbols.tsv", chomp: true)
67
+ .to_h { |line| line.split("\t") }
68
+ end
69
+
70
+ def self.material_symbols
71
+ @material_symbols ||= File.readlines("#{ICONS_DIR}/material_symbols.txt", chomp: true).to_set
72
+ end
73
+
74
+ def initialize(path = DEFAULT_PATH)
75
+ @path = path
76
+ @offenses = []
77
+ @lines = []
78
+ end
79
+
80
+ def run
81
+ unless File.exist?(@path)
82
+ return [Offense.new(
83
+ key: nil,
84
+ line: nil,
85
+ severity: :error,
86
+ message: "#{@path} does not exist. Run `rails generate ruby_native:install` to create it."
87
+ )]
88
+ end
89
+
90
+ source = File.read(@path, encoding: "UTF-8")
91
+ @lines = source.lines
92
+ @erb = source.match?(ERB_TAG)
93
+
94
+ config = parse(source)
95
+ return @offenses if @parse_failed
96
+
97
+ unless config.is_a?(Hash)
98
+ error(nil, "#{@path} is empty or is not a YAML mapping, so Ruby Native ignores the whole file and the app boots unconfigured.")
99
+ return @offenses
100
+ end
101
+
102
+ check_top_level(config)
103
+ check_app(config["app"])
104
+ check_appearance(config["appearance"])
105
+ check_tabs(config["tabs"])
106
+ check_auth(config["auth"])
107
+ check_analytics(config)
108
+ check_linked_paths(config["linked_paths"])
109
+ check_identifiers(config["ios"], "ios", %w[bundle_id team_id], "/.well-known/apple-app-site-association")
110
+ check_android(config["android"])
111
+
112
+ @offenses
113
+ end
114
+
115
+ private
116
+
117
+ def parse(source)
118
+ YAML.load(source.gsub(ERB_TAG, ERB_SENTINEL), aliases: true)
119
+ rescue Psych::SyntaxError => syntax_error
120
+ @parse_failed = true
121
+
122
+ # Stubbing out `<%= %>` keeps a value-position tag valid, but a tag
123
+ # that opens a block takes structure with it. That is this command's
124
+ # blind spot, not the developer's mistake, so it says so rather than
125
+ # reporting a syntax error the file does not have.
126
+ if @erb
127
+ warning(nil, "Could not check #{@path}: stubbing out its ERB left YAML that does not parse (#{syntax_error.problem}). " \
128
+ "That happens when a tag spans structure rather than filling in one value, and it does not mean the file is broken.",
129
+ line: syntax_line(syntax_error))
130
+ else
131
+ error(nil, "#{@path} is not valid YAML: #{syntax_error.problem}. Rails cannot load it, so the app has no config at all.",
132
+ line: syntax_line(syntax_error))
133
+ end
134
+ nil
135
+ end
136
+
137
+ # libyaml counts lines from zero and reports where it gave up, which can
138
+ # be the line after the mistake or the end of the file. Adding one and
139
+ # clamping to the file puts every shape of that back on a real line.
140
+ def syntax_line(syntax_error)
141
+ [[syntax_error.line + 1, @lines.size].min, 1].max
142
+ end
143
+
144
+ # --- Sections ---
145
+
146
+ def check_top_level(config)
147
+ config.each_key do |key|
148
+ next if TOP_LEVEL_KEYS.include?(key.to_s)
149
+
150
+ warning(key.to_s, "Unknown top-level key `#{key}`, which Ruby Native ignores. It reads: #{TOP_LEVEL_KEYS.join(", ")}.")
151
+ end
152
+ end
153
+
154
+ def check_app(app)
155
+ return if app.nil?
156
+ return error("app", "`app` has to be a mapping.") unless app.is_a?(Hash)
157
+
158
+ unknown_keys(app, APP_KEYS, "app")
159
+
160
+ mode = app["mode"]
161
+ if mode && !sentinel?(mode) && !MODES.include?(mode.to_s)
162
+ warning("app.mode", "`app.mode` is #{mode.inspect}. The app matches it against #{MODES.join(" and ")} and runs Normal Mode for anything else, so a typo here is silent.")
163
+ end
164
+
165
+ entry = app["entry_path"]
166
+ if entry.is_a?(String) && !sentinel?(entry) && !entry.start_with?("/")
167
+ warning("app.entry_path", "`app.entry_path` is #{entry.inspect}. It is a path the app opens on launch, so write it absolute: \"/#{entry}\".")
168
+ end
169
+ end
170
+
171
+ def check_appearance(appearance)
172
+ return if appearance.nil?
173
+ return error("appearance", "`appearance` has to be a mapping.") unless appearance.is_a?(Hash)
174
+
175
+ unknown_keys(appearance, APPEARANCE_KEYS, "appearance")
176
+ check_color(appearance["tint_color"], "appearance.tint_color")
177
+ check_color(appearance["background_color"], "appearance.background_color")
178
+ check_enum(appearance["theme"], "appearance.theme", THEMES)
179
+ check_boolean(appearance["landscape"], "appearance.landscape")
180
+ check_navbar(appearance["navbar"])
181
+ check_splash(appearance["splash"])
182
+ end
183
+
184
+ def check_navbar(navbar)
185
+ return if navbar.nil?
186
+ return error("appearance.navbar", "`appearance.navbar` has to be a mapping.") unless navbar.is_a?(Hash)
187
+
188
+ unknown_keys(navbar, NAVBAR_KEYS, "appearance.navbar")
189
+ check_color(navbar["background_color"], "appearance.navbar.background_color")
190
+ check_color(navbar["foreground_color"], "appearance.navbar.foreground_color")
191
+ check_enum(navbar["status_bar"], "appearance.navbar.status_bar", STATUS_BARS)
192
+ end
193
+
194
+ def check_splash(splash)
195
+ return if splash.nil?
196
+ return error("appearance.splash", "`appearance.splash` has to be a mapping.") unless splash.is_a?(Hash)
197
+
198
+ unknown_keys(splash, SPLASH_KEYS, "appearance.splash")
199
+ check_boolean(splash["enabled"], "appearance.splash.enabled")
200
+ check_color(splash["spinner_color"], "appearance.splash.spinner_color")
201
+ check_enum(splash["status_bar"], "appearance.splash.status_bar", STATUS_BARS)
202
+ end
203
+
204
+ def check_tabs(tabs)
205
+ return if tabs.nil?
206
+ return error("tabs", "`tabs` has to be a list of tabs.") unless tabs.is_a?(Array)
207
+
208
+ keys = []
209
+
210
+ tabs.each_with_index do |tab, index|
211
+ location = "tabs[#{index}]"
212
+
213
+ unless tab.is_a?(Hash)
214
+ error(location, "`#{location}` has to be a mapping with a title, a path, and an icon.")
215
+ next
216
+ end
217
+
218
+ unknown_keys(tab, TAB_KEYS, location)
219
+ check_tab_path(tab, location)
220
+ check_tab_icon(tab, location)
221
+ check_tab_icon_names(tab, location)
222
+ check_tab_title(tab, location)
223
+ %w[eager search badge].each { |flag| check_boolean(tab[flag], "#{location}.#{flag}") }
224
+ check_auto_route(tab["auto_route"], "#{location}.auto_route")
225
+
226
+ keys << tab["key"].to_s if tab["key"]
227
+ end
228
+
229
+ duplicate_keys(keys)
230
+ end
231
+
232
+ def check_tab_path(tab, location)
233
+ path = tab["path"]
234
+
235
+ if path.nil?
236
+ error("#{location}.path", "`#{location}` has no `path`. Every tab needs one, and without it the whole config fails to decode: the app shows its error screen instead of your tabs.")
237
+ elsif path.is_a?(String) && !sentinel?(path) && !path.start_with?("/")
238
+ warning("#{location}.path", "`#{location}.path` is #{path.inspect}. Tab paths are absolute, so write it as \"/#{path}\".")
239
+ end
240
+ end
241
+
242
+ # `icon` is a required String on both platforms. The gem backfills it
243
+ # from `icons.ios` or `icons.android`, so either form satisfies it.
244
+ def check_tab_icon(tab, location)
245
+ icons = tab["icons"]
246
+ return if tab["icon"].is_a?(String) && !tab["icon"].empty?
247
+ return if icons.is_a?(Hash) && (icons["ios"] || icons["android"])
248
+
249
+ error("#{location}.icon", "`#{location}` has no icon. Give it `icon:`, or `icons:` with `ios:` or `android:`. Without one the whole config fails to decode and the app shows its error screen. Icon names: #{ICON_DOCS}")
250
+ end
251
+
252
+ # Each platform reads its own `icons:` entry first, then the flat `icon:`.
253
+ def check_tab_icon_names(tab, location)
254
+ icons = tab["icons"].is_a?(Hash) ? tab["icons"] : {}
255
+ flat = tab["icon"] if tab["icon"].is_a?(String)
256
+
257
+ if icons["ios"].is_a?(String)
258
+ check_sf_symbol(icons["ios"], "#{location}.icons.ios")
259
+ elsif flat
260
+ check_sf_symbol(flat, "#{location}.icon", shared: true)
261
+ end
262
+
263
+ if icons["android"].is_a?(String)
264
+ check_material_symbol(icons["android"], "#{location}.icons.android")
265
+ elsif flat
266
+ check_material_symbol(flat, "#{location}.icon", shared: true)
267
+ end
268
+ end
269
+
270
+ def check_sf_symbol(name, key, shared: false)
271
+ return if name.empty? || sentinel?(name)
272
+
273
+ added = self.class.sf_symbols[name]
274
+
275
+ if added.nil?
276
+ hint = if shared && self.class.material_symbols.include?(name)
277
+ " It looks like a Material Symbols name, so move it to `icons.android` and give `icons.ios` an SF Symbol."
278
+ end
279
+ warning(key, "`#{key}` is #{name.inspect}, which isn't an SF Symbol, so iOS draws a placeholder instead.#{hint} Icon names: #{ICON_DOCS}")
280
+ elsif Gem::Version.new(added) > IOS_FLOOR
281
+ warning(key, "`#{key}` is #{name.inspect}, which needs iOS #{added}. Ruby Native apps run on iOS #{IOS_FLOOR.segments.first} and up, so older devices draw a placeholder instead. Pick a symbol that's been around longer, or accept the placeholder on older devices.")
282
+ end
283
+ end
284
+
285
+ def check_material_symbol(name, key, shared: false)
286
+ return if name.empty? || sentinel?(name)
287
+ return if self.class.material_symbols.include?(name)
288
+
289
+ hint = if shared && self.class.sf_symbols.key?(name)
290
+ " That name works on iOS, so keep it under `icons.ios` and give `icons.android` a Material Symbols name."
291
+ end
292
+ warning(key, "`#{key}` is #{name.inspect}, which isn't a Material Symbol, so Android draws a placeholder instead.#{hint} Icon names: #{ICON_DOCS}")
293
+ end
294
+
295
+ # `title` is optional in YAML only because locale files can carry it.
296
+ # With neither, the gem names the tab after its key, or "Untitled".
297
+ def check_tab_title(tab, location)
298
+ return if tab["title"].is_a?(String) && !tab["title"].empty?
299
+
300
+ if tab["key"]
301
+ warning("#{location}.title", "`#{location}` has no `title`, so its label comes from `ruby_native.tabs.#{tab["key"]}.title` in your locale files. Make sure that key exists, or the tab falls back to a title built from #{tab["key"].to_s.inspect}.")
302
+ else
303
+ warning("#{location}.title", "`#{location}` has no `title` and no `key`, so the tab renders as \"Untitled\". Give it a title, or a key with copy under `ruby_native.tabs.<key>.title`.")
304
+ end
305
+ end
306
+
307
+ def check_auto_route(value, key)
308
+ return if value.nil? || value == false || value.is_a?(Array)
309
+
310
+ if value == true
311
+ error(key, "`#{key}` is true, which the app rejects outright: the config fails to decode and the user gets the error screen. Use false to never switch to this tab, or a list of path prefixes.")
312
+ else
313
+ error(key, "`#{key}` is #{value.inspect}. It takes false, or a list of path prefixes like [\"/orders/\"]. Anything else fails the config decode.")
314
+ end
315
+ end
316
+
317
+ def check_auth(auth)
318
+ return if auth.nil?
319
+ return error("auth", "`auth` has to be a mapping.") unless auth.is_a?(Hash)
320
+
321
+ paths = auth["oauth_paths"]
322
+ return if paths.nil?
323
+
324
+ unless paths.is_a?(Array)
325
+ warning("auth.oauth_paths", "`auth.oauth_paths` is a single value. Ruby Native reads it as a list of one, but write it as a list.")
326
+ return
327
+ end
328
+
329
+ paths.select { |path| paths.any? { |start| path == "#{start}/callback" } }.each do |callback|
330
+ warning("auth.oauth_paths", "`auth.oauth_paths` lists #{callback.inspect}. List only the authorize path: Ruby Native drops callbacks, and the app would otherwise read the last segment as a provider named \"callback\" and loop sign-in.")
331
+ end
332
+ end
333
+
334
+ def check_analytics(config)
335
+ return unless config.key?("analytics")
336
+
337
+ value = config["analytics"]
338
+ return if value == true || value == false
339
+
340
+ warning("analytics", "`analytics` is #{value.inspect}. It has to be true or false; Ruby Native drops anything else, including the string \"false\", and keeps reporting.")
341
+ end
342
+
343
+ def check_linked_paths(paths)
344
+ return if paths.nil?
345
+ return error("linked_paths", "`linked_paths` has to be a list of path prefixes.") unless paths.is_a?(Array)
346
+
347
+ paths.each do |path|
348
+ text = path.to_s
349
+
350
+ if text.end_with?("*")
351
+ warning("linked_paths", "`linked_paths` lists #{path.inspect}. Ruby Native strips the trailing `*` -- the AASA file uses one, this file takes a plain prefix -- so drop it here.")
352
+ elsif !text.start_with?("/")
353
+ warning("linked_paths", "`linked_paths` lists #{path.inspect}. Entries are absolute prefixes; Ruby Native rewrites it to \"/#{text}\".")
354
+ end
355
+ end
356
+ end
357
+
358
+ def check_identifiers(section, key, required, endpoint)
359
+ return if section.nil?
360
+ return error(key, "`#{key}` has to be a mapping.") unless section.is_a?(Hash)
361
+
362
+ missing = required.select { |field| section[field].to_s.strip.empty? }
363
+ return if missing.empty?
364
+
365
+ warning(key, "`#{key}` is missing #{missing.map { |field| "`#{field}`" }.join(" and ")}. Your app serves #{endpoint} only once both are set, so universal links and shared passwords stay off until then.")
366
+ end
367
+
368
+ def check_android(android)
369
+ return if android.nil?
370
+ return error("android", "`android` has to be a mapping.") unless android.is_a?(Hash)
371
+
372
+ unknown_keys(android, ANDROID_KEYS, "android")
373
+ if android.key?("package") || android.key?("cert_fingerprint")
374
+ check_identifiers(android, "android", %w[package cert_fingerprint], "/.well-known/assetlinks.json")
375
+ end
376
+ check_firebase(android["firebase"])
377
+ end
378
+
379
+ def check_firebase(firebase)
380
+ return if firebase.nil?
381
+ return warning("android.firebase", "`android.firebase` has to be a mapping with #{FIREBASE_KEYS.join(", ")}. Until it is, push stays off on Android. #{PUSH_DOCS}") unless firebase.is_a?(Hash)
382
+
383
+ unknown_keys(firebase, FIREBASE_KEYS, "android.firebase")
384
+ missing = FIREBASE_KEYS.select { |field| firebase[field].to_s.strip.empty? }
385
+ if missing.any?
386
+ warning("android.firebase", "`android.firebase` is missing #{missing.map { |field| "`#{field}`" }.join(" and ")}. The app needs all four to start Firebase, so push stays off on Android until then. #{PUSH_DOCS}")
387
+ end
388
+
389
+ app_id = firebase["app_id"]
390
+ if app_id.is_a?(String) && !app_id.empty? && !sentinel?(app_id) && !app_id.include?(":android:")
391
+ warning("android.firebase.app_id", "`android.firebase.app_id` is #{app_id.inspect}, which is not an Android app id. Copy `mobilesdk_app_id` from google-services.json; it looks like 1:1234567890:android:abc123.")
392
+ end
393
+ end
394
+
395
+ # --- Value checks ---
396
+
397
+ def check_color(value, key)
398
+ return if value.nil?
399
+
400
+ case value
401
+ when String
402
+ return if sentinel?(value) || value.match?(HEX_COLOR)
403
+
404
+ warning(key, "`#{key}` is #{value.inspect}, which is not a six-digit hex color. The app cannot read it and falls back to its default, with nothing said either way. Write it as \"#RRGGBB\".")
405
+ when Hash
406
+ missing = %w[light dark] - value.keys.map(&:to_s)
407
+
408
+ if missing.any?
409
+ error(key, "`#{key}` is a mapping without #{missing.map { |field| "`#{field}`" }.join(" and ")}. A per-theme color needs both, or the config fails to decode and the app shows its error screen.")
410
+ else
411
+ check_color(value["light"], "#{key}.light")
412
+ check_color(value["dark"], "#{key}.dark")
413
+ end
414
+ else
415
+ error(key, "`#{key}` is #{value.inspect}. A color is a hex string like \"#007AFF\", or a mapping with `light` and `dark`. Anything else fails the config decode and the app shows its error screen.")
416
+ end
417
+ end
418
+
419
+ def check_boolean(value, key)
420
+ return if value.nil? || value == true || value == false
421
+
422
+ error(key, "`#{key}` is #{value.inspect}. It has to be true or false, or the config fails to decode and the app shows its error screen.")
423
+ end
424
+
425
+ def check_enum(value, key, allowed)
426
+ return if value.nil? || sentinel?(value)
427
+ return if allowed.include?(value.to_s)
428
+
429
+ error(key, "`#{key}` is #{value.inspect}. It takes #{allowed.join(", ")}; anything else fails the config decode and the app shows its error screen.")
430
+ end
431
+
432
+ def unknown_keys(section, known, prefix)
433
+ section.each_key do |key|
434
+ next if known.include?(key.to_s)
435
+
436
+ warning("#{prefix}.#{key}", "Unknown key `#{key}` under `#{prefix}`, which Ruby Native ignores. It reads: #{known.join(", ")}.")
437
+ end
438
+ end
439
+
440
+ # A tab's key names its copy in the host app's locale files, so two tabs
441
+ # sharing one read the same translations and one is always mislabeled.
442
+ def duplicate_keys(keys)
443
+ keys.tally.select { |_, count| count > 1 }.each_key do |key|
444
+ warning("tabs", "Two or more tabs share the key #{key.inspect}, so they read the same translations and one of them is always mislabeled. Give each tab its own key.")
445
+ end
446
+ end
447
+
448
+ # --- Offenses ---
449
+
450
+ def error(key, message, line: nil)
451
+ add(:error, key, message, line)
452
+ end
453
+
454
+ def warning(key, message, line: nil)
455
+ add(:warning, key, message, line)
456
+ end
457
+
458
+ def add(severity, key, message, line)
459
+ @offenses << Offense.new(key: key, line: line || line_for(key), severity: severity, message: message)
460
+ end
461
+
462
+ # Walks the file the way the key path walks the document: `tabs[1].icon`
463
+ # finds `tabs:`, then the second list item under it, then `icon:` inside
464
+ # that item. A missing leaf falls back to the block that should have
465
+ # held it, which is where the fix goes. Every other dead end returns
466
+ # nil: a wrong line number sends a reader somewhere else entirely,
467
+ # which is worse than sending them nowhere.
468
+ def line_for(key)
469
+ return nil if key.nil?
470
+
471
+ segments = key.to_s.split(".")
472
+ first = 0
473
+ last = @lines.size
474
+ line = nil
475
+
476
+ segments.each_with_index do |segment, position|
477
+ name, index = segment.match(/\A([^\[]*)(?:\[(\d+)\])?\z/).captures
478
+
479
+ unless name.empty?
480
+ found = find_key(name, first, last)
481
+ return position == segments.size - 1 && line ? line + 1 : nil unless found
482
+
483
+ line = found
484
+ first, last = block_bounds(found)
485
+ end
486
+
487
+ if index
488
+ bounds = item_bounds(first, last, index.to_i)
489
+ return nil unless bounds
490
+
491
+ first, last = bounds
492
+ line = first
493
+ end
494
+ end
495
+
496
+ line ? line + 1 : nil
497
+ end
498
+
499
+ # The first line in range holding `name:`, preferring the shallowest
500
+ # indentation so a direct child wins over one nested deeper. Comments
501
+ # never match, since the `#` lands where the key would.
502
+ def find_key(name, first, last)
503
+ pattern = /\A\s*(?:-\s+)?#{Regexp.escape(name)}\s*:/
504
+
505
+ (first...last)
506
+ .select { |index| @lines[index].match?(pattern) }
507
+ .min_by { |index| [indent(index), index] }
508
+ end
509
+
510
+ # The lines a key owns: everything under it indented deeper than it is.
511
+ def block_bounds(line)
512
+ depth = indent(line)
513
+ finish = ((line + 1)...@lines.size).find { |index| meaningful?(index) && indent(index) <= depth }
514
+
515
+ [line + 1, finish || @lines.size]
516
+ end
517
+
518
+ # The lines of the nth `- ` item in range, counting only items at the
519
+ # shallowest dash indentation so a nested list is not mistaken for one.
520
+ def item_bounds(first, last, wanted)
521
+ starts = (first...last).select { |index| @lines[index].match?(/\A\s*-\s/) }
522
+ return nil if starts.empty?
523
+
524
+ depth = starts.map { |index| indent(index) }.min
525
+ starts = starts.select { |index| indent(index) == depth }
526
+ start = starts[wanted]
527
+ return nil unless start
528
+
529
+ [start, starts[wanted + 1] || last]
530
+ end
531
+
532
+ def indent(line)
533
+ @lines[line][/\A */].length
534
+ end
535
+
536
+ def meaningful?(line)
537
+ !@lines[line].match?(/\A\s*(#|\z)/)
538
+ end
539
+
540
+ def sentinel?(value)
541
+ value.is_a?(String) && value.include?(ERB_SENTINEL)
542
+ end
543
+ end
544
+ end
545
+ end
546
+ end
@@ -0,0 +1,54 @@
1
+ require "json"
2
+
3
+ module RubyNative
4
+ class CLI
5
+ class Mcp
6
+ # Registers the server in the project's `.mcp.json`, the file Claude Code
7
+ # reads for project-scoped servers, so "point your agent at it" is one
8
+ # command rather than a JSON snippet to paste. Anything already in the
9
+ # file is kept; only the ruby-native entry is written.
10
+ class Install
11
+ PATH = ".mcp.json".freeze
12
+ NAME = "ruby-native".freeze
13
+ SERVER = { "command" => "bundle", "args" => %w[exec ruby_native mcp] }.freeze
14
+
15
+ def initialize(_argv = [])
16
+ end
17
+
18
+ def run
19
+ config = existing_config
20
+ servers = config["mcpServers"] = config.fetch("mcpServers", {})
21
+ already = servers[NAME] == SERVER
22
+ servers[NAME] = SERVER
23
+
24
+ File.write(PATH, JSON.pretty_generate(config) + "\n") unless already
25
+
26
+ puts already ? "#{PATH} already has the ruby-native server." : "Added the ruby-native server to #{PATH}."
27
+ puts "Claude Code picks it up on its next start in this directory."
28
+ puts ""
29
+ puts "Other clients take the same entry:"
30
+ puts JSON.pretty_generate("mcpServers" => { NAME => SERVER })
31
+ puts ""
32
+ puts "Then, in your agent:"
33
+ puts ""
34
+ puts " /mcp__#{NAME}__ship"
35
+ puts ""
36
+ puts "It walks every step to the App Store and Google Play and stops at the ones only you can take."
37
+ puts "No prompt support in your agent? Paste the output of: bundle exec ruby_native ship"
38
+ end
39
+
40
+ private
41
+
42
+ def existing_config
43
+ return {} unless File.exist?(PATH)
44
+
45
+ config = JSON.parse(File.read(PATH))
46
+ config.is_a?(Hash) ? config : {}
47
+ rescue JSON::ParserError
48
+ puts "#{PATH} is not valid JSON, so nothing was written. Fix or remove it and run this again."
49
+ exit 1
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end