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,1026 @@
1
+ require "json"
2
+ require "ruby_native/cli/apple"
3
+ require "ruby_native/cli/check"
4
+ require "ruby_native/cli/mcp/config_validator"
5
+ require "ruby_native/cli/mcp/platform"
6
+ require "ruby_native/cli/mcp/prompts"
7
+ require "ruby_native/cli/credentials"
8
+ require "ruby_native/cli/demo_account"
9
+ require "ruby_native/cli/icon"
10
+ require "ruby_native/cli/listing"
11
+ require "ruby_native/cli/permissions"
12
+ require "ruby_native/cli/screenshots"
13
+ require "ruby_native/cli/link"
14
+ require "ruby_native/cli/status"
15
+ require "ruby_native/cli/submit"
16
+ require "ruby_native/cli/testers"
17
+ require "ruby_native/signals"
18
+ require "ruby_native/version"
19
+
20
+ module RubyNative
21
+ class CLI
22
+ # An MCP server over stdin/stdout, so a coding agent working in a Rails app
23
+ # can check its own work before anyone builds it.
24
+ #
25
+ # Signals fail silently by design (see Check): a shell that does not
26
+ # understand one ignores it. A person notices the button that never
27
+ # appeared. An agent writing markup it will never see rendered does not,
28
+ # and neither does the reviewer reading a diff of plausible-looking
29
+ # attributes. These tools hand it the same answers `ruby_native check`
30
+ # gives a person, in a shape it can act on without a device.
31
+ #
32
+ # Read-only throughout, and local by default: check_views, lookup_signals,
33
+ # and validate_config answer from this working copy alone. Three more --
34
+ # config_errors, deployed_builds, and build_status -- read the Ruby Native
35
+ # API with the token `ruby_native login` already stored, because what they
36
+ # answer exists nowhere else: what real devices are failing on, and what
37
+ # the binary in the store is old enough to ignore. Nothing here writes,
38
+ # and nothing deploys -- an agent that can start an App Store build is a
39
+ # different feature with a different set of questions.
40
+ class Mcp
41
+ # The versions of the protocol this speaks. A client asking for one of
42
+ # them gets it back; anything else is answered with the newest, which is
43
+ # what the spec asks a server to do.
44
+ PROTOCOL_VERSIONS = %w[2024-11-05 2025-03-26 2025-06-18].freeze
45
+ PROTOCOL_VERSION = PROTOCOL_VERSIONS.last
46
+
47
+ PARSE_ERROR = -32700
48
+ INVALID_REQUEST = -32600
49
+ METHOD_NOT_FOUND = -32601
50
+ INVALID_PARAMS = -32602
51
+
52
+ # Raised by a tool that cannot run at all, as opposed to one that ran and
53
+ # found problems. Problems are the answer; these are reported as failures.
54
+ class ToolFailure < StandardError; end
55
+
56
+ # A method this server does not implement, which is a different answer
57
+ # from a call it understood and could not carry out.
58
+ class UnknownMethod < StandardError; end
59
+
60
+ TOOLS = [
61
+ {
62
+ name: "check_views",
63
+ description: "Check a Rails app's ERB templates against the Ruby Native signal vocabulary and report what " \
64
+ "the native app will silently ignore: misspelled data-native-* attributes, signals newer than " \
65
+ "the installed gem, and duplicates of a signal that is only read once. Run this after writing " \
66
+ "or editing any view that uses data-native-* attributes or native_* helpers -- these failures " \
67
+ "are invisible at runtime, so nothing else will catch them.",
68
+ inputSchema: {
69
+ type: "object",
70
+ properties: {
71
+ paths: {
72
+ type: "array",
73
+ items: { type: "string" },
74
+ description: "Directories to scan, relative to the app root. Defaults to app/views."
75
+ }
76
+ },
77
+ additionalProperties: false
78
+ },
79
+ annotations: { title: "Check views", readOnlyHint: true, openWorldHint: false }
80
+ },
81
+ {
82
+ name: "lookup_signals",
83
+ description: "Look up the Ruby Native signal vocabulary: every data-native-* attribute the native apps " \
84
+ "read, the gem version each one needs, and the helper that emits it. Call this before " \
85
+ "writing a data-native-* attribute rather than guessing at one, because an attribute the " \
86
+ "apps do not know is ignored without any error.",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ name: {
91
+ type: "string",
92
+ description: "One signal to look up, with or without the data-native- prefix. Omit to list them all."
93
+ }
94
+ },
95
+ additionalProperties: false
96
+ },
97
+ annotations: { title: "Look up signals", readOnlyHint: true, openWorldHint: false }
98
+ },
99
+ {
100
+ name: "validate_config",
101
+ description: "Check config/ruby_native.yml against what the native apps actually decode. Reports errors " \
102
+ "that make the app fail to load its config and show an error screen instead of the app -- a " \
103
+ "tab without an icon, an unrecognized theme -- and warnings for settings that are silently " \
104
+ "ignored, like a three-digit hex color or a misspelled key. Run this after editing the file.",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ path: {
109
+ type: "string",
110
+ description: "Path to the config file. Defaults to config/ruby_native.yml."
111
+ }
112
+ },
113
+ additionalProperties: false
114
+ },
115
+ annotations: { title: "Validate config", readOnlyHint: true, openWorldHint: false }
116
+ },
117
+ {
118
+ name: "config_errors",
119
+ description: "Report what the app your users are running is failing on: the config errors real devices " \
120
+ "sent back over the last 48 hours, each with the decode failure that caused it. This is " \
121
+ "the runtime counterpart to validate_config -- that one says what will fail, this one says " \
122
+ "what did, on which devices. Reaches rubynative.com and needs `ruby_native login`.",
123
+ inputSchema: {
124
+ type: "object",
125
+ properties: {
126
+ app_id: {
127
+ type: "string",
128
+ description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml."
129
+ }
130
+ },
131
+ additionalProperties: false
132
+ },
133
+ annotations: { title: "Config errors from devices", readOnlyHint: true, openWorldHint: true }
134
+ },
135
+ {
136
+ name: "deployed_builds",
137
+ description: "Report the latest iOS and Android build your users can install, and which signals that " \
138
+ "build is too old to understand. check_views compares your templates against the installed " \
139
+ "gem; this compares them against the binary in the store, which is where \"the attribute " \
140
+ "is right but nothing happens on my phone\" usually comes from. Reaches rubynative.com and " \
141
+ "needs `ruby_native login`.",
142
+ inputSchema: {
143
+ type: "object",
144
+ properties: {
145
+ app_id: {
146
+ type: "string",
147
+ description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml."
148
+ }
149
+ },
150
+ additionalProperties: false
151
+ },
152
+ annotations: { title: "Deployed builds", readOnlyHint: true, openWorldHint: true }
153
+ },
154
+ {
155
+ name: "build_status",
156
+ description: "Look up one build by id: whether it succeeded, and the error message if it did not. Use " \
157
+ "it to follow a build `ruby_native deploy` started. Reaches rubynative.com and needs " \
158
+ "`ruby_native login`.",
159
+ inputSchema: {
160
+ type: "object",
161
+ properties: {
162
+ build_id: { type: "integer", description: "The build's id, as deploy and this server report it." },
163
+ app_id: {
164
+ type: "string",
165
+ description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml."
166
+ }
167
+ },
168
+ required: [ "build_id" ],
169
+ additionalProperties: false
170
+ },
171
+ annotations: { title: "Build status", readOnlyHint: true, openWorldHint: true }
172
+ },
173
+ {
174
+ name: "link_app",
175
+ description: "Link this project to an app on rubynative.com by writing `ruby_native.app_id` into " \
176
+ "config/ruby_native.yml. Call it when another tool answers that no app is linked. Without " \
177
+ "`app_id` it links the account's only app, or lists the apps to choose from. Reaches " \
178
+ "rubynative.com and needs `ruby_native login`.",
179
+ inputSchema: {
180
+ type: "object",
181
+ properties: {
182
+ app_id: { type: "string", description: "The app's public id, from the list this tool returns or the dashboard URL." }
183
+ }
184
+ },
185
+ annotations: { title: "Link app", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
186
+ },
187
+ {
188
+ name: "status",
189
+ description: "Report where the app is on the way to the App Store and Google Play and what is in the way: " \
190
+ "setup, subscription, the latest build, store state, and a `blocked_by` list per track for its next act. " \
191
+ "Each blocker says who clears it. `who: human` means stop and hand the person the steps: " \
192
+ "paying, creating the app record, or answering a questionnaire Apple gives us no API for. " \
193
+ "Call this first, and again after the person says they have done a step. Before " \
194
+ "`ruby_native login` or a linked app it answers from this checkout instead: the config, the " \
195
+ "first native pass, and what to do next. Otherwise reaches rubynative.com.",
196
+ inputSchema: {
197
+ type: "object",
198
+ properties: {
199
+ app_id: {
200
+ type: "string",
201
+ description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml."
202
+ }
203
+ },
204
+ additionalProperties: false
205
+ },
206
+ annotations: { title: "App status", readOnlyHint: true, openWorldHint: true }
207
+ },
208
+ {
209
+ name: "apple_key_set",
210
+ description: "Hand over the App Store Connect key the person minted: the path to the .p8 they downloaded, " \
211
+ "plus the key ID, the issuer ID, and the Team ID. Only a person can make the key (a Team key with " \
212
+ "the Admin role, under Users and Access > Integrations); ask them for the path, never read the " \
213
+ "file yourself. Ruby Native checks it against Apple and this waits up to half a minute for the " \
214
+ "answer; if it is still checking, read `status` again shortly. Reaches rubynative.com and needs " \
215
+ "`ruby_native login`.",
216
+ inputSchema: {
217
+ type: "object",
218
+ properties: {
219
+ path: { type: "string", description: "Path to the .p8 file, relative to the app root or absolute." },
220
+ key_id: { type: "string", description: "The key ID shown next to the key in App Store Connect." },
221
+ issuer_id: { type: "string", description: "The issuer ID shown above the keys list." },
222
+ team_id: { type: "string", description: "The Team ID from developer.apple.com/account." },
223
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
224
+ },
225
+ required: [ "path", "key_id", "issuer_id", "team_id" ],
226
+ additionalProperties: false
227
+ },
228
+ annotations: { title: "Set the App Store Connect key", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
229
+ },
230
+ {
231
+ name: "permissions_set",
232
+ description: "Set the sentence iOS shows when the app first asks for the camera, the microphone, or the " \
233
+ "photo library. Say what the app does with it, in the user's words. The same three strings decide " \
234
+ "which permissions the Android manifest declares, so setting one turns the feature on for both " \
235
+ "stores. They land in Info.plist and the manifest, so a change ships with the next " \
236
+ "`ruby_native deploy`, not the next Rails deploy. Send only the ones to change; an empty string " \
237
+ "removes one. Reaches rubynative.com and needs `ruby_native login`.",
238
+ inputSchema: {
239
+ type: "object",
240
+ properties: {
241
+ camera: { type: "string", description: "Why the app uses the camera, for example \"To scan tickets at the door.\"" },
242
+ microphone: { type: "string", description: "Why the app uses the microphone." },
243
+ photos: { type: "string", description: "Why the app reads the photo library." },
244
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
245
+ },
246
+ additionalProperties: false
247
+ },
248
+ annotations: { title: "Set permission strings", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
249
+ },
250
+ {
251
+ name: "testers_list",
252
+ description: "List the internal TestFlight testers on the app and whether each has accepted the invite. " \
253
+ "With platform android, Google Play keeps its list in Google Play Console, so the answer is the hand-off " \
254
+ "with the steps. Reaches rubynative.com and needs `ruby_native login`.",
255
+ inputSchema: {
256
+ type: "object",
257
+ properties: {
258
+ platform: { type: "string", enum: [ "ios", "android" ], description: "Defaults to ios." },
259
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
260
+ },
261
+ additionalProperties: false
262
+ },
263
+ annotations: { title: "List testers", readOnlyHint: true, openWorldHint: true }
264
+ },
265
+ {
266
+ name: "testers_add",
267
+ description: "Invite an App Store Connect team member to TestFlight for this app. Apple emails them the " \
268
+ "invite; only they can accept it. Someone not on the team is refused with what to do. Reaches " \
269
+ "rubynative.com and needs `ruby_native login`.",
270
+ inputSchema: {
271
+ type: "object",
272
+ properties: {
273
+ email: { type: "string", description: "The Apple ID email the person uses for App Store Connect." },
274
+ platform: { type: "string", enum: [ "ios", "android" ], description: "Defaults to ios." },
275
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
276
+ },
277
+ required: [ "email" ],
278
+ additionalProperties: false
279
+ },
280
+ annotations: { title: "Add a tester", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
281
+ },
282
+ {
283
+ name: "icon_set",
284
+ description: "Upload the app icon: a 1024x1024 PNG with no transparency, from a path in this project. It " \
285
+ "ships with the next `ruby_native deploy`. Reaches rubynative.com and needs `ruby_native login`.",
286
+ inputSchema: {
287
+ type: "object",
288
+ properties: {
289
+ path: { type: "string", description: "Path to the PNG, relative to the app root." },
290
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
291
+ },
292
+ required: [ "path" ],
293
+ additionalProperties: false
294
+ },
295
+ annotations: { title: "Set the app icon", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
296
+ },
297
+ {
298
+ name: "screenshots_setup",
299
+ description: "Set up store screenshots, which rubynative.com captures against the deployed site: sets " \
300
+ "the 3 to 10 pages to capture, for one store or both. Only pages behind a sign-in need the " \
301
+ "screenshot key: pass sign_in to generate it (returned once, with where to put it in the app's " \
302
+ "Rails credentials and initializer) and deploy the site before capturing. Then call " \
303
+ "screenshots_capture. Reaches rubynative.com and needs `ruby_native login`.",
304
+ inputSchema: {
305
+ type: "object",
306
+ properties: {
307
+ paths: { type: "array", items: { type: "string" }, description: "3 to 10 absolute paths that show the app off, for example [\"/\", \"/inbox\", \"/settings\"]." },
308
+ platform: { type: "string", enum: [ "ios", "android" ], description: "The store the pages are for. Omit to set both." },
309
+ sign_in: { type: "boolean", description: "The pages need a signed-in user: generate the key if there is none." },
310
+ rotate_key: { type: "boolean", description: "Replace an existing key. The old one stops working." },
311
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
312
+ },
313
+ additionalProperties: false
314
+ },
315
+ annotations: { title: "Set up screenshots", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
316
+ },
317
+ {
318
+ name: "screenshots_capture",
319
+ description: "Start a screenshot capture of the deployed site on a phone. Takes about five minutes; follow " \
320
+ "it with screenshots_list. Publishing the result to the store is a click on the dashboard, not " \
321
+ "a tool. Reaches rubynative.com and needs `ruby_native login`.",
322
+ inputSchema: {
323
+ type: "object",
324
+ properties: {
325
+ platform: { type: "string", enum: [ "ios", "android" ], description: "Defaults to ios." },
326
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
327
+ },
328
+ additionalProperties: false
329
+ },
330
+ annotations: { title: "Capture screenshots", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
331
+ },
332
+ {
333
+ name: "screenshots_list",
334
+ description: "Report the screenshot setup (key, pages) and the latest capture per platform. Reaches " \
335
+ "rubynative.com and needs `ruby_native login`.",
336
+ inputSchema: {
337
+ type: "object",
338
+ properties: {
339
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
340
+ },
341
+ additionalProperties: false
342
+ },
343
+ annotations: { title: "List screenshots", readOnlyHint: true, openWorldHint: true }
344
+ },
345
+ {
346
+ name: "listing_push",
347
+ description: "Save config/ruby_native_listing.yml to Ruby Native as the listing draft for both stores. Write the " \
348
+ "file first: name (30), subtitle (30), keywords (100, comma separated, no spaces), " \
349
+ "whats_new (4000, required on every version after the first), description (4000), privacy_policy_url, support_url, " \
350
+ "primary_category and secondary_category from Apple's list, copyright (\"2026 Trailhead LLC\"), " \
351
+ "support_email and website (Google Play's contact details; Play requires the email), and review_contact, " \
352
+ "who App Review contacts, with first_name, last_name, email, and phone (quoted, a + and the " \
353
+ "country code: \"+1 503 555 0100\"). An ios: or android: block replaces name, subtitle, " \
354
+ "description, or whats_new for that store alone (Apple refuses a listing that mentions Android; Play's " \
355
+ "subtitle, its short description, takes 80). Nothing reaches a store: a person publishes it. " \
356
+ "Reaches rubynative.com and needs `ruby_native login`.",
357
+ inputSchema: {
358
+ type: "object",
359
+ properties: {
360
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
361
+ },
362
+ additionalProperties: false
363
+ },
364
+ annotations: { title: "Push the listing", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
365
+ },
366
+ {
367
+ name: "listing_pull",
368
+ description: "Write config/ruby_native_listing.yml from the listing Ruby Native holds, so a draft made on " \
369
+ "the dashboard can be edited in the repo. Reaches rubynative.com and needs `ruby_native login`.",
370
+ inputSchema: {
371
+ type: "object",
372
+ properties: {
373
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
374
+ },
375
+ additionalProperties: false
376
+ },
377
+ annotations: { title: "Pull the listing", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
378
+ },
379
+ {
380
+ name: "demo_account_set",
381
+ description: "Stage the sign-in App Review uses to get past the app's login: an account that exists on " \
382
+ "the production site. It reaches App Store Connect when the listing is published. Keep it " \
383
+ "out of the listing file, which is committed. Reaches rubynative.com and needs `ruby_native login`.",
384
+ inputSchema: {
385
+ type: "object",
386
+ properties: {
387
+ name: { type: "string", description: "The username or email the reviewer signs in with." },
388
+ password: { type: "string", description: "Its password." },
389
+ notes: { type: "string", description: "Anything the reviewer needs to know to reach the app's features." },
390
+ required: { type: "boolean", description: "Whether the app needs a sign-in at all. Defaults to true." },
391
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
392
+ },
393
+ required: [ "name" ],
394
+ additionalProperties: false
395
+ },
396
+ annotations: { title: "Set the demo account", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
397
+ },
398
+ {
399
+ name: "submit",
400
+ description: "Submit the app for App Store review, in two calls. Without `confirmation_token` it submits " \
401
+ "NOTHING: it returns the exact listing, build, and checklist that would go to Apple, as a " \
402
+ "summary written to be read aloud, plus a token that expires in ten minutes. Show the person " \
403
+ "that summary and get their explicit yes before calling again with the token. Only then does " \
404
+ "it submit. Until Apple approves, `withdraw` takes it back out of the queue. With platform " \
405
+ "android nothing is ever sent: Ruby Native never promotes to production, so the answer is the " \
406
+ "go-live list with the Google Play Console promotion steps last. Reaches rubynative.com and needs " \
407
+ "`ruby_native login`.",
408
+ inputSchema: {
409
+ type: "object",
410
+ properties: {
411
+ confirmation_token: { type: "string", description: "The token from the previous call, after the person said yes." },
412
+ confirmations: {
413
+ type: "array",
414
+ items: { type: "string", enum: [ "app_privacy", "age_rating", "play_category", "play_content" ] },
415
+ description: "Questionnaires the person says they have completed in App Store Connect or Google Play Console. Send one only after they say so, in their words; never infer it."
416
+ },
417
+ platform: { type: "string", enum: [ "ios", "android" ], description: "Defaults to ios." },
418
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
419
+ },
420
+ additionalProperties: false
421
+ },
422
+ annotations: { title: "Submit for review", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }
423
+ },
424
+ {
425
+ name: "withdraw",
426
+ description: "Take the app back out of the App Store review queue. Works from submission until Apple " \
427
+ "approves it; after release there is nothing to withdraw. Costs a place in the queue and " \
428
+ "nothing else. Reaches rubynative.com and needs `ruby_native login`.",
429
+ inputSchema: {
430
+ type: "object",
431
+ properties: {
432
+ platform: { type: "string", enum: [ "ios", "android" ], description: "Defaults to ios." },
433
+ app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
434
+ },
435
+ additionalProperties: false
436
+ },
437
+ annotations: { title: "Withdraw from review", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
438
+ }
439
+ ].freeze
440
+
441
+ # How many inert signals deployed_builds names before it just counts the
442
+ # rest: enough to act on, not so many that the answer is a wall.
443
+ SIGNAL_PREVIEW = 10
444
+
445
+ # A raw decode failure can run long. The whole value stays in the
446
+ # structured result; this keeps the readable report readable.
447
+ DETAIL_LIMIT = 400
448
+
449
+ # `argv` is accepted so the CLI can build every command the same way; this
450
+ # one takes no flags.
451
+ def initialize(_argv = [], input: $stdin, output: $stdout)
452
+ @input = input
453
+ @output = output
454
+ end
455
+
456
+ def run
457
+ @output.sync = true if @output.respond_to?(:sync=)
458
+ Platform.client = "mcp"
459
+ warn "ruby_native #{RubyNative::VERSION} MCP server ready on stdio."
460
+
461
+ while (line = @input.gets)
462
+ next if line.strip.empty?
463
+
464
+ handle(line)
465
+ end
466
+ ensure
467
+ Platform.client = nil
468
+ end
469
+
470
+ private
471
+
472
+ def handle(line)
473
+ message = JSON.parse(line)
474
+ rescue JSON::ParserError => error
475
+ write(nil, error: failure(PARSE_ERROR, "Invalid JSON: #{error.message}"))
476
+ else
477
+ unless message.is_a?(Hash)
478
+ return write(nil, error: failure(INVALID_REQUEST, "A request has to be a JSON object."))
479
+ end
480
+
481
+ id = message["id"]
482
+ # A notification carries no id and takes no reply, not even an error.
483
+ return if id.nil?
484
+
485
+ begin
486
+ write(id, result: result_for(message["method"], message["params"] || {}))
487
+ rescue UnknownMethod => error
488
+ write(id, error: failure(METHOD_NOT_FOUND, error.message))
489
+ rescue ToolFailure => error
490
+ write(id, error: failure(INVALID_PARAMS, error.message))
491
+ end
492
+ end
493
+
494
+ def result_for(method, params)
495
+ case method
496
+ when "initialize" then initialize_result(params)
497
+ when "tools/list" then { tools: TOOLS }
498
+ when "tools/call" then call(params)
499
+ when "prompts/list" then { prompts: Prompts::LIST }
500
+ when "prompts/get" then prompt(params)
501
+ when "ping" then {}
502
+ else raise UnknownMethod, "Unknown method: #{method.inspect}."
503
+ end
504
+ end
505
+
506
+ def initialize_result(params)
507
+ requested = params["protocolVersion"]
508
+
509
+ {
510
+ protocolVersion: PROTOCOL_VERSIONS.include?(requested) ? requested : PROTOCOL_VERSION,
511
+ capabilities: { tools: {}, prompts: {} },
512
+ serverInfo: { name: "ruby_native", version: RubyNative::VERSION },
513
+ instructions: INSTRUCTIONS
514
+ }
515
+ end
516
+
517
+ def prompt(params)
518
+ name = params["name"]
519
+ Prompts.get(name) or raise ToolFailure, "Unknown prompt: #{name.inspect}. Call prompts/list for the ones this server has."
520
+ end
521
+
522
+ # What a client hands the model about this server. "Set up Ruby Native in
523
+ # this app" has to be answerable from here plus the docs, so the setup
524
+ # steps and the rules of the road are stated once, in the handshake.
525
+ INSTRUCTIONS = <<~TEXT.strip
526
+ Ruby Native turns this Rails app into an iOS and Android app. The native shell loads the site
527
+ in web views and reads `data-native-*` attributes (signals) from the HTML to render native
528
+ tabs, buttons, forms, and more. `config/ruby_native.yml` configures the shell and is fetched
529
+ by the app on every launch, so config changes ship with a Rails deploy and need no rebuild.
530
+
531
+ Setting up: (1) `config/ruby_native.yml` exists; edit tabs so every path is one the app routes.
532
+ (2) In the layout <head>, add `<%= stylesheet_link_tag :ruby_native %>` and put
533
+ `viewport-fit=cover` in the viewport meta tag. (3) In the layout <body>, add
534
+ `<%= native_tabs_tag %>` and `<%= native_identity_tag(current_user&.id) %>`.
535
+ (4) Run `validate_config` and `check_views`, then `bundle exec ruby_native preview` to see it
536
+ on a phone. Full docs: https://rubynative.com/llms.txt (index) and llms-full.txt (everything).
537
+
538
+ Signals fail silently: an attribute the app does not know is ignored with no error anywhere.
539
+ Call `lookup_signals` before writing one and `check_views` after editing any view.
540
+
541
+ `status` reports where the app is on the way to the store and a `blocked_by` list. A blocker
542
+ whose `who` is `human` is a step only a person can take (pay, create the App Store Connect
543
+ record, answer a questionnaire Apple has no API for). Stop, show them the steps and the URL,
544
+ and read `status` again when they say it is done. Never claim a `verifiable: false` blocker is
545
+ cleared: only the person can, on the dashboard. Before login or a linked app, `status`
546
+ answers from this checkout and says what comes next.
547
+
548
+ The `ship` prompt is the whole way to the stores in one command: install, the first native
549
+ pass, the account, then every store step in order, stopping at the ones only a person can
550
+ take. Start there when asked to ship, set up, or get the app into the stores.
551
+ TEXT
552
+
553
+ def call(params)
554
+ name = params["name"]
555
+ arguments = params["arguments"] || {}
556
+
557
+ # The protocol owns stdout: one JSON message per line and nothing else.
558
+ # Library code underneath is free to `puts` -- Check does, on the paths
559
+ # this does not take -- so stdout is pointed at stderr for the duration
560
+ # of a call rather than trusted to stay quiet. `@output` holds the real
561
+ # stream, so replies are unaffected.
562
+ quietly do
563
+ case name
564
+ when "check_views" then check_views(arguments)
565
+ when "lookup_signals" then lookup_signals(arguments)
566
+ when "validate_config" then validate_config(arguments)
567
+ when "config_errors" then config_errors(arguments)
568
+ when "deployed_builds" then deployed_builds(arguments)
569
+ when "build_status" then build_status(arguments)
570
+ when "status" then status(arguments)
571
+ when "link_app"
572
+ outcome = Link.link(arguments["app_id"].to_s.strip.empty? ? nil : arguments["app_id"].to_s.strip)
573
+ tool_result(outcome.text, outcome.data, is_error: !outcome.ok)
574
+ when "apple_key_set"
575
+ with_app(arguments) do |app_id|
576
+ Apple.set_key(app_id, path: string!(arguments, "path"), key_id: string!(arguments, "key_id"),
577
+ issuer_id: string!(arguments, "issuer_id"), team_id: string!(arguments, "team_id"), wait: 30)
578
+ end
579
+ when "permissions_set" then with_app(arguments) { |app_id| Permissions.set(app_id, arguments.slice("camera", "microphone", "photos")) }
580
+ when "testers_list" then with_app(arguments) { |app_id| Testers.list(app_id, platform: arguments["platform"] || "ios") }
581
+ when "testers_add" then with_app(arguments) { |app_id| Testers.add(app_id, string!(arguments, "email"), platform: arguments["platform"] || "ios") }
582
+ when "icon_set" then with_app(arguments) { |app_id| Icon.set(app_id, string!(arguments, "path")) }
583
+ when "screenshots_setup" then with_app(arguments) { |app_id| Screenshots.setup(app_id, paths: string_list(arguments["paths"], "paths") || [], platforms: (arguments["platform"] ? [ arguments["platform"] ] : nil), sign_in: arguments["sign_in"] == true, rotate_key: arguments["rotate_key"] == true) }
584
+ when "screenshots_capture" then with_app(arguments) { |app_id| Screenshots.capture(app_id, platform: arguments["platform"] || "ios") }
585
+ when "screenshots_list" then with_app(arguments) { |app_id| Screenshots.list(app_id) }
586
+ when "submit"
587
+ with_app(arguments) do |app_id|
588
+ token = arguments["confirmation_token"]
589
+ platform = arguments["platform"] || "ios"
590
+ confirmations = string_list(arguments["confirmations"], "confirmations") || []
591
+ if platform == "android" || token.to_s.strip.empty?
592
+ Submit.preview(app_id, platform: platform, confirmations: confirmations)
593
+ else
594
+ Submit.submit(app_id, token.to_s.strip)
595
+ end
596
+ end
597
+ when "withdraw" then with_app(arguments) { |app_id| Submit.withdraw(app_id, platform: arguments["platform"] || "ios") }
598
+ when "listing_push" then with_app(arguments) { |app_id| Listing.push(app_id) }
599
+ when "listing_pull" then with_app(arguments) { |app_id| Listing.pull(app_id) }
600
+ when "demo_account_set"
601
+ with_app(arguments) do |app_id|
602
+ DemoAccount.set(app_id, name: string!(arguments, "name"), password: arguments["password"], notes: arguments["notes"],
603
+ required: arguments.fetch("required", true) == true)
604
+ end
605
+ else raise ToolFailure, "Unknown tool: #{name.inspect}. Call tools/list for the ones this server has."
606
+ end
607
+ end
608
+ rescue ToolFailure
609
+ raise
610
+ rescue StandardError => error
611
+ # A tool that blew up is an answer the model can work with, so it comes
612
+ # back as a result rather than a protocol error it cannot see.
613
+ tool_result("#{error.class}: #{error.message}", { error: error.message }, is_error: true)
614
+ end
615
+
616
+ # --- Tools ---
617
+
618
+ def check_views(arguments)
619
+ paths = string_list(arguments["paths"], "paths") || Check::DEFAULT_PATHS
620
+
621
+ unless Check.herb_available?
622
+ return tool_result(<<~TEXT.strip, { herb_available: false }, is_error: true)
623
+ check_views needs the herb gem, which parses HTML and ERB together.
624
+
625
+ Add it to the Gemfile:
626
+ gem "herb"
627
+
628
+ Rails 8.2 and later already ship it as a dependency of Action View.
629
+ TEXT
630
+ end
631
+
632
+ files = Check.template_files(paths: paths)
633
+ offenses = Check.signal_offenses(paths: paths) || []
634
+
635
+ if files.empty?
636
+ return tool_result("No .html.erb templates found in #{paths.join(", ")}.",
637
+ { paths: paths, checked: 0, offenses: [] })
638
+ end
639
+
640
+ errors, warnings = offenses.partition { |offense| offense.severity == :error }
641
+
642
+ tool_result(Check.report_lines(files, offenses).join("\n"), {
643
+ paths: paths,
644
+ checked: files.size,
645
+ gem_version: RubyNative::VERSION,
646
+ errors: errors.size,
647
+ warnings: warnings.size,
648
+ offenses: offenses.map do |offense|
649
+ { file: offense.file, line: offense.line, severity: offense.severity.to_s, message: offense.message }
650
+ end
651
+ })
652
+ end
653
+
654
+ def lookup_signals(arguments)
655
+ name = arguments["name"]
656
+ return signal_list if name.nil? || name.to_s.strip.empty?
657
+
658
+ raise ToolFailure, "`name` has to be a string." unless name.is_a?(String)
659
+
660
+ signal_detail(qualify(name.strip))
661
+ end
662
+
663
+ def validate_config(arguments)
664
+ path = arguments["path"] || ConfigValidator::DEFAULT_PATH
665
+ raise ToolFailure, "`path` has to be a string." unless path.is_a?(String)
666
+
667
+ offenses = in_line_order(ConfigValidator.run(path: path))
668
+ errors, warnings = offenses.partition { |offense| offense.severity == :error }
669
+
670
+ tool_result(config_report(path, offenses), {
671
+ path: path,
672
+ errors: errors.size,
673
+ warnings: warnings.size,
674
+ offenses: offenses.map do |offense|
675
+ { file: path, key: offense.key, line: offense.line, severity: offense.severity.to_s, message: offense.message }
676
+ end
677
+ })
678
+ end
679
+
680
+ # --- Tools that read the API ---
681
+
682
+ def config_errors(arguments)
683
+ app_id = resolve_app_id(arguments)
684
+ return tool_result(Platform::NO_APP_ID, { app_id: nil }, is_error: true) unless app_id
685
+
686
+ result = Platform.get("/api/v1/apps/#{app_id}/config_error_reports")
687
+ unless result.ok?
688
+ return tool_result(failure_for(result, app_id), { app_id: app_id }, is_error: true)
689
+ end
690
+
691
+ reports = Array(result.value)
692
+
693
+ tool_result(config_errors_report(reports), { app_id: app_id, count: reports.size, reports: reports })
694
+ end
695
+
696
+ def deployed_builds(arguments)
697
+ app_id = resolve_app_id(arguments)
698
+ return tool_result(Platform::NO_APP_ID, { app_id: nil }, is_error: true) unless app_id
699
+
700
+ result = Platform.get("/api/v1/apps/#{app_id}/builds/latest?platform=all")
701
+ return tool_result(failure_for(result, app_id), { app_id: app_id }, is_error: true) unless result.ok?
702
+
703
+ builds = result.value.is_a?(Hash) ? result.value : {}
704
+
705
+ tool_result(deployed_builds_report(builds), {
706
+ app_id: app_id,
707
+ gem_version: RubyNative::VERSION,
708
+ platforms: builds.transform_values do |state|
709
+ state.is_a?(Hash) ? state.merge("inert_signals" => inert_signals(state["gem_version"])) : state
710
+ end
711
+ })
712
+ end
713
+
714
+ def build_status(arguments)
715
+ build_id = arguments["build_id"]
716
+ unless build_id.is_a?(Integer) || build_id.to_s.match?(/\A\d+\z/)
717
+ raise ToolFailure, "`build_id` is required and has to be a number."
718
+ end
719
+
720
+ app_id = resolve_app_id(arguments)
721
+ return tool_result(Platform::NO_APP_ID, { app_id: nil }, is_error: true) unless app_id
722
+
723
+ result = Platform.get("/api/v1/apps/#{app_id}/builds/#{build_id}")
724
+ unless result.ok?
725
+ # Either half of the path can be the missing one, and the API does
726
+ # not say which.
727
+ message = result.not_found? ? "No build #{build_id} in app #{app_id.inspect} on this account." : result.error
728
+ return tool_result(message, { app_id: app_id, build_id: build_id }, is_error: true)
729
+ end
730
+
731
+ build = result.value.is_a?(Hash) ? result.value : {}
732
+
733
+ tool_result(build_status_report(build), build.merge("app_id" => app_id))
734
+ end
735
+
736
+ # Before there is anything to fetch, the answer is what this checkout
737
+ # shows: the same report `ruby_native status` prints, so the first thing
738
+ # an agent asks has an answer even on a fresh clone with no account.
739
+ def status(arguments)
740
+ app_id = resolve_app_id(arguments)
741
+ if app_id.nil? || Credentials.token.nil?
742
+ local = Status.local
743
+ return tool_result(Status.local_report(local), local)
744
+ end
745
+
746
+ result = Status.fetch(app_id)
747
+ unless result.ok?
748
+ return tool_result(Status.failure_message(result, app_id), { app_id: app_id }, is_error: true)
749
+ end
750
+
751
+ status = result.value.is_a?(Hash) ? result.value : {}
752
+
753
+ tool_result(Status.report(status), status)
754
+ end
755
+
756
+ # The CLI commands do the work; a tool is the same verb answered in the
757
+ # protocol's shape.
758
+ def with_app(arguments)
759
+ app_id = resolve_app_id(arguments)
760
+ return tool_result(Platform::NO_APP_ID, { app_id: nil }, is_error: true) unless app_id
761
+
762
+ outcome = yield(app_id)
763
+ tool_result(outcome.text, outcome.data, is_error: !outcome.ok)
764
+ end
765
+
766
+ def string!(arguments, name)
767
+ value = arguments[name]
768
+ raise ToolFailure, "`#{name}` is required and has to be a string." unless value.is_a?(String) && !value.strip.empty?
769
+
770
+ value.strip
771
+ end
772
+
773
+ # --- Rendering ---
774
+
775
+ def config_errors_report(reports)
776
+ if reports.empty?
777
+ return "No config errors reported in the last 48 hours. Only a device running a build reports here, so " \
778
+ "this says nothing about config you have not deployed yet."
779
+ end
780
+
781
+ lines = [ "#{reports.size} config #{reports.size == 1 ? "error" : "errors"} reported by devices in the " \
782
+ "last 48 hours, newest first.", "" ]
783
+
784
+ reports.each do |report|
785
+ lines << "#{report["error_type"]} — #{report["headline"]}"
786
+ lines << " #{report["error_description"]}" unless report["error_description"].to_s.strip.empty?
787
+ lines << " #{devices_line(report)}"
788
+ lines << " first seen #{report["first_seen_at"]}, last seen #{report["last_seen_at"]}"
789
+
790
+ detail = report["error_detail"].to_s.strip
791
+ lines << " #{truncate(detail, DETAIL_LIMIT).gsub("\n", "\n ")}" unless detail.empty?
792
+ lines << ""
793
+ end
794
+
795
+ lines.join("\n").strip
796
+ end
797
+
798
+ def devices_line(report)
799
+ os = [ report["os_name"], report["os_version"] ].map(&:to_s).reject(&:empty?).join(" ")
800
+ version = report["app_version"].to_s
801
+ build = report["build_number"].to_s
802
+ app = build.empty? ? version : "#{version} (#{build})".strip
803
+
804
+ [ os, report["device_model"].to_s, app.empty? ? "" : "app #{app}" ].reject(&:empty?).join(" · ")
805
+ end
806
+
807
+ def deployed_builds_report(builds)
808
+ return "#{Platform::HOST} has no build information for this app." if builds.empty?
809
+
810
+ lines = [ "This project is on ruby_native #{RubyNative::VERSION}.", "" ]
811
+
812
+ builds.each do |platform, state|
813
+ label = platform == "ios" ? "iOS" : platform.to_s.capitalize
814
+
815
+ unless state.is_a?(Hash)
816
+ lines << "#{label} — no information."
817
+ next
818
+ end
819
+
820
+ unless state["deployable"]
821
+ lines << "#{label} — not set up for deploys yet."
822
+ next
823
+ end
824
+
825
+ built = state["gem_version"]
826
+ if built.to_s.empty?
827
+ lines << "#{label} — set up to deploy, but nothing has been built yet."
828
+ next
829
+ end
830
+
831
+ lines << "#{label} — build #{state["number"]} (#{state["version"]}), #{state["status"]}, " \
832
+ "made with ruby_native #{built}."
833
+ lines.concat(skew_lines(built))
834
+ end
835
+
836
+ lines.join("\n").strip
837
+ end
838
+
839
+ # The point of the whole tool: a signal the shipped binary predates is
840
+ # inert on the phone in someone's hand, and says nothing about it there
841
+ # or anywhere else.
842
+ def skew_lines(built)
843
+ inert = inert_signals(built)
844
+ return [ " Nothing in the signal vocabulary is newer than that build." ] if inert.empty?
845
+
846
+ shown = inert.first(SIGNAL_PREVIEW)
847
+ listed = shown.join(", ")
848
+ remaining = inert.size - shown.size
849
+ listed += ", and #{remaining} more" if remaining.positive?
850
+
851
+ [
852
+ " #{inert.size} #{inert.size == 1 ? "signal is" : "signals are"} newer than that build, so they do " \
853
+ "nothing on the app your users have, with no error anywhere: #{listed}.",
854
+ " Deploy on #{RubyNative::VERSION} to use them."
855
+ ]
856
+ end
857
+
858
+ def inert_signals(built)
859
+ return [] if built.to_s.empty?
860
+
861
+ deployed = Gem::Version.new(built.to_s.delete_prefix("v"))
862
+
863
+ Signals.names.select do |name|
864
+ since = Signals.since(name)
865
+ since && Gem::Version.new(since) > deployed
866
+ end
867
+ rescue ArgumentError
868
+ # gem_version reaches the API as unvalidated CLI input, so it may not
869
+ # be a version at all. No comparison beats a wrong one.
870
+ []
871
+ end
872
+
873
+ def build_status_report(build)
874
+ return "No build information came back." if build.empty?
875
+
876
+ label = build["platform"] == "ios" ? "iOS" : build["platform"].to_s.capitalize
877
+ lines = [ "#{label} build #{build["number"]} (#{build["version"]}) — #{build["status"]}." ]
878
+
879
+ if build["gem_version"].to_s != ""
880
+ lines << "Made with ruby_native #{build["gem_version"]} on Ruby Native #{build["native_version"]}."
881
+ end
882
+ lines << "Error: #{build["error_message"]}" unless build["error_message"].to_s.strip.empty?
883
+ lines << build["notice"] unless build["notice"].to_s.strip.empty?
884
+
885
+ lines.join("\n")
886
+ end
887
+
888
+
889
+ def signal_list
890
+ rows = Signals.names.map do |name|
891
+ { name: name, since: Signals.since(name), helper: Signals.helper(name), singleton: Signals.singleton?(name) }
892
+ end
893
+
894
+ width = Signals.names.map(&:length).max
895
+ lines = rows.map do |row|
896
+ "#{row[:name].ljust(width)} #{(row[:since] || "-").ljust(8)}#{row[:helper]}".rstrip
897
+ end
898
+
899
+ text = <<~TEXT.strip
900
+ #{Signals.names.size} signals, as of ruby_native #{RubyNative::VERSION}. Columns: signal, the gem version
901
+ that introduced it, the helper that emits it. A signal needing a version newer than the app's build is
902
+ ignored on device without an error. Signals with no version are written by the app, not by your views.
903
+
904
+ #{lines.join("\n")}
905
+
906
+ What each one does: https://rubynative.com/docs
907
+ TEXT
908
+
909
+ tool_result(text, { count: rows.size, gem_version: RubyNative::VERSION, signals: rows })
910
+ end
911
+
912
+ def signal_detail(name)
913
+ unless Signals.known?(name)
914
+ suggestion = Signals.nearest(name)
915
+ text = "`#{name}` is not a Ruby Native signal, so the apps ignore it."
916
+ text += " Did you mean `#{suggestion}`?" if suggestion
917
+
918
+ return tool_result(text, { name: name, known: false, suggestion: suggestion })
919
+ end
920
+
921
+ since = Signals.since(name)
922
+ supported = since.nil? || Gem::Version.new(RubyNative::VERSION) >= Gem::Version.new(since)
923
+
924
+ details = []
925
+ details << (since ? "Needs ruby_native #{since}." : "Written by the app, not by your views.")
926
+ details << "Emitted by `#{Signals.helper(name)}`." if Signals.helper(name)
927
+ details << "Read once per page: a second element carrying it is ignored." if Signals.singleton?(name)
928
+ details << "This app is on #{RubyNative::VERSION}, so it is inert here until the gem is updated." unless supported
929
+
930
+ tool_result("`#{name}` — #{details.join(" ")}", {
931
+ name: name,
932
+ known: true,
933
+ since: since,
934
+ helper: Signals.helper(name),
935
+ singleton: Signals.singleton?(name),
936
+ gem_version: RubyNative::VERSION,
937
+ supported: supported
938
+ })
939
+ end
940
+
941
+ # Reading order, so a reader works down the file. Offenses the validator
942
+ # could not pin to a line go last rather than to the top.
943
+ def in_line_order(offenses)
944
+ offenses.each_with_index.sort_by { |offense, index| [offense.line || Float::INFINITY, index] }.map(&:first)
945
+ end
946
+
947
+ def config_report(path, offenses)
948
+ return "Checked #{path}. No problems found." if offenses.empty?
949
+
950
+ errors, warnings = offenses.partition { |offense| offense.severity == :error }
951
+
952
+ lines = [path]
953
+ offenses.each do |offense|
954
+ location = "#{offense.line}:".ljust(5)
955
+ location = " " * 5 unless offense.line
956
+ lines << " #{location}#{offense.severity.to_s.ljust(8)}#{offense.message}"
957
+ end
958
+ lines << ""
959
+ lines << "#{errors.size} #{errors.size == 1 ? "error" : "errors"}, " \
960
+ "#{warnings.size} #{warnings.size == 1 ? "warning" : "warnings"}."
961
+
962
+ lines.join("\n")
963
+ end
964
+
965
+ # --- Protocol ---
966
+
967
+ def tool_result(text, structured = nil, is_error: false)
968
+ result = { content: [{ type: "text", text: text }], isError: is_error }
969
+ result[:structuredContent] = structured if structured
970
+ result
971
+ end
972
+
973
+ def failure(code, message)
974
+ { code: code, message: message }
975
+ end
976
+
977
+ def write(id, body)
978
+ @output.puts(JSON.generate({ jsonrpc: "2.0", id: id }.merge(body)))
979
+ end
980
+
981
+ def quietly
982
+ original = $stdout
983
+ $stdout = $stderr
984
+ yield
985
+ ensure
986
+ $stdout = original
987
+ end
988
+
989
+ # Every app-scoped route answers 404 the same way for an app the token
990
+ # cannot see, which is the only 404 these tools can provoke.
991
+ def failure_for(result, app_id)
992
+ return result.error unless result.not_found?
993
+
994
+ "No app #{app_id.inspect} on this account. Check `ruby_native.app_id` in #{Platform::CONFIG_PATH}, " \
995
+ "or pass `app_id`."
996
+ end
997
+
998
+ def resolve_app_id(arguments)
999
+ app_id = arguments["app_id"]
1000
+ raise ToolFailure, "`app_id` has to be a string." unless app_id.nil? || app_id.is_a?(String)
1001
+
1002
+ app_id = Platform.app_id if app_id.to_s.strip.empty?
1003
+ app_id.to_s.strip.empty? ? nil : app_id
1004
+ end
1005
+
1006
+ def truncate(text, limit)
1007
+ text.length > limit ? "#{text[0, limit].rstrip}…" : text
1008
+ end
1009
+
1010
+ def string_list(value, name)
1011
+ return nil if value.nil?
1012
+ raise ToolFailure, "`#{name}` has to be a list of strings." unless value.is_a?(Array) && value.all?(String)
1013
+
1014
+ entries = value.map(&:strip).reject(&:empty?)
1015
+ entries.empty? ? nil : entries
1016
+ end
1017
+
1018
+ # Agents reach for the bare name as often as the attribute, and both mean
1019
+ # the same thing here. A name that is already prefixed is left alone, so a
1020
+ # typo still gets its "did you mean".
1021
+ def qualify(name)
1022
+ name.start_with?("data-native-") ? name : "data-native-#{name.delete_prefix("data-")}"
1023
+ end
1024
+ end
1025
+ end
1026
+ end