rails-markup 1.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +304 -0
  4. data/app/assets/javascripts/rails_markup/toolbar.js +2225 -0
  5. data/app/controllers/rails_markup/annotations_controller.rb +244 -0
  6. data/app/controllers/rails_markup/application_controller.rb +7 -0
  7. data/app/controllers/rails_markup/dashboard_controller.rb +230 -0
  8. data/app/controllers/rails_markup/external/annotations_controller.rb +68 -0
  9. data/app/models/rails_markup/annotation.rb +176 -0
  10. data/app/views/layouts/rails_markup/application.html.erb +158 -0
  11. data/app/views/rails_markup/dashboard/_annotation_card.html.erb +25 -0
  12. data/app/views/rails_markup/dashboard/_annotation_list.html.erb +57 -0
  13. data/app/views/rails_markup/dashboard/_annotation_page.html.erb +13 -0
  14. data/app/views/rails_markup/dashboard/_filters.html.erb +75 -0
  15. data/app/views/rails_markup/dashboard/_stats.html.erb +22 -0
  16. data/app/views/rails_markup/dashboard/_styles.html.erb +115 -0
  17. data/app/views/rails_markup/dashboard/board.html.erb +135 -0
  18. data/app/views/rails_markup/dashboard/index.html.erb +38 -0
  19. data/app/views/rails_markup/dashboard/show.html.erb +129 -0
  20. data/app/views/rails_markup/shared/_toolbar.html.erb +28 -0
  21. data/bin/rails-markup +5 -0
  22. data/config/routes.rb +45 -0
  23. data/db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb +13 -0
  24. data/db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb +17 -0
  25. data/lib/generators/rails_markup/install/templates/auth_controller.rb.erb +17 -0
  26. data/lib/generators/rails_markup/install/templates/bin_markup.erb +5 -0
  27. data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +27 -0
  28. data/lib/generators/rails_markup/install/templates/initializer.rb.erb +54 -0
  29. data/lib/generators/rails_markup/install_generator.rb +167 -0
  30. data/lib/generators/rails_markup/uninstall_generator.rb +110 -0
  31. data/lib/rails_markup/cli/base.rb +8 -0
  32. data/lib/rails_markup/cli/initializer_writer.rb +54 -0
  33. data/lib/rails_markup/cli/setup_wizard.rb +229 -0
  34. data/lib/rails_markup/cli.rb +831 -0
  35. data/lib/rails_markup/client_uuid_maintenance.rb +174 -0
  36. data/lib/rails_markup/configuration.rb +160 -0
  37. data/lib/rails_markup/engine.rb +18 -0
  38. data/lib/rails_markup/http_server.rb +226 -0
  39. data/lib/rails_markup/http_store_proxy.rb +122 -0
  40. data/lib/rails_markup/mcp_config.rb +258 -0
  41. data/lib/rails_markup/mcp_server.rb +639 -0
  42. data/lib/rails_markup/server.rb +73 -0
  43. data/lib/rails_markup/store.rb +219 -0
  44. data/lib/rails_markup/version.rb +5 -0
  45. data/lib/rails_markup.rb +11 -0
  46. data/lib/tasks/rails_markup_client_uuids.rake +19 -0
  47. metadata +198 -0
@@ -0,0 +1,831 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lipgloss"
4
+ require "set"
5
+ require_relative "version"
6
+ require_relative "store"
7
+ require_relative "http_store_proxy"
8
+ require_relative "http_server"
9
+ require_relative "mcp_server"
10
+ require_relative "server"
11
+ require_relative "mcp_config"
12
+ require_relative "cli/base"
13
+
14
+ module RailsMarkup
15
+ class Cli
16
+ require_relative "cli/initializer_writer"
17
+ require_relative "cli/setup_wizard"
18
+ # ── Lipgloss styles ────────────────────────────────────────
19
+ HEADER_STYLE = Lipgloss::Style.new.bold(true).foreground("#FFFFFF").background("#5C4AE4").padding(0, 1)
20
+ ODD_STYLE = Lipgloss::Style.new.foreground("#E2E2E2").padding(0, 1)
21
+ EVEN_STYLE = Lipgloss::Style.new.foreground("#A0A0A0").padding(0, 1)
22
+ MASKED_STYLE = Lipgloss::Style.new.foreground("#6B7280").padding(0, 1)
23
+ LABEL_STYLE = Lipgloss::Style.new.bold(true).foreground("#FFFFFF")
24
+ HINT_STYLE = Lipgloss::Style.new.foreground("#6B7280")
25
+ SUCCESS_STYLE = Lipgloss::Style.new.bold(true).foreground("#22C55E")
26
+ ERROR_STYLE = Lipgloss::Style.new.bold(true).foreground("#EF4444")
27
+
28
+ desc "server", "Start HTTP + MCP server"
29
+ long_desc <<-DESC
30
+ Start the combined HTTP annotation server and MCP stdio bridge.
31
+
32
+ The HTTP server serves the annotation toolbar and API endpoints.
33
+ The MCP bridge lets AI editors communicate via JSON-RPC over stdio.
34
+
35
+ Examples:
36
+
37
+ bin/markup server # default port 4747
38
+
39
+ bin/markup server --port 5000 # custom port
40
+ DESC
41
+ method_option :port, type: :numeric, default: 4747, desc: "HTTP server port"
42
+ def server
43
+ srv = RailsMarkup::Server.new(port: options[:port])
44
+ srv.start
45
+ end
46
+
47
+ desc "init", "Interactive setup wizard"
48
+ long_desc <<-DESC
49
+ Launch the interactive TUI setup wizard.
50
+
51
+ Walks through toolbar accent, position, size, screenshots,
52
+ production URL, and MCP scope (local/global/codex).
53
+ Writes the Rails initializer and MCP config on confirm.
54
+
55
+ Requires a terminal with arrow key support.
56
+ DESC
57
+ def init
58
+ wizard = Cli::SetupWizard.new(dir: Dir.pwd)
59
+ Bubbletea.run(wizard)
60
+ if wizard.completed
61
+ say ""
62
+ say "Setup complete!", :green
63
+ else
64
+ say ""
65
+ say "Setup cancelled.", :yellow
66
+ end
67
+ end
68
+
69
+ desc "mcp", "Start MCP-only server (stdio)"
70
+ long_desc <<-DESC
71
+ Start the MCP server in stdio-only mode (no HTTP server).
72
+
73
+ This is what AI editors (Claude Code, Codex CLI, Cursor) invoke
74
+ via .mcp.json or global config. Communicates over stdin/stdout
75
+ using the JSON-RPC MCP protocol.
76
+
77
+ You rarely need to run this manually — it's called by the editor.
78
+ DESC
79
+ method_option :port, type: :numeric, default: 4747, desc: "HTTP server port to proxy to"
80
+ def mcp
81
+ srv = RailsMarkup::Server.new(port: options[:port], mcp_only: true)
82
+ srv.start
83
+ end
84
+
85
+ desc "configure", "Set MCP env vars (local, global, or codex)"
86
+ long_desc <<-DESC
87
+ Write environment variables to the MCP config file for this project
88
+ or globally for all projects.
89
+
90
+ Scope flags:
91
+
92
+ (default) Write to .mcp.json in the current directory
93
+
94
+ --global Write to ~/.claude/settings.json (Claude Code)
95
+
96
+ --codex Write to ~/.codex/config.toml (OpenAI Codex CLI)
97
+
98
+ Examples:
99
+
100
+ bin/markup configure --dev-url http://localhost:3000
101
+
102
+ bin/markup configure --prod-url URL --prod-token TOKEN
103
+
104
+ bin/markup configure --prod-url URL --global
105
+
106
+ bin/markup configure --prod-url URL --codex
107
+ DESC
108
+ method_option :prod_url, type: :string, desc: "Production URL (RAILS_MARKUP_PROD_URL)"
109
+ method_option :prod_token, type: :string, desc: "Production API token (RAILS_MARKUP_PROD_TOKEN)"
110
+ method_option :dev_url, type: :string, desc: "Dev URL (RAILS_MARKUP_DEV_URL)"
111
+ method_option :mount_path, type: :string, desc: "Engine mount path (RAILS_MARKUP_MOUNT_PATH)"
112
+ method_option :global, type: :boolean, default: false, desc: "Write to ~/.claude/settings.json"
113
+ method_option :codex, type: :boolean, default: false, desc: "Write to ~/.codex/config.toml"
114
+ def configure
115
+ env_updates = McpConfig::ENV_KEYS.each_with_object({}) do |(opt, env_key), hash|
116
+ hash[env_key] = options[opt] if options[opt]
117
+ end
118
+
119
+ if env_updates.empty?
120
+ say "No options provided. Usage:", :yellow
121
+ say ""
122
+ say " bin/markup configure --dev-url http://localhost:3000"
123
+ say " bin/markup configure --prod-url URL --prod-token TOKEN"
124
+ say " bin/markup configure --prod-url URL --global # Claude Code global"
125
+ say " bin/markup configure --prod-url URL --codex # Codex CLI global"
126
+ say ""
127
+ say " Dev needs only a URL (no token). Production requires both."
128
+ say ""
129
+ say " Or run: bin/markup setup-production --url=https://yourapp.com"
130
+ say ""
131
+ return
132
+ end
133
+
134
+ config = McpConfig.new(scope: resolve_scope)
135
+ config.update_env(env_updates)
136
+ $stdout.puts "#{LABEL_STYLE.render("Updated #{config.scope_label}")} #{HINT_STYLE.render("(#{config.path})")}"
137
+ $stdout.puts env_table(config.display_env, label: config.scope_label)
138
+ end
139
+
140
+ desc "status", "Show MCP config across all scopes"
141
+ long_desc <<-DESC
142
+ Display the current MCP configuration across all scopes.
143
+
144
+ Checks .mcp.json (local), ~/.claude/settings.json (Claude Code),
145
+ and ~/.codex/config.toml (Codex CLI). Tokens are masked.
146
+ DESC
147
+ def status
148
+ found = false
149
+
150
+ McpConfig::SCOPES.each do |scope|
151
+ config = McpConfig.new(scope: scope)
152
+ next unless config.exist?
153
+
154
+ env = config.display_env
155
+ next if env.empty?
156
+
157
+ found = true
158
+ say ""
159
+ $stdout.puts env_table(env, label: config.scope_label)
160
+ end
161
+
162
+ unless found
163
+ say ""
164
+ say "No MCP config found. Run:", :yellow
165
+ say " rails-markup configure --prod-url URL --prod-token TOKEN"
166
+ say " rails-markup configure --prod-url URL --global # Claude Code global"
167
+ say " rails-markup configure --prod-url URL --codex # Codex CLI global"
168
+ end
169
+ say ""
170
+ end
171
+
172
+ # ── Unified commands (match MCP tool verbs) ──────────────
173
+
174
+ desc "sessions", "List active annotation sessions (MCP only)"
175
+ def sessions
176
+ say "Sessions are only available via MCP tools (in-memory store).", :yellow
177
+ say ""
178
+ say " Use the rails_markup_sessions MCP tool in your AI editor."
179
+ say " The CLI talks to the Rails API which has database-backed annotations,"
180
+ say " not session-based ones."
181
+ say ""
182
+ say " To view pending annotations: bin/markup pending"
183
+ end
184
+
185
+ desc "session ID", "Get a session with annotations (MCP only)"
186
+ def session(id = nil)
187
+ say "Sessions are only available via MCP tools (in-memory store).", :yellow
188
+ say ""
189
+ say " Use the rails_markup_session MCP tool in your AI editor."
190
+ say ""
191
+ say " To view pending annotations: bin/markup pending"
192
+ end
193
+
194
+ desc "watch", "Watch for new annotations (polls pending)"
195
+ long_desc <<-DESC
196
+ Poll for new annotations and print them as they arrive.
197
+ Press Ctrl+C to stop.
198
+
199
+ Examples:
200
+
201
+ bin/markup watch # local dev, 5s interval
202
+
203
+ bin/markup watch --production # production
204
+
205
+ bin/markup watch --interval 2 # poll every 2s
206
+ DESC
207
+ method_option :production, type: :boolean, default: false, desc: "Watch production"
208
+ method_option :url, type: :string, desc: "Override base URL"
209
+ method_option :token, type: :string, desc: "Override API token"
210
+ method_option :mount_path, type: :string, desc: "Engine mount path"
211
+ method_option :interval, type: :numeric, default: 5, desc: "Poll interval in seconds"
212
+ def watch
213
+ env = resolve_env(options[:production])
214
+ return unless env
215
+
216
+ env_label = options[:production] ? "Production" : "Development"
217
+ interval = [options[:interval], 1].max
218
+
219
+ $stdout.puts ""
220
+ $stdout.puts "#{LABEL_STYLE.render(" Watching #{env_label} ")} #{HINT_STYLE.render(env[:base_url])}"
221
+ $stdout.puts "#{HINT_STYLE.render(" Polling every #{interval}s. Press Ctrl+C to stop.")}"
222
+ $stdout.puts ""
223
+
224
+ seen_ids = Set.new
225
+ loop do
226
+ annotations = fetch_pending_from(env)
227
+ break unless annotations
228
+
229
+ new_annotations = annotations.reject { |ann| seen_ids.include?(ann["id"]) }
230
+ if new_annotations.any?
231
+ new_annotations.each do |ann|
232
+ seen_ids.add(ann["id"])
233
+ render_annotation(ann)
234
+ end
235
+ $stdout.puts "#{HINT_STYLE.render(" #{seen_ids.size} total seen, #{annotations.size} pending")}"
236
+ $stdout.puts ""
237
+ end
238
+
239
+ sleep interval
240
+ rescue Interrupt
241
+ say ""
242
+ say "Stopped watching.", :yellow
243
+ break
244
+ end
245
+ end
246
+
247
+ desc "pending", "Fetch pending annotations"
248
+ long_desc <<-DESC
249
+ Fetch all pending (unresolved) annotations from the Rails API.
250
+
251
+ Examples:
252
+
253
+ bin/markup pending # local dev
254
+
255
+ bin/markup pending --production # production
256
+ DESC
257
+ method_option :production, type: :boolean, default: false, desc: "Fetch from production"
258
+ method_option :url, type: :string, desc: "Override base URL"
259
+ method_option :token, type: :string, desc: "Override API token"
260
+ method_option :mount_path, type: :string, desc: "Engine mount path"
261
+ def pending
262
+ env = resolve_env(options[:production])
263
+ return unless env
264
+
265
+ annotations = fetch_pending_from(env)
266
+ return unless annotations
267
+
268
+ env_label = options[:production] ? "Production" : "Development"
269
+ $stdout.puts ""
270
+ $stdout.puts "#{LABEL_STYLE.render(" #{env_label} ")} #{HINT_STYLE.render(env[:base_url])}"
271
+ $stdout.puts ""
272
+
273
+ if annotations.empty?
274
+ say " No pending annotations.", :yellow
275
+ say ""
276
+ return
277
+ end
278
+
279
+ $stdout.puts annotation_table(annotations)
280
+ say ""
281
+ annotations.each { |ann| render_annotation(ann) }
282
+ end
283
+
284
+ desc "resolve-all", "Resolve all pending annotations"
285
+ long_desc <<-DESC
286
+ Batch-resolve every pending annotation.
287
+
288
+ Examples:
289
+
290
+ bin/markup resolve-all --summary "Shipped in v2.1"
291
+
292
+ bin/markup resolve-all --production --summary "Done"
293
+ DESC
294
+ method_option :summary, type: :string, desc: "Summary applied to all"
295
+ method_option :production, type: :boolean, default: false, desc: "Target production"
296
+ def resolve_all
297
+ env = resolve_env(options[:production])
298
+ return unless env
299
+
300
+ annotations = fetch_pending_from(env)
301
+ return unless annotations
302
+
303
+ if annotations.empty?
304
+ say "No pending annotations.", :yellow
305
+ return
306
+ end
307
+
308
+ resolved = 0
309
+ annotations.each do |ann|
310
+ result = patch_annotation(env, ann["id"], "resolve", summary: options[:summary])
311
+ if result
312
+ $stdout.puts "#{SUCCESS_STYLE.render("Resolved")} ##{ann["id"]} #{HINT_STYLE.render(ann["content"].to_s[0, 50])}"
313
+ resolved += 1
314
+ else
315
+ $stdout.puts "#{ERROR_STYLE.render("Failed")} ##{ann["id"]}"
316
+ end
317
+ end
318
+
319
+ say ""
320
+ say "#{resolved}/#{annotations.size} annotations resolved.", :green
321
+ end
322
+
323
+ desc "resolve ID", "Resolve an annotation with a summary"
324
+ long_desc <<-DESC
325
+ Mark a single annotation as resolved.
326
+
327
+ Examples:
328
+
329
+ bin/markup resolve 42 --summary "Fixed padding"
330
+
331
+ bin/markup resolve 42 --production
332
+ DESC
333
+ method_option :summary, type: :string, desc: "Summary of how it was resolved"
334
+ method_option :production, type: :boolean, default: false, desc: "Target production"
335
+ def resolve(id = nil)
336
+ return say("#{ERROR_STYLE.render("Error:")} annotation ID is required") unless id
337
+
338
+ env = resolve_env(options[:production])
339
+ return unless env
340
+
341
+ result = patch_annotation(env, id, "resolve", summary: options[:summary])
342
+ return unless result
343
+
344
+ $stdout.puts "#{SUCCESS_STYLE.render("Resolved")} ##{id}"
345
+ end
346
+
347
+ desc "dismiss ID", "Dismiss an annotation with a reason"
348
+ long_desc <<-DESC
349
+ Dismiss an annotation (won't fix, not applicable, etc).
350
+
351
+ Examples:
352
+
353
+ bin/markup dismiss 42 --reason "By design"
354
+
355
+ bin/markup dismiss 42 --production --reason "Duplicate"
356
+ DESC
357
+ method_option :reason, type: :string, desc: "Reason for dismissing"
358
+ method_option :production, type: :boolean, default: false, desc: "Target production"
359
+ def dismiss(id = nil)
360
+ return say("#{ERROR_STYLE.render("Error:")} annotation ID is required") unless id
361
+
362
+ env = resolve_env(options[:production])
363
+ return unless env
364
+
365
+ result = patch_annotation(env, id, "dismiss", reason: options[:reason])
366
+ return unless result
367
+
368
+ $stdout.puts "#{SUCCESS_STYLE.render("Dismissed")} ##{id}"
369
+ end
370
+
371
+ desc "reply ID MESSAGE", "Reply to an annotation thread"
372
+ long_desc <<-DESC
373
+ Add a reply to an annotation's discussion thread.
374
+
375
+ Examples:
376
+
377
+ bin/markup reply 42 "Fixed in commit abc123"
378
+
379
+ bin/markup reply 42 "Deployed" --production
380
+ DESC
381
+ method_option :production, type: :boolean, default: false, desc: "Target production"
382
+ def reply(id = nil, message = nil)
383
+ return say("#{ERROR_STYLE.render("Error:")} annotation ID is required") unless id
384
+ return say("#{ERROR_STYLE.render("Error:")} message is required") unless message
385
+
386
+ env = resolve_env(options[:production])
387
+ return unless env
388
+
389
+ result = patch_annotation(env, id, "reply", message: message)
390
+ return unless result
391
+
392
+ $stdout.puts "#{SUCCESS_STYLE.render("Reply sent")} to ##{id}"
393
+ end
394
+
395
+ desc "acknowledge ID", "Mark an annotation as acknowledged"
396
+ long_desc <<-DESC
397
+ Mark an annotation as seen/acknowledged without resolving it.
398
+
399
+ Examples:
400
+
401
+ bin/markup acknowledge 42
402
+
403
+ bin/markup acknowledge 42 --production
404
+ DESC
405
+ method_option :production, type: :boolean, default: false, desc: "Target production"
406
+ def acknowledge(id = nil)
407
+ return say("#{ERROR_STYLE.render("Error:")} annotation ID is required") unless id
408
+
409
+ env = resolve_env(options[:production])
410
+ return unless env
411
+
412
+ result = patch_annotation(env, id, "acknowledge")
413
+ return unless result
414
+
415
+ $stdout.puts "#{SUCCESS_STYLE.render("Acknowledged")} ##{id}"
416
+ end
417
+
418
+ # ── Shorthand aliases ─────────────────────────────────────
419
+
420
+ desc "fetch [ENVIRONMENT]", "Fetch pending annotations (default: local dev)"
421
+ long_desc <<-DESC
422
+ Fetch pending annotations from local dev or production.
423
+
424
+ Examples:
425
+ bin/markup fetch # local dev
426
+ bin/markup fetch production # production
427
+ bin/markup fetch -e production # also production
428
+ DESC
429
+ method_option :production, type: :boolean, default: false, desc: "Fetch from production"
430
+ method_option :environment, type: :string, aliases: "-e", enum: %w[dev production],
431
+ desc: "Environment to fetch from"
432
+ method_option :url, type: :string, desc: "Override base URL"
433
+ method_option :token, type: :string, desc: "Override API token"
434
+ method_option :mount_path, type: :string, desc: "Engine mount path (default: /admin/annotations)"
435
+ def fetch(env_arg = nil)
436
+ production = env_arg == "production" || options[:production] || options[:environment] == "production"
437
+ env = resolve_env(production)
438
+ return unless env
439
+
440
+ annotations = fetch_pending_from(env)
441
+ return unless annotations
442
+
443
+ env_label = production ? "Production" : "Development"
444
+ $stdout.puts ""
445
+ $stdout.puts "#{LABEL_STYLE.render(" #{env_label} ")} #{HINT_STYLE.render(env[:base_url])}"
446
+ $stdout.puts ""
447
+
448
+ if annotations.empty?
449
+ say " No pending annotations.", :yellow
450
+ say ""
451
+ return
452
+ end
453
+
454
+ $stdout.puts annotation_table(annotations)
455
+ say ""
456
+ annotations.each { |ann| render_annotation(ann) }
457
+ end
458
+
459
+ desc "setup-production", "Generate a token and configure production access"
460
+ long_desc <<-DESC
461
+ Generate a secure API token, save it to MCP config, and print
462
+ instructions for adding it to your Rails credentials.
463
+
464
+ Examples:
465
+
466
+ bin/markup setup-production --url=https://yourapp.com
467
+
468
+ bin/markup setup-production --url=https://yourapp.com --global
469
+
470
+ bin/markup setup-production --url=https://yourapp.com --codex
471
+ DESC
472
+ method_option :url, type: :string, desc: "Production URL (e.g. https://yourapp.com)"
473
+ method_option :global, type: :boolean, default: false, desc: "Write to ~/.claude/settings.json"
474
+ method_option :codex, type: :boolean, default: false, desc: "Write to ~/.codex/config.toml"
475
+ def setup_production
476
+ require "securerandom"
477
+
478
+ prod_url = options[:url]
479
+ unless prod_url
480
+ say "Usage: bin/markup setup-production --url=https://yourapp.com", :red
481
+ return
482
+ end
483
+
484
+ token = SecureRandom.hex(24)
485
+
486
+ config = McpConfig.new(scope: resolve_scope)
487
+ config.update_env({
488
+ "RAILS_MARKUP_PROD_URL" => prod_url,
489
+ "RAILS_MARKUP_PROD_TOKEN" => token
490
+ })
491
+
492
+ say ""
493
+ say "Production token generated!", :green
494
+ say ""
495
+ say "Token: #{token}"
496
+ say ""
497
+ say "Saved to #{config.scope_label} — CLI and MCP tools are ready."
498
+ say ""
499
+ say "Now add the same token to your Rails app:"
500
+ say ""
501
+ say " Option A: Rails credentials (recommended)"
502
+ say " rails credentials:edit"
503
+ say " # Add:"
504
+ say " # rails_markup:"
505
+ say " # api_token: #{token}"
506
+ say ""
507
+ say " Option B: Environment variable"
508
+ say " RAILS_MARKUP_API_TOKEN=#{token}"
509
+ say ""
510
+ say "Then in config/initializers/rails_markup.rb:"
511
+ say ' config.api_token = Rails.application.credentials.dig(:rails_markup, :api_token)'
512
+ say " # or"
513
+ say ' config.api_token = ENV["RAILS_MARKUP_API_TOKEN"]'
514
+ say ""
515
+ say "Deploy, then verify:"
516
+ say " bin/markup pending --production"
517
+ say ""
518
+ end
519
+
520
+ desc "man", "Full command reference with examples"
521
+ def man
522
+ require_relative "version"
523
+ $stdout.puts render_man_page
524
+ end
525
+
526
+ desc "version", "Print version"
527
+ def version
528
+ require_relative "version"
529
+ say "rails-markup #{RailsMarkup::VERSION}"
530
+ end
531
+
532
+ def self.exit_on_failure?
533
+ true
534
+ end
535
+
536
+ private
537
+
538
+ def render_man_page
539
+ require_relative "version"
540
+ sections = []
541
+
542
+ sections << HEADER_STYLE.render(" RAILS-MARKUP(1) — v#{RailsMarkup::VERSION} ")
543
+ sections << ""
544
+ sections << " Point-and-click annotation tool for AI agents."
545
+ sections << " Annotate Rails views in the browser; AI editors read and act on your feedback."
546
+ sections << ""
547
+
548
+ sections << LABEL_STYLE.render(" GETTING STARTED ")
549
+ sections << ""
550
+ sections << " #{HINT_STYLE.render("# 1. Add to Gemfile")}"
551
+ sections << ' gem "rails-markup"'
552
+ sections << ""
553
+ sections << " #{HINT_STYLE.render("# 2. Run install generator")}"
554
+ sections << " rails generate rails_markup:install"
555
+ sections << " rails db:migrate"
556
+ sections << ""
557
+ sections << " #{HINT_STYLE.render("# 3. Interactive setup (toolbar + MCP config)")}"
558
+ sections << " bin/markup init"
559
+ sections << ""
560
+ sections << " #{HINT_STYLE.render("# 4. Or configure manually")}"
561
+ sections << " bin/markup configure --dev-url http://localhost:3000"
562
+ sections << " bin/markup setup-production --url https://yourapp.com"
563
+ sections << ""
564
+
565
+ sections << LABEL_STYLE.render(" SETUP ")
566
+ sections << ""
567
+ sections << " init Interactive TUI wizard"
568
+ sections << " configure [OPTIONS] Set MCP env vars"
569
+ sections << " --prod-url URL Production URL"
570
+ sections << " --prod-token TOKEN Production API token"
571
+ sections << " --dev-url URL Development URL"
572
+ sections << " --mount-path PATH Engine mount path"
573
+ sections << " --global Write to ~/.claude/settings.json"
574
+ sections << " --codex Write to ~/.codex/config.toml"
575
+ sections << " setup-production --url URL Generate token + configure production"
576
+ sections << " --global / --codex Same scope flags as configure"
577
+ sections << " status Show config across all scopes"
578
+ sections << ""
579
+
580
+ sections << LABEL_STYLE.render(" SERVERS ")
581
+ sections << ""
582
+ sections << " server [--port N] Start HTTP + MCP server (default: 4747)"
583
+ sections << " mcp [--port N] Start MCP-only server (stdio, for editors)"
584
+ sections << ""
585
+
586
+ sections << LABEL_STYLE.render(" ANNOTATIONS ")
587
+ sections << ""
588
+ sections << " pending [--production] Fetch pending annotations"
589
+ sections << " fetch [ENV] Alias for pending (accepts 'production' arg)"
590
+ sections << " watch [--production] Poll and print new annotations (Ctrl+C to stop)"
591
+ sections << " --interval N Seconds between polls (default: 5)"
592
+ sections << " resolve ID [--summary S] Resolve an annotation"
593
+ sections << " resolve-all [--summary S] Batch-resolve all pending"
594
+ sections << " dismiss ID [--reason R] Dismiss an annotation"
595
+ sections << " reply ID MESSAGE Reply to an annotation thread"
596
+ sections << " acknowledge ID Mark as seen"
597
+ sections << ""
598
+ sections << " #{HINT_STYLE.render("All annotation commands accept --production to target prod.")}"
599
+ sections << ""
600
+
601
+ sections << LABEL_STYLE.render(" MCP CONFIG SCOPES ")
602
+ sections << ""
603
+ sections << " local .mcp.json This project only"
604
+ sections << " global ~/.claude/settings.json Claude Code (all projects)"
605
+ sections << " codex ~/.codex/config.toml Codex CLI (all projects)"
606
+ sections << ""
607
+ sections << " #{HINT_STYLE.render("Commands fall back through local → global → codex when resolving env.")}"
608
+ sections << ""
609
+
610
+ sections << LABEL_STYLE.render(" QUICK RECIPES ")
611
+ sections << ""
612
+ sections << " #{HINT_STYLE.render("# Dev setup")}"
613
+ sections << " bin/markup configure --dev-url http://localhost:3000"
614
+ sections << ""
615
+ sections << " #{HINT_STYLE.render("# Production setup (writes to global Claude Code config)")}"
616
+ sections << " bin/markup setup-production --url https://myapp.com --global"
617
+ sections << ""
618
+ sections << " #{HINT_STYLE.render("# Watch for annotations in real-time")}"
619
+ sections << " bin/markup watch --production --interval 2"
620
+ sections << ""
621
+ sections << " #{HINT_STYLE.render("# Resolve everything after a deploy")}"
622
+ sections << " bin/markup resolve-all --production --summary \"Shipped in v2.1\""
623
+ sections << ""
624
+
625
+ sections << LABEL_STYLE.render(" INFO ")
626
+ sections << ""
627
+ sections << " man This reference page"
628
+ sections << " version Print version"
629
+ sections << " help [COMMAND] Thor help for a specific command"
630
+ sections << ""
631
+
632
+ sections.join("\n")
633
+ end
634
+
635
+ # ── Environment resolution ────────────────────────────────
636
+
637
+ def resolve_scope
638
+ return "global" if options[:global]
639
+ return "codex" if options[:codex]
640
+
641
+ "local"
642
+ end
643
+
644
+ # Check local first, then fall back to global/codex configs.
645
+ def resolve_mcp_env
646
+ McpConfig::SCOPES.each do |scope|
647
+ config = McpConfig.new(scope: scope)
648
+ next unless config.exist?
649
+
650
+ env = config.raw_env
651
+ return env unless env.empty?
652
+ end
653
+
654
+ {}
655
+ end
656
+
657
+ def resolve_env(production)
658
+ mcp_env = resolve_mcp_env
659
+
660
+ if production
661
+ base_url = options[:url] || mcp_env["RAILS_MARKUP_PROD_URL"]
662
+ token = options[:token] || mcp_env["RAILS_MARKUP_PROD_TOKEN"]
663
+ mount = options[:mount_path] || mcp_env["RAILS_MARKUP_MOUNT_PATH"] || "/admin/annotations"
664
+
665
+ unless base_url
666
+ say "No production URL. Set it via:", :red
667
+ say " bin/markup configure --prod-url URL"
668
+ return nil
669
+ end
670
+
671
+ unless token
672
+ say "No production token configured.", :red
673
+ say " bin/markup configure --prod-token TOKEN"
674
+ return nil
675
+ end
676
+
677
+ { base_url: base_url, token: token, mount_path: mount }
678
+ else
679
+ base_url = options[:url] || mcp_env["RAILS_MARKUP_DEV_URL"]
680
+ token = options[:token] || mcp_env["RAILS_MARKUP_DEV_TOKEN"]
681
+ mount = options[:mount_path] || mcp_env["RAILS_MARKUP_MOUNT_PATH"] || "/admin/annotations"
682
+
683
+ unless base_url
684
+ say "No dev URL. Set it via:", :red
685
+ say " bin/markup configure --dev-url URL"
686
+ return nil
687
+ end
688
+
689
+ { base_url: base_url, token: token, mount_path: mount }
690
+ end
691
+ end
692
+
693
+ def api_base(env)
694
+ "#{env[:base_url]}#{env[:mount_path]}/external"
695
+ end
696
+
697
+ # ── HTTP helpers ──────────────────────────────────────────
698
+
699
+ def fetch_pending_from(env)
700
+ require "net/http"
701
+ require "uri"
702
+ require "json"
703
+
704
+ url = "#{api_base(env)}/pending"
705
+ resp = http_get(url, token: env[:token])
706
+
707
+ unless resp.is_a?(Net::HTTPSuccess)
708
+ say "API error: #{resp.code} #{resp.body}", :red
709
+ return nil
710
+ end
711
+
712
+ data = JSON.parse(resp.body)
713
+ data["annotations"] || []
714
+ rescue => e
715
+ say "Connection error: #{e.message}", :red
716
+ nil
717
+ end
718
+
719
+ def patch_annotation(env, id, action, **params)
720
+ require "net/http"
721
+ require "uri"
722
+ require "json"
723
+
724
+ url = "#{api_base(env)}/#{id}/#{action}"
725
+ resp = http_patch(url, token: env[:token], params: params.compact)
726
+
727
+ unless resp.is_a?(Net::HTTPSuccess)
728
+ say "API error: #{resp.code} #{resp.body}", :red
729
+ return nil
730
+ end
731
+
732
+ JSON.parse(resp.body)
733
+ rescue => e
734
+ say "Connection error: #{e.message}", :red
735
+ nil
736
+ end
737
+
738
+ def http_get(url, token: nil)
739
+ uri = URI.parse(url)
740
+ req = Net::HTTP::Get.new(uri)
741
+ req["Authorization"] = "Bearer #{token}" if token
742
+ req["Accept"] = "application/json"
743
+ http = Net::HTTP.new(uri.host, uri.port)
744
+ http.use_ssl = uri.scheme == "https"
745
+ http.open_timeout = 10
746
+ http.read_timeout = 15
747
+ http.request(req)
748
+ end
749
+
750
+ def http_patch(url, token: nil, params: {})
751
+ uri = URI.parse(url)
752
+ req = Net::HTTP::Patch.new(uri)
753
+ req["Authorization"] = "Bearer #{token}" if token
754
+ req["Content-Type"] = "application/json"
755
+ req["Accept"] = "application/json"
756
+ req.body = params.to_json unless params.empty?
757
+ http = Net::HTTP.new(uri.host, uri.port)
758
+ http.use_ssl = uri.scheme == "https"
759
+ http.open_timeout = 10
760
+ http.read_timeout = 15
761
+ http.request(req)
762
+ end
763
+
764
+ # ── Rendering ─────────────────────────────────────────────
765
+
766
+ def annotation_table(annotations)
767
+ rows = annotations.map do |ann|
768
+ page = URI.parse(ann["pageUrl"]).path rescue ann["pageUrl"]
769
+ ["##{ann["id"]}", ann["intent"], ann["severity"], truncate(page, 30)]
770
+ end
771
+
772
+ Lipgloss::Table.new
773
+ .headers(["ID", "Intent", "Severity", "Page"])
774
+ .rows(rows)
775
+ .border(:rounded)
776
+ .style_func(rows: rows.size, columns: 4) do |row, _col|
777
+ if row == Lipgloss::Table::HEADER_ROW
778
+ HEADER_STYLE
779
+ else
780
+ row.odd? ? MASKED_STYLE : ODD_STYLE
781
+ end
782
+ end
783
+ .render
784
+ end
785
+
786
+ def render_annotation(ann)
787
+ target = ann["target"] || {}
788
+ page = URI.parse(ann["pageUrl"]).path rescue ann["pageUrl"]
789
+ nearby = target["nearbyText"]&.strip&.gsub(/\s+/, " ")&.slice(0, 80)
790
+
791
+ say "─" * 60
792
+ $stdout.puts "#{LABEL_STYLE.render(" ##{ann["id"]} ")} #{ann["intent"]} | #{ann["severity"]} #{HINT_STYLE.render(page)}"
793
+ say ""
794
+ say " #{ann["content"]}"
795
+ say ""
796
+ say " Author: #{ann["authorName"]}" if ann["authorName"]
797
+ say " CSS path: #{target["cssPath"]}" if target["cssPath"]
798
+ say " Selector: #{target["selector"]}" if target["selector"]
799
+ say " Text near: \"#{nearby}\"" if nearby
800
+ say " Selected: \"#{ann["selectedText"]}\"" if ann["selectedText"]
801
+ say " Created: #{ann["createdAt"]}"
802
+ say ""
803
+ end
804
+
805
+ def env_table(env_hash, label: ".mcp.json")
806
+ rows = env_hash.map { |k, v| [k, v] }
807
+ table = Lipgloss::Table.new
808
+ .headers(["Variable", "Value"])
809
+ .rows(rows)
810
+ .border(:rounded)
811
+ .style_func(rows: rows.size, columns: 2) do |row, _col|
812
+ if row == Lipgloss::Table::HEADER_ROW
813
+ HEADER_STYLE
814
+ else
815
+ row.odd? ? MASKED_STYLE : ODD_STYLE
816
+ end
817
+ end
818
+ .render
819
+
820
+ title = LABEL_STYLE.render(" Rails Markup ")
821
+ path = HINT_STYLE.render(label)
822
+ "#{title} #{path}\n\n#{table}"
823
+ end
824
+
825
+ def truncate(str, limit)
826
+ return str if str.length <= limit
827
+
828
+ "#{str[0, limit - 3]}..."
829
+ end
830
+ end
831
+ end