rails-mcp-server 1.2.3 → 1.4.1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/README.md +168 -166
  4. data/docs/AGENT.md +345 -0
  5. data/exe/rails-mcp-config +1407 -0
  6. data/exe/rails-mcp-server +23 -10
  7. data/exe/rails-mcp-setup-claude +1 -1
  8. data/lib/rails-mcp-server/analyzers/analyze_controller_views.rb +253 -0
  9. data/lib/rails-mcp-server/analyzers/analyze_environment_config.rb +79 -0
  10. data/lib/rails-mcp-server/analyzers/analyze_models.rb +251 -0
  11. data/lib/rails-mcp-server/analyzers/base_analyzer.rb +42 -0
  12. data/lib/rails-mcp-server/analyzers/get_file.rb +40 -0
  13. data/lib/rails-mcp-server/analyzers/get_routes.rb +212 -0
  14. data/lib/rails-mcp-server/analyzers/get_schema.rb +216 -0
  15. data/lib/rails-mcp-server/analyzers/list_files.rb +43 -0
  16. data/lib/rails-mcp-server/analyzers/load_guide.rb +84 -0
  17. data/lib/rails-mcp-server/analyzers/project_info.rb +136 -0
  18. data/lib/rails-mcp-server/resources/guide_content_formatter.rb +3 -3
  19. data/lib/rails-mcp-server/resources/kamal_guides_resources.rb +1 -1
  20. data/lib/rails-mcp-server/tools/base_tool.rb +2 -0
  21. data/lib/rails-mcp-server/tools/execute_ruby.rb +409 -0
  22. data/lib/rails-mcp-server/tools/execute_tool.rb +115 -0
  23. data/lib/rails-mcp-server/tools/search_tools.rb +186 -0
  24. data/lib/rails-mcp-server/tools/switch_project.rb +16 -1
  25. data/lib/rails-mcp-server/version.rb +1 -1
  26. data/lib/rails_mcp_server.rb +19 -53
  27. metadata +66 -19
  28. data/lib/rails-mcp-server/extensions/resource_templating.rb +0 -182
  29. data/lib/rails-mcp-server/extensions/server_templating.rb +0 -333
  30. data/lib/rails-mcp-server/tools/analyze_controller_views.rb +0 -239
  31. data/lib/rails-mcp-server/tools/analyze_environment_config.rb +0 -427
  32. data/lib/rails-mcp-server/tools/analyze_models.rb +0 -116
  33. data/lib/rails-mcp-server/tools/get_file.rb +0 -55
  34. data/lib/rails-mcp-server/tools/get_routes.rb +0 -24
  35. data/lib/rails-mcp-server/tools/get_schema.rb +0 -141
  36. data/lib/rails-mcp-server/tools/list_files.rb +0 -54
  37. data/lib/rails-mcp-server/tools/load_guide.rb +0 -370
  38. data/lib/rails-mcp-server/tools/project_info.rb +0 -86
@@ -0,0 +1,1407 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "json"
6
+ require "fileutils"
7
+
8
+ # Rails MCP Server - Project Configuration Tool
9
+ # Uses Gum (https://github.com/charmbracelet/gum) for nice TUI with fallback
10
+
11
+ module RailsMcpConfig
12
+ SEPARATOR = "─" * 20
13
+
14
+ # Catppuccin Mocha color palette
15
+ module Colors
16
+ # True color escape sequences (24-bit)
17
+ def self.rgb(r, g, b, text)
18
+ "\e[38;2;#{r};#{g};#{b}m#{text}\e[0m"
19
+ end
20
+
21
+ # Catppuccin Mocha palette
22
+ def self.green(text) = rgb(166, 227, 161, text) # #a6e3a1
23
+ def self.red(text) = rgb(243, 139, 168, text) # #f38ba8
24
+ def self.yellow(text) = rgb(249, 226, 175, text) # #f9e2af
25
+ def self.blue(text) = rgb(137, 180, 250, text) # #89b4fa
26
+ def self.mauve(text) = rgb(203, 166, 247, text) # #cba6f7
27
+ def self.teal(text) = rgb(148, 226, 213, text) # #94e2d5
28
+ def self.peach(text) = rgb(250, 179, 135, text) # #fab387
29
+ def self.pink(text) = rgb(245, 194, 231, text) # #f5c2e7
30
+ def self.sky(text) = rgb(137, 220, 235, text) # #89dceb
31
+ def self.lavender(text) = rgb(180, 190, 254, text) # #b4befe
32
+ def self.text(text) = rgb(205, 214, 244, text) # #cdd6f4
33
+ def self.subtext(text) = rgb(166, 173, 200, text) # #a6adc8
34
+ def self.overlay(text) = rgb(108, 112, 134, text) # #6c7086
35
+
36
+ # Semantic aliases
37
+ def self.success(text) = green(text)
38
+ def self.error(text) = red(text)
39
+ def self.warning(text) = yellow(text)
40
+ def self.info(text) = sky(text)
41
+ def self.accent(text) = mauve(text)
42
+ def self.bold(text) = "\e[1m#{text}\e[0m"
43
+ def self.dim(text) = overlay(text)
44
+
45
+ # Gum hex colors (without #)
46
+ GUM_GREEN = "a6e3a1"
47
+ GUM_RED = "f38ba8"
48
+ GUM_YELLOW = "f9e2af"
49
+ GUM_BLUE = "89b4fa"
50
+ GUM_MAUVE = "cba6f7"
51
+ GUM_TEAL = "94e2d5"
52
+ GUM_PEACH = "fab387"
53
+ GUM_PINK = "f5c2e7"
54
+ GUM_SKY = "89dceb"
55
+ GUM_LAVENDER = "b4befe"
56
+ GUM_TEXT = "cdd6f4"
57
+ GUM_SUBTEXT = "a6adc8"
58
+ GUM_OVERLAY = "6c7086"
59
+ GUM_BASE = "1e1e2e"
60
+ end
61
+
62
+ # Gum wrapper with fallback to basic terminal
63
+ class UI
64
+ def initialize
65
+ @gum_available = system("which gum > /dev/null 2>&1")
66
+ end
67
+
68
+ def gum_available?
69
+ @gum_available
70
+ end
71
+
72
+ def header(text)
73
+ if gum_available?
74
+ system("gum style --border rounded --padding '0 2' --border-foreground '##{Colors::GUM_PINK}' --foreground '##{Colors::GUM_TEXT}' '#{text}'")
75
+ else
76
+ puts
77
+ puts Colors.pink("╭" + "─" * (text.length + 4) + "╮")
78
+ puts Colors.pink("│ #{Colors.bold(text)} │")
79
+ puts Colors.pink("╰" + "─" * (text.length + 4) + "╯")
80
+ puts
81
+ end
82
+ end
83
+
84
+ def success(text)
85
+ if gum_available?
86
+ system("gum style --foreground '##{Colors::GUM_GREEN}' '✓ #{text}'")
87
+ else
88
+ puts Colors.green("✓ #{text}")
89
+ end
90
+ end
91
+
92
+ def error(text)
93
+ if gum_available?
94
+ system("gum style --foreground '##{Colors::GUM_RED}' '✗ #{text}'")
95
+ else
96
+ puts Colors.red("✗ #{text}")
97
+ end
98
+ end
99
+
100
+ def warning(text)
101
+ if gum_available?
102
+ system("gum style --foreground '##{Colors::GUM_YELLOW}' '! #{text}'")
103
+ else
104
+ puts Colors.yellow("! #{text}")
105
+ end
106
+ end
107
+
108
+ def info(text)
109
+ if gum_available?
110
+ system("gum style --foreground '##{Colors::GUM_SKY}' '#{text}'")
111
+ else
112
+ puts Colors.sky(text)
113
+ end
114
+ end
115
+
116
+ def separator?(option)
117
+ option.start_with?("─")
118
+ end
119
+
120
+ def choose(prompt, options, allow_cancel: true)
121
+ return nil if options.empty?
122
+
123
+ options += ["Cancel"] if allow_cancel
124
+
125
+ if gum_available?
126
+ result = %x(gum choose --header "#{prompt}" \
127
+ --header.foreground "##{Colors::GUM_MAUVE}" \
128
+ --cursor.foreground "##{Colors::GUM_PINK}" \
129
+ --item.foreground "##{Colors::GUM_TEXT}" \
130
+ #{options.map { |o| "'#{o}'" }.join(" ")}).strip
131
+ return nil if result.empty? || result == "Cancel" || separator?(result)
132
+ result
133
+ else
134
+ puts
135
+ puts Colors.mauve(Colors.bold(prompt))
136
+ selectable_index = 0
137
+ index_map = {}
138
+
139
+ options.each do |opt|
140
+ if separator?(opt)
141
+ puts Colors.dim(" #{opt}")
142
+ else
143
+ selectable_index += 1
144
+ index_map[selectable_index] = opt
145
+ puts " #{Colors.mauve(selectable_index.to_s)}. #{Colors.text(opt)}"
146
+ end
147
+ end
148
+
149
+ print Colors.dim("Enter choice (1-#{selectable_index}): ")
150
+
151
+ choice = gets&.strip&.to_i
152
+ return nil if choice.nil? || choice < 1 || choice > selectable_index
153
+
154
+ selected = index_map[choice]
155
+ return nil if selected == "Cancel"
156
+
157
+ selected
158
+ end
159
+ end
160
+
161
+ def multi_select(prompt, options)
162
+ return [] if options.empty?
163
+
164
+ if gum_available?
165
+ result = %x(gum choose --no-limit --header "#{prompt}" \
166
+ --header.foreground "##{Colors::GUM_MAUVE}" \
167
+ --cursor.foreground "##{Colors::GUM_PINK}" \
168
+ --item.foreground "##{Colors::GUM_TEXT}" \
169
+ --selected.foreground "##{Colors::GUM_GREEN}" \
170
+ #{options.map { |o| "'#{o}'" }.join(" ")}).strip
171
+ result.split("\n").map(&:strip).reject(&:empty?)
172
+ else
173
+ puts
174
+ puts Colors.mauve(Colors.bold(prompt))
175
+ puts Colors.dim("(Enter numbers separated by spaces, or 'all')")
176
+ options.each_with_index do |opt, i|
177
+ puts " #{Colors.mauve((i + 1).to_s)}. #{Colors.text(opt)}"
178
+ end
179
+ print Colors.dim("Selection: ")
180
+
181
+ input = gets&.strip&.downcase
182
+ return [] if input.nil? || input.empty?
183
+ return options.dup if input == "all"
184
+
185
+ indices = input.split(/[\s,]+/).map(&:to_i)
186
+ indices.select { |i| i.between?(1, options.length) }.map { |i| options[i - 1] }
187
+ end
188
+ end
189
+
190
+ def input(prompt, placeholder: "", default_value: "")
191
+ if gum_available?
192
+ cmd = ["gum", "input",
193
+ "--header", prompt,
194
+ "--header.foreground", "##{Colors::GUM_MAUVE}",
195
+ "--cursor.foreground", "##{Colors::GUM_PINK}",
196
+ "--placeholder", placeholder]
197
+ cmd += ["--value", default_value] unless default_value.empty?
198
+ result = `#{cmd.shelljoin}`.strip
199
+ result.empty? ? nil : result
200
+ else
201
+ print Colors.mauve(Colors.bold("#{prompt} "))
202
+ print Colors.dim("[#{default_value}] ") unless default_value.empty?
203
+ result = gets&.strip
204
+ return default_value if result&.empty? && !default_value.empty?
205
+ result&.empty? ? nil : result
206
+ end
207
+ end
208
+
209
+ def file_picker(prompt, directory: Dir.pwd)
210
+ if gum_available?
211
+ result = `gum file "#{directory}" --cursor.foreground "##{Colors::GUM_PINK}"`.strip
212
+ result.empty? ? nil : result
213
+ else
214
+ puts Colors.dim("(Enter path manually)")
215
+ input(prompt, placeholder: directory)
216
+ end
217
+ end
218
+
219
+ def confirm(prompt, default: false)
220
+ if gum_available?
221
+ default_flag = default ? "--default=true" : "--default=false"
222
+ system("gum confirm #{default_flag} \
223
+ --prompt.foreground '##{Colors::GUM_MAUVE}' \
224
+ --selected.background '##{Colors::GUM_PINK}' \
225
+ --selected.foreground '##{Colors::GUM_BASE}' \
226
+ '#{prompt}'")
227
+ else
228
+ default_hint = default ? "[Y/n]" : "[y/N]"
229
+ print "#{Colors.mauve(prompt)} #{Colors.dim(default_hint)} "
230
+ answer = gets&.strip&.downcase
231
+ return default if answer.nil? || answer.empty?
232
+ answer == "y" || answer == "yes"
233
+ end
234
+ end
235
+
236
+ def table(headers, rows, max_widths: nil)
237
+ return if rows.empty?
238
+
239
+ if gum_available?
240
+ # Build TSV for gum table
241
+ tsv = ([headers] + rows).map { |row| row.join("\t") }.join("\n")
242
+ system("echo '#{tsv}' | gum table --border rounded \
243
+ --border.foreground '##{Colors::GUM_OVERLAY}' \
244
+ --header.foreground '##{Colors::GUM_LAVENDER}'")
245
+ else
246
+ # Calculate column widths
247
+ all_rows = [headers] + rows
248
+ widths = headers.map.with_index do |_, i|
249
+ calculated = all_rows.map { |row| row[i].to_s.length }.max
250
+ (max_widths && max_widths[i]) ? [calculated, max_widths[i]].min : calculated
251
+ end
252
+
253
+ # Print header
254
+ puts
255
+ header_line = headers.map.with_index { |h, i| h.to_s.ljust(widths[i]) }.join(" │ ")
256
+ puts Colors.bold(Colors.lavender(header_line))
257
+ puts Colors.overlay("─" * header_line.length)
258
+
259
+ # Print rows (truncate if needed)
260
+ rows.each do |row|
261
+ cells = row.map.with_index do |cell, i|
262
+ cell_str = cell.to_s
263
+ if cell_str.length > widths[i]
264
+ cell_str[0, widths[i] - 1] + "…"
265
+ else
266
+ cell_str.ljust(widths[i])
267
+ end
268
+ end
269
+ puts Colors.text(cells.join(" │ "))
270
+ end
271
+ puts
272
+ end
273
+ end
274
+
275
+ def pager(content, title: nil)
276
+ if gum_available?
277
+ # Pipe content directly to gum pager (no border flags - not supported)
278
+ IO.popen(["gum", "pager", "--soft-wrap"], "w") do |io|
279
+ io.write(content)
280
+ end
281
+ else
282
+ puts Colors.mauve(Colors.bold(title)) if title
283
+ puts Colors.overlay("─" * 40)
284
+ puts content
285
+ puts Colors.overlay("─" * 40)
286
+ end
287
+ end
288
+
289
+ def spin(title)
290
+ frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
291
+ stop_spinner = false
292
+ result = nil
293
+
294
+ spinner_thread = Thread.new do
295
+ i = 0
296
+ until stop_spinner
297
+ print "\r#{Colors.pink(frames[i % frames.length])} #{Colors.text(title)}"
298
+ $stdout.flush
299
+ sleep 0.08
300
+ i += 1
301
+ end
302
+ end
303
+
304
+ begin
305
+ result = yield
306
+ ensure
307
+ stop_spinner = true
308
+ spinner_thread.join
309
+ print "\r\e[K" # Clear line
310
+ end
311
+
312
+ result
313
+ end
314
+
315
+ def clear
316
+ system("clear") || system("cls")
317
+ end
318
+ end
319
+
320
+ # Project configuration manager
321
+ class ProjectManager
322
+ attr_reader :ui, :config_path, :config_dir, :projects
323
+
324
+ def initialize
325
+ @ui = UI.new
326
+ @config_dir = determine_config_dir
327
+ @config_path = File.join(@config_dir, "projects.yml")
328
+ @projects = load_projects
329
+ end
330
+
331
+ def run
332
+ loop do
333
+ ui.clear
334
+ ui.header("Rails MCP Server - Configuration")
335
+
336
+ if ui.gum_available?
337
+ ui.info("Using Gum for enhanced UI")
338
+ else
339
+ ui.warning("Gum not found. Install from: https://github.com/charmbracelet/gum")
340
+ ui.info("Using basic terminal UI")
341
+ end
342
+
343
+ puts
344
+ ui.info("Config: #{config_path}")
345
+ puts
346
+
347
+ # Build menu options - Projects first (daily use focus)
348
+ menu_options = [
349
+ "List projects",
350
+ "Add project"
351
+ ]
352
+
353
+ # Add "Add current directory" option if not already configured
354
+ if can_add_current_directory?
355
+ menu_options << "Add current directory (#{current_directory_name})"
356
+ end
357
+
358
+ menu_options += [
359
+ "Edit project",
360
+ "Remove project",
361
+ "Validate all projects",
362
+ SEPARATOR,
363
+ "Download guides",
364
+ "Import custom guides",
365
+ "Manage custom guides",
366
+ SEPARATOR,
367
+ "Claude Desktop integration",
368
+ "Open config file",
369
+ SEPARATOR,
370
+ "Exit"
371
+ ]
372
+
373
+ action = ui.choose("Select an action:", menu_options, allow_cancel: false)
374
+
375
+ case action
376
+ when /List/
377
+ list_projects
378
+ when /Add current/
379
+ add_current_directory
380
+ when /Add/
381
+ add_project
382
+ when /Edit/
383
+ edit_project
384
+ when /Remove/
385
+ remove_project
386
+ when /Validate/
387
+ validate_projects
388
+ when /Download guides/
389
+ download_guides
390
+ when /Import custom/
391
+ import_custom_guides
392
+ when /Manage custom/
393
+ manage_custom_guides
394
+ when /Claude Desktop/
395
+ claude_desktop_integration
396
+ when /Open/
397
+ open_config
398
+ when /Exit/, nil
399
+ ui.success("Goodbye!")
400
+ break
401
+ end
402
+
403
+ unless action&.match?(/Exit/)
404
+ puts
405
+ print Colors.dim("Press Enter to continue...")
406
+ gets
407
+ end
408
+ end
409
+ end
410
+
411
+ private
412
+
413
+ def determine_config_dir
414
+ config_dir = if RUBY_PLATFORM.match?(/mswin|mingw|cygwin/)
415
+ File.join(ENV["APPDATA"], "rails-mcp")
416
+ else
417
+ xdg = ENV["XDG_CONFIG_HOME"]
418
+ if xdg && !xdg.empty?
419
+ File.join(xdg, "rails-mcp")
420
+ else
421
+ File.join(Dir.home, ".config", "rails-mcp")
422
+ end
423
+ end
424
+
425
+ FileUtils.mkdir_p(config_dir)
426
+ config_dir
427
+ end
428
+
429
+ def load_projects
430
+ return {} unless File.exist?(config_path)
431
+
432
+ content = File.read(config_path)
433
+ YAML.safe_load(content) || {}
434
+ rescue Psych::SyntaxError => e
435
+ ui.error("Invalid YAML in projects.yml: #{e.message}")
436
+ {}
437
+ end
438
+
439
+ def save_projects
440
+ # Preserve comments if possible by rebuilding
441
+ content = "# Rails MCP Projects Configuration\n"
442
+ content += "# Format: project_name: /path/to/rails/project\n"
443
+ content += "# Managed by rails-mcp-config\n"
444
+ content += "\n"
445
+
446
+ projects.each do |name, path|
447
+ content += "#{name}: \"#{path}\"\n"
448
+ end
449
+
450
+ File.write(config_path, content)
451
+ @projects = load_projects
452
+ true
453
+ rescue => e
454
+ ui.error("Failed to save: #{e.message}")
455
+ false
456
+ end
457
+
458
+ def path_to_home_relative(path)
459
+ expanded = File.expand_path(path)
460
+ home = Dir.home
461
+
462
+ if expanded.start_with?(home)
463
+ expanded.sub(home, "~")
464
+ else
465
+ expanded
466
+ end
467
+ end
468
+
469
+ def current_directory_path
470
+ path_to_home_relative(Dir.pwd)
471
+ end
472
+
473
+ def current_directory_name
474
+ File.basename(Dir.pwd)
475
+ end
476
+
477
+ def current_directory_configured?
478
+ current_expanded = File.expand_path(Dir.pwd)
479
+ projects.values.any? { |path| File.expand_path(path) == current_expanded }
480
+ end
481
+
482
+ def can_add_current_directory?
483
+ return false if current_directory_configured?
484
+
485
+ # Check if it looks like a Rails project
486
+ gemfile = File.join(Dir.pwd, "Gemfile")
487
+ File.exist?(gemfile)
488
+ end
489
+
490
+ def list_projects
491
+ ui.clear
492
+ ui.header("Configured Projects")
493
+
494
+ if projects.empty?
495
+ ui.warning("No projects configured yet.")
496
+ ui.info("Use 'Add project' to configure your first Rails project.")
497
+ return
498
+ end
499
+
500
+ headers = ["Name", "Path", "Status"]
501
+ rows = projects.map do |name, path|
502
+ expanded = File.expand_path(path)
503
+ status = if File.directory?(expanded)
504
+ gemfile = File.join(expanded, "Gemfile")
505
+ File.exist?(gemfile) ? "✓ Valid" : "⚠ No Gemfile"
506
+ else
507
+ "✗ Not found"
508
+ end
509
+ [name, path, status]
510
+ end
511
+
512
+ ui.table(headers, rows)
513
+ end
514
+
515
+ def add_current_directory
516
+ ui.clear
517
+ ui.header("Add Current Directory")
518
+
519
+ path = current_directory_path
520
+ suggested_name = current_directory_name
521
+
522
+ ui.info("Directory: #{path}")
523
+ puts
524
+
525
+ # Get project name
526
+ name = ui.input("Project name:", placeholder: suggested_name, default_value: suggested_name)
527
+ return ui.warning("Cancelled.") if name.nil?
528
+
529
+ # Check if name exists
530
+ if projects.key?(name)
531
+ ui.error("Project '#{name}' already exists.")
532
+ return
533
+ end
534
+
535
+ # Validate name
536
+ unless name.match?(/\A[a-zA-Z0-9_-]+\z/)
537
+ ui.error("Invalid name. Use only letters, numbers, hyphens, and underscores.")
538
+ return
539
+ end
540
+
541
+ # Save
542
+ @projects[name] = path
543
+ if save_projects
544
+ ui.success("Added project '#{name}' -> #{path}")
545
+ end
546
+ end
547
+
548
+ def add_project
549
+ ui.clear
550
+ ui.header("Add New Project")
551
+
552
+ # Get project name
553
+ name = ui.input("Project name:", placeholder: "my-rails-app")
554
+ return ui.warning("Cancelled.") if name.nil?
555
+
556
+ # Check if exists
557
+ if projects.key?(name)
558
+ ui.error("Project '#{name}' already exists.")
559
+ return
560
+ end
561
+
562
+ # Validate name
563
+ unless name.match?(/\A[a-zA-Z0-9_-]+\z/)
564
+ ui.error("Invalid name. Use only letters, numbers, hyphens, and underscores.")
565
+ return
566
+ end
567
+
568
+ # Get path
569
+ puts
570
+ ui.info("Select the Rails project directory:")
571
+ path = if ui.gum_available? && ui.confirm("Use file picker?", default: true)
572
+ ui.file_picker("Select directory:")
573
+ else
574
+ ui.input("Project path:", placeholder: "/path/to/rails/project")
575
+ end
576
+
577
+ return ui.warning("Cancelled.") if path.nil?
578
+
579
+ # Expand and validate path
580
+ expanded = File.expand_path(path)
581
+
582
+ unless File.directory?(expanded)
583
+ ui.error("Directory not found: #{expanded}")
584
+ return
585
+ end
586
+
587
+ gemfile = File.join(expanded, "Gemfile")
588
+ unless File.exist?(gemfile)
589
+ unless ui.confirm("No Gemfile found. Add anyway?", default: false)
590
+ return ui.warning("Cancelled.")
591
+ end
592
+ end
593
+
594
+ # Convert to home-relative path
595
+ path = path_to_home_relative(path)
596
+
597
+ # Save
598
+ @projects[name] = path
599
+ if save_projects
600
+ ui.success("Added project '#{name}' -> #{path}")
601
+ end
602
+ end
603
+
604
+ def edit_project
605
+ ui.clear
606
+ ui.header("Edit Project")
607
+
608
+ if projects.empty?
609
+ ui.warning("No projects to edit.")
610
+ return
611
+ end
612
+
613
+ name = ui.choose("Select project to edit:", projects.keys)
614
+ return ui.warning("Cancelled.") if name.nil?
615
+
616
+ current_path = projects[name]
617
+ ui.info("Current path: #{current_path}")
618
+ puts
619
+
620
+ # Edit name or path?
621
+ what = ui.choose("What to edit?", ["Name", "Path", "Both"])
622
+ return ui.warning("Cancelled.") if what.nil?
623
+
624
+ new_name = name
625
+ new_path = current_path
626
+
627
+ if what.match?(/Name|Both/)
628
+ new_name = ui.input("New name:", default_value: name)
629
+ return ui.warning("Cancelled.") if new_name.nil?
630
+
631
+ if new_name != name && projects.key?(new_name)
632
+ ui.error("Project '#{new_name}' already exists.")
633
+ return
634
+ end
635
+ end
636
+
637
+ if what.match?(/Path|Both/)
638
+ new_path = ui.input("New path:", default_value: current_path)
639
+ return ui.warning("Cancelled.") if new_path.nil?
640
+
641
+ expanded = File.expand_path(new_path)
642
+ unless File.directory?(expanded)
643
+ unless ui.confirm("Directory not found. Save anyway?", default: false)
644
+ return ui.warning("Cancelled.")
645
+ end
646
+ end
647
+
648
+ # Convert to home-relative path
649
+ new_path = path_to_home_relative(new_path)
650
+ end
651
+
652
+ # Apply changes
653
+ @projects.delete(name) if new_name != name
654
+ @projects[new_name] = new_path
655
+
656
+ if save_projects
657
+ ui.success("Updated project '#{new_name}'")
658
+ end
659
+ end
660
+
661
+ def remove_project
662
+ ui.clear
663
+ ui.header("Remove Project")
664
+
665
+ if projects.empty?
666
+ ui.warning("No projects to remove.")
667
+ return
668
+ end
669
+
670
+ name = ui.choose("Select project to remove:", projects.keys)
671
+ return ui.warning("Cancelled.") if name.nil?
672
+
673
+ ui.warning("This will remove '#{name}' from configuration.")
674
+ ui.info("Path: #{projects[name]}")
675
+ puts
676
+
677
+ return unless ui.confirm("Are you sure?", default: false)
678
+
679
+ @projects.delete(name)
680
+ if save_projects
681
+ ui.success("Removed project '#{name}'")
682
+ end
683
+ end
684
+
685
+ def guide_downloaded?(guide_name)
686
+ # Resources are stored in config_dir/resources/guide_name/
687
+ resource_dir = File.join(config_dir, "resources", guide_name)
688
+ return false unless Dir.exist?(resource_dir)
689
+
690
+ # Check if directory has any files
691
+ files = Dir.glob(File.join(resource_dir, "**", "*")).select { |f| File.file?(f) }
692
+ !files.empty?
693
+ end
694
+
695
+ def download_guides
696
+ ui.clear
697
+ ui.header("Download Guides")
698
+
699
+ # Try to load the resource downloader
700
+ begin
701
+ require_relative "../lib/rails-mcp-server/helpers/resource_downloader"
702
+ rescue LoadError
703
+ ui.error("Resource downloader not available.")
704
+ ui.info("Run from the gem installation directory or check your installation.")
705
+ return
706
+ end
707
+
708
+ available = RailsMcpServer::ResourceDownloader.available_resources(config_dir)
709
+
710
+ if available.empty?
711
+ ui.warning("No guide resources configured.")
712
+ ui.info("Check config/resources.yml in the gem directory.")
713
+ return
714
+ end
715
+
716
+ # Check download status for each guide
717
+ guide_status = available.map do |guide|
718
+ downloaded = guide_downloaded?(guide)
719
+ {name: guide, downloaded: downloaded}
720
+ end
721
+
722
+ # Build options with clear status
723
+ options = guide_status.map do |g|
724
+ status = g[:downloaded] ? "✓ downloaded" : "not downloaded"
725
+ "#{g[:name]} (#{status})"
726
+ end
727
+
728
+ ui.info("Select guides to download:")
729
+ puts
730
+
731
+ selected = ui.multi_select("Available guides:", options)
732
+
733
+ if selected.empty?
734
+ ui.warning("No guides selected.")
735
+ return
736
+ end
737
+
738
+ # Extract guide names (first word before space)
739
+ guide_names = selected.map { |opt| opt.split(" ").first }
740
+
741
+ # Always ask about force download
742
+ puts
743
+ force = ui.confirm("Force re-download (overwrite existing)?", default: false)
744
+
745
+ puts
746
+ total_results = {downloaded: 0, skipped: 0, failed: 0}
747
+
748
+ guide_names.each do |guide|
749
+ puts Colors.sky("Downloading #{guide}...")
750
+
751
+ results = ui.spin(" Fetching files...") do
752
+ downloader = RailsMcpServer::ResourceDownloader.new(
753
+ guide,
754
+ config_dir: config_dir,
755
+ force: force,
756
+ verbose: false
757
+ )
758
+ downloader.download
759
+ rescue => e
760
+ {downloaded: 0, skipped: 0, failed: 1, error: e.message}
761
+ end
762
+
763
+ if results[:error]
764
+ ui.error(" #{results[:error]}")
765
+ elsif results[:failed] > 0
766
+ ui.error(" Failed: #{results[:failed]} files")
767
+ else
768
+ ui.success(" #{results[:downloaded]} downloaded, #{results[:skipped]} skipped")
769
+ end
770
+
771
+ total_results[:downloaded] += results[:downloaded]
772
+ total_results[:skipped] += results[:skipped]
773
+ total_results[:failed] += results[:failed]
774
+ end
775
+
776
+ puts
777
+ ui.info("Total: #{total_results[:downloaded]} downloaded, #{total_results[:skipped]} skipped, #{total_results[:failed]} failed")
778
+ end
779
+
780
+ def import_custom_guides
781
+ ui.clear
782
+ ui.header("Import Custom Guides")
783
+
784
+ # Try to load the resource importer
785
+ begin
786
+ require_relative "../lib/rails-mcp-server/helpers/resource_importer"
787
+ rescue LoadError
788
+ ui.error("Resource importer not available.")
789
+ ui.info("Run from the gem installation directory or check your installation.")
790
+ return
791
+ end
792
+
793
+ ui.info("Import markdown files as custom guides.")
794
+ ui.info("Files will be available via the load_guide tool.")
795
+ ui.info("Starting from: #{Dir.pwd}")
796
+ puts
797
+
798
+ # Get path - start from current directory
799
+ path = if ui.gum_available? && ui.confirm("Use file picker?", default: true)
800
+ ui.file_picker("Select file or directory:", directory: Dir.pwd)
801
+ else
802
+ ui.input("Path to file or directory:", placeholder: "./")
803
+ end
804
+
805
+ return ui.warning("Cancelled.") if path.nil?
806
+
807
+ expanded = File.expand_path(path)
808
+ unless File.exist?(expanded)
809
+ ui.error("Path not found: #{expanded}")
810
+ return
811
+ end
812
+
813
+ force = ui.confirm("Overwrite existing files?", default: false)
814
+
815
+ puts
816
+ puts Colors.sky("Importing from #{File.basename(path)}...")
817
+
818
+ results = ui.spin(" Processing files...") do
819
+ importer = RailsMcpServer::ResourceImporter.new(
820
+ "custom",
821
+ config_dir: config_dir,
822
+ source_path: expanded,
823
+ force: force,
824
+ verbose: false
825
+ )
826
+ importer.import
827
+ rescue => e
828
+ {imported: 0, skipped: 0, failed: 1, error: e.message}
829
+ end
830
+
831
+ if results[:error]
832
+ ui.error(" #{results[:error]}")
833
+ elsif results[:failed] > 0
834
+ ui.error(" Import completed with errors")
835
+ ui.info(" Imported: #{results[:imported]}, Skipped: #{results[:skipped]}, Failed: #{results[:failed]}")
836
+ else
837
+ ui.success(" #{results[:imported]} imported, #{results[:skipped]} skipped")
838
+ end
839
+ end
840
+
841
+ def get_custom_guides
842
+ custom_dir = File.join(config_dir, "resources", "custom")
843
+ return [] unless Dir.exist?(custom_dir)
844
+
845
+ # Get all markdown files in custom directory
846
+ files = Dir.glob(File.join(custom_dir, "*.md"))
847
+ files.map do |file|
848
+ name = File.basename(file, ".md")
849
+ size = File.size(file)
850
+ mtime = File.mtime(file)
851
+ {name: name, path: file, size: size, mtime: mtime}
852
+ end.sort_by { |f| f[:name] }
853
+ end
854
+
855
+ def format_size(bytes)
856
+ if bytes < 1024
857
+ "#{bytes} B"
858
+ elsif bytes < 1024 * 1024
859
+ "#{(bytes / 1024.0).round(1)} KB"
860
+ else
861
+ "#{(bytes / 1024.0 / 1024.0).round(1)} MB"
862
+ end
863
+ end
864
+
865
+ def manage_custom_guides
866
+ ui.clear
867
+ ui.header("Manage Custom Guides")
868
+
869
+ custom_guides = get_custom_guides
870
+
871
+ if custom_guides.empty?
872
+ ui.warning("No custom guides found.")
873
+ ui.info("Use 'Import custom guides' to add your own markdown files.")
874
+ return
875
+ end
876
+
877
+ # Display location
878
+ custom_dir = File.join(config_dir, "resources", "custom")
879
+ ui.info("Location: #{custom_dir}")
880
+ puts
881
+
882
+ # Display table with max widths to prevent overflow
883
+ headers = ["Name", "Size", "Modified"]
884
+ rows = custom_guides.map do |g|
885
+ [g[:name], format_size(g[:size]), g[:mtime].strftime("%Y-%m-%d %H:%M")]
886
+ end
887
+
888
+ # Use max widths for fallback mode
889
+ ui.table(headers, rows, max_widths: [30, 10, 16])
890
+
891
+ ui.info("Total: #{custom_guides.length} guide(s)")
892
+ puts
893
+
894
+ # Ask what to do
895
+ action = ui.choose("What would you like to do?", ["Delete guides", "Back to menu"])
896
+ return if action.nil? || action.match?(/Back/)
897
+
898
+ # Multi-select guides to delete
899
+ options = custom_guides.map { |g| g[:name] }
900
+ selected = ui.multi_select("Select guides to delete:", options)
901
+
902
+ if selected.empty?
903
+ ui.warning("No guides selected.")
904
+ return
905
+ end
906
+
907
+ # Confirm deletion
908
+ puts
909
+ ui.warning("This will permanently delete #{selected.length} guide(s):")
910
+ selected.each { |name| puts Colors.text(" - #{name}") }
911
+ puts
912
+
913
+ return unless ui.confirm("Are you sure?", default: false)
914
+
915
+ # Delete selected guides
916
+ deleted = 0
917
+ failed = 0
918
+
919
+ selected.each do |name|
920
+ guide = custom_guides.find { |g| g[:name] == name }
921
+ next unless guide
922
+
923
+ begin
924
+ File.delete(guide[:path])
925
+ deleted += 1
926
+ rescue => e
927
+ ui.error("Failed to delete #{name}: #{e.message}")
928
+ failed += 1
929
+ end
930
+ end
931
+
932
+ # Update manifest if it exists
933
+ manifest_path = File.join(config_dir, "resources", "custom", "manifest.yaml")
934
+ if File.exist?(manifest_path)
935
+ begin
936
+ manifest = YAML.load_file(manifest_path)
937
+ if manifest && manifest["files"]
938
+ selected.each do |name|
939
+ manifest["files"].delete("#{name}.md")
940
+ end
941
+ manifest["updated_at"] = Time.now.to_s
942
+ File.write(manifest_path, manifest.to_yaml)
943
+ end
944
+ rescue => e
945
+ ui.warning("Could not update manifest: #{e.message}")
946
+ end
947
+ end
948
+
949
+ puts
950
+ if failed > 0
951
+ ui.warning("Deleted #{deleted} guide(s), #{failed} failed")
952
+ else
953
+ ui.success("Deleted #{deleted} guide(s)")
954
+ end
955
+ end
956
+
957
+ # Claude Desktop Integration
958
+ def claude_config_path
959
+ if RUBY_PLATFORM.match?(/mswin|mingw|cygwin/)
960
+ File.join(ENV["APPDATA"], "Claude", "claude_desktop_config.json")
961
+ elsif RUBY_PLATFORM.match?(/darwin/)
962
+ File.expand_path("~/Library/Application Support/Claude/claude_desktop_config.json")
963
+ else
964
+ # Linux - check common locations
965
+ xdg = ENV["XDG_CONFIG_HOME"] || File.expand_path("~/.config")
966
+ File.join(xdg, "Claude", "claude_desktop_config.json")
967
+ end
968
+ end
969
+
970
+ def load_claude_config
971
+ return nil unless File.exist?(claude_config_path)
972
+ JSON.parse(File.read(claude_config_path))
973
+ rescue JSON::ParserError => e
974
+ ui.error("Invalid JSON in Claude config: #{e.message}")
975
+ nil
976
+ end
977
+
978
+ def rails_mcp_server_path
979
+ # Find the rails-mcp-server executable
980
+ gem_exe = File.expand_path("../rails-mcp-server", __FILE__)
981
+ return gem_exe if File.exist?(gem_exe)
982
+
983
+ # Try to find via which
984
+ path = `which rails-mcp-server 2>/dev/null`.strip
985
+ return path unless path.empty?
986
+
987
+ nil
988
+ end
989
+
990
+ def ruby_executable_path
991
+ # Get full path to ruby
992
+ RbConfig.ruby
993
+ end
994
+
995
+ def npx_executable_path
996
+ `which npx 2>/dev/null`.strip.then { |p| p.empty? ? nil : p }
997
+ end
998
+
999
+ def generate_stdio_config
1000
+ server_path = rails_mcp_server_path
1001
+ ruby_path = ruby_executable_path
1002
+
1003
+ {
1004
+ "command" => ruby_path,
1005
+ "args" => [server_path]
1006
+ }
1007
+ end
1008
+
1009
+ def generate_http_config(url)
1010
+ npx_path = npx_executable_path
1011
+
1012
+ {
1013
+ "command" => npx_path,
1014
+ "args" => ["mcp-remote", url]
1015
+ }
1016
+ end
1017
+
1018
+ def generate_example_config(mode, url = nil)
1019
+ config = {"mcpServers" => {}}
1020
+
1021
+ config["mcpServers"]["railsMcpServer"] = if mode == :stdio
1022
+ generate_stdio_config
1023
+ else
1024
+ generate_http_config(url || "http://localhost:3001/mcp")
1025
+ end
1026
+
1027
+ config
1028
+ end
1029
+
1030
+ def claude_desktop_integration
1031
+ ui.clear
1032
+ ui.header("Claude Desktop Integration")
1033
+
1034
+ config_file = claude_config_path
1035
+ ui.info("Config file: #{config_file}")
1036
+ puts
1037
+
1038
+ # Check if file exists
1039
+ if File.exist?(config_file)
1040
+ ui.success("Claude Desktop config file found")
1041
+
1042
+ config = load_claude_config
1043
+ if config.nil?
1044
+ ui.error("Could not parse config file")
1045
+ return
1046
+ end
1047
+
1048
+ # Check if railsMcpServer is configured
1049
+ mcp_servers = config["mcpServers"] || {}
1050
+ rails_config = mcp_servers["railsMcpServer"]
1051
+
1052
+ if rails_config
1053
+ ui.success("Rails MCP Server is configured")
1054
+ puts
1055
+ puts Colors.lavender("Current configuration:")
1056
+ puts Colors.text(" command: #{rails_config["command"]}")
1057
+ puts Colors.text(" args: #{rails_config["args"]&.join(" ") || "(none)"}")
1058
+ else
1059
+ ui.warning("Rails MCP Server is NOT configured")
1060
+ end
1061
+
1062
+ puts
1063
+
1064
+ # Menu options
1065
+ options = ["View full config"]
1066
+ options << (rails_config ? "Update Rails MCP Server config" : "Add Rails MCP Server config")
1067
+ options << "Back to menu"
1068
+
1069
+ action = ui.choose("What would you like to do?", options, allow_cancel: false)
1070
+
1071
+ case action
1072
+ when /View/
1073
+ puts
1074
+ ui.pager(JSON.pretty_generate(config))
1075
+ when /Update|Add/
1076
+ configure_rails_mcp_server(config)
1077
+ end
1078
+
1079
+ else
1080
+ ui.warning("Claude Desktop config file not found")
1081
+ puts
1082
+ ui.info("Claude Desktop stores its configuration at:")
1083
+ puts Colors.text(" #{config_file}")
1084
+ puts
1085
+
1086
+ action = ui.choose("What would you like to do?", [
1087
+ "Create config file",
1088
+ "Show example configuration",
1089
+ "Back to menu"
1090
+ ], allow_cancel: false)
1091
+
1092
+ case action
1093
+ when /Create/
1094
+ create_claude_config
1095
+ when /Show example/
1096
+ show_example_config
1097
+ end
1098
+ end
1099
+ end
1100
+
1101
+ def configure_rails_mcp_server(config)
1102
+ ui.clear
1103
+ ui.header("Configure Rails MCP Server")
1104
+
1105
+ ui.info("Choose connection mode:")
1106
+ puts
1107
+ puts Colors.text(" STDIO - Direct communication (recommended)")
1108
+ puts Colors.text(" Requires: Ruby")
1109
+ puts
1110
+ puts Colors.text(" HTTP - Network-based communication")
1111
+ puts Colors.text(" Requires: Node.js and mcp-remote package")
1112
+ puts
1113
+
1114
+ mode = ui.choose("Select mode:", ["STDIO (recommended)", "HTTP"])
1115
+ return ui.warning("Cancelled.") if mode.nil?
1116
+
1117
+ if mode.match?(/STDIO/)
1118
+ configure_stdio_mode(config)
1119
+ else
1120
+ configure_http_mode(config)
1121
+ end
1122
+ end
1123
+
1124
+ def configure_stdio_mode(config)
1125
+ ruby_path = ruby_executable_path
1126
+ server_path = rails_mcp_server_path
1127
+
1128
+ unless server_path
1129
+ ui.error("Could not find rails-mcp-server executable")
1130
+ ui.info("Make sure the gem is properly installed")
1131
+ return
1132
+ end
1133
+
1134
+ ui.info("Ruby executable: #{ruby_path}")
1135
+ ui.info("Server executable: #{server_path}")
1136
+ puts
1137
+
1138
+ return unless ui.confirm("Apply this configuration?", default: true)
1139
+
1140
+ # Backup existing config
1141
+ backup_claude_config
1142
+
1143
+ # Update config
1144
+ config["mcpServers"] ||= {}
1145
+ config["mcpServers"]["railsMcpServer"] = generate_stdio_config
1146
+
1147
+ save_claude_config(config)
1148
+ end
1149
+
1150
+ def configure_http_mode(config)
1151
+ npx_path = npx_executable_path
1152
+
1153
+ unless npx_path
1154
+ ui.error("npx not found. Please install Node.js first.")
1155
+ puts
1156
+ ui.info("After installing Node.js, install mcp-remote:")
1157
+ puts Colors.text(" npm install -g mcp-remote")
1158
+ return
1159
+ end
1160
+
1161
+ ui.info("npx executable: #{npx_path}")
1162
+ puts
1163
+ ui.warning("Make sure mcp-remote is installed:")
1164
+ puts Colors.text(" npm install -g mcp-remote")
1165
+ puts
1166
+
1167
+ # Get server URL
1168
+ default_url = "http://localhost:3001/mcp"
1169
+ url = ui.input("Server URL:", placeholder: default_url, default_value: default_url)
1170
+ return ui.warning("Cancelled.") if url.nil?
1171
+
1172
+ puts
1173
+ ui.info("Configuration:")
1174
+ puts Colors.text(" command: #{npx_path}")
1175
+ puts Colors.text(" args: mcp-remote #{url}")
1176
+ puts
1177
+
1178
+ return unless ui.confirm("Apply this configuration?", default: true)
1179
+
1180
+ # Backup existing config
1181
+ backup_claude_config
1182
+
1183
+ # Update config
1184
+ config["mcpServers"] ||= {}
1185
+ config["mcpServers"]["railsMcpServer"] = generate_http_config(url)
1186
+
1187
+ save_claude_config(config)
1188
+ end
1189
+
1190
+ def create_claude_config
1191
+ ui.clear
1192
+ ui.header("Create Claude Desktop Config")
1193
+
1194
+ mode = ui.choose("Select connection mode:", ["STDIO (recommended)", "HTTP"])
1195
+ return ui.warning("Cancelled.") if mode.nil?
1196
+
1197
+ if mode.match?(/STDIO/)
1198
+ ruby_path = ruby_executable_path
1199
+ server_path = rails_mcp_server_path
1200
+
1201
+ unless server_path
1202
+ ui.error("Could not find rails-mcp-server executable")
1203
+ return
1204
+ end
1205
+
1206
+ config = generate_example_config(:stdio)
1207
+
1208
+ ui.info("Will create config with:")
1209
+ puts Colors.text(" command: #{ruby_path}")
1210
+ puts Colors.text(" args: #{server_path}")
1211
+
1212
+ else
1213
+ npx_path = npx_executable_path
1214
+
1215
+ unless npx_path
1216
+ ui.error("npx not found. Please install Node.js first.")
1217
+ return
1218
+ end
1219
+
1220
+ default_url = "http://localhost:3001/mcp"
1221
+ url = ui.input("Server URL:", placeholder: default_url, default_value: default_url)
1222
+ return ui.warning("Cancelled.") if url.nil?
1223
+
1224
+ config = generate_example_config(:http, url)
1225
+
1226
+ ui.info("Will create config with:")
1227
+ puts Colors.text(" command: #{npx_path}")
1228
+ puts Colors.text(" args: mcp-remote #{url}")
1229
+ end
1230
+
1231
+ puts
1232
+ return unless ui.confirm("Create config file?", default: true)
1233
+
1234
+ # Create directory if needed
1235
+ config_file = claude_config_path
1236
+ FileUtils.mkdir_p(File.dirname(config_file))
1237
+
1238
+ save_claude_config(config)
1239
+ end
1240
+
1241
+ def show_example_config
1242
+ ui.clear
1243
+ ui.header("Example Configuration")
1244
+
1245
+ mode = ui.choose("Select connection mode:", ["STDIO", "HTTP"])
1246
+ return if mode.nil?
1247
+
1248
+ if mode == "STDIO"
1249
+ config = generate_example_config(:stdio)
1250
+ ui.info("STDIO mode configuration:")
1251
+ else
1252
+ config = generate_example_config(:http)
1253
+ ui.info("HTTP mode configuration:")
1254
+ puts
1255
+ ui.warning("For HTTP mode, install mcp-remote first:")
1256
+ puts Colors.text(" npm install -g mcp-remote")
1257
+ end
1258
+
1259
+ puts
1260
+ puts Colors.lavender("#{claude_config_path}:")
1261
+ puts Colors.overlay("─" * 50)
1262
+ puts JSON.pretty_generate(config)
1263
+ puts Colors.overlay("─" * 50)
1264
+ puts
1265
+ ui.info("Copy the above JSON to your Claude Desktop config file.")
1266
+ end
1267
+
1268
+ def backup_claude_config
1269
+ config_file = claude_config_path
1270
+ return unless File.exist?(config_file)
1271
+
1272
+ backup_file = "#{config_file}.backup.#{Time.now.strftime("%Y%m%d_%H%M%S")}"
1273
+ FileUtils.cp(config_file, backup_file)
1274
+ ui.success("Backed up existing config to: #{File.basename(backup_file)}")
1275
+ end
1276
+
1277
+ def save_claude_config(config)
1278
+ config_file = claude_config_path
1279
+
1280
+ begin
1281
+ File.write(config_file, JSON.pretty_generate(config))
1282
+ ui.success("Configuration saved!")
1283
+ puts
1284
+ ui.warning("Please restart Claude Desktop to apply changes.")
1285
+ rescue => e
1286
+ ui.error("Failed to save config: #{e.message}")
1287
+ end
1288
+ end
1289
+
1290
+ def open_config
1291
+ ui.clear
1292
+ ui.header("Open Config File")
1293
+
1294
+ ui.info("Config file: #{config_path}")
1295
+ puts
1296
+
1297
+ editor = ENV["EDITOR"] || ENV["VISUAL"]
1298
+
1299
+ if editor
1300
+ if ui.confirm("Open in #{editor}?", default: true)
1301
+ system("#{editor} '#{config_path}'")
1302
+ @projects = load_projects
1303
+ ui.success("Reloaded configuration")
1304
+ end
1305
+ else
1306
+ ui.warning("No EDITOR environment variable set.")
1307
+ ui.info("You can manually edit: #{config_path}")
1308
+
1309
+ if RUBY_PLATFORM.match?(/darwin/)
1310
+ if ui.confirm("Open in TextEdit?", default: true)
1311
+ system("open '#{config_path}'")
1312
+ end
1313
+ elsif RUBY_PLATFORM.match?(/linux/)
1314
+ if ui.confirm("Open with xdg-open?", default: true)
1315
+ system("xdg-open '#{config_path}'")
1316
+ end
1317
+ end
1318
+ end
1319
+ end
1320
+
1321
+ def validate_projects
1322
+ ui.clear
1323
+ ui.header("Validate All Projects")
1324
+
1325
+ if projects.empty?
1326
+ ui.warning("No projects to validate.")
1327
+ return
1328
+ end
1329
+
1330
+ valid_count = 0
1331
+ invalid_count = 0
1332
+
1333
+ projects.each do |name, path|
1334
+ expanded = File.expand_path(path)
1335
+
1336
+ print "#{Colors.text(name)}: "
1337
+
1338
+ issues = []
1339
+ issues << "directory not found" unless File.directory?(expanded)
1340
+
1341
+ if File.directory?(expanded)
1342
+ issues << "no Gemfile" unless File.exist?(File.join(expanded, "Gemfile"))
1343
+ issues << "no config/application.rb" unless File.exist?(File.join(expanded, "config", "application.rb"))
1344
+ issues << "no bin/rails" unless File.exist?(File.join(expanded, "bin", "rails"))
1345
+ end
1346
+
1347
+ if issues.empty?
1348
+ ui.success("Valid Rails project")
1349
+ valid_count += 1
1350
+ else
1351
+ ui.error(issues.join(", "))
1352
+ invalid_count += 1
1353
+ end
1354
+ end
1355
+
1356
+ puts
1357
+ ui.info("Summary: #{valid_count} valid, #{invalid_count} invalid")
1358
+ end
1359
+ end
1360
+ end
1361
+
1362
+ # Entry point
1363
+ if __FILE__ == $0 || File.basename($0) == "rails-mcp-config"
1364
+ require "shellwords"
1365
+
1366
+ # Handle --help
1367
+ if ARGV.include?("-h") || ARGV.include?("--help")
1368
+ puts "Rails MCP Server - Configuration Tool"
1369
+ puts
1370
+ puts "Usage: rails-mcp-config"
1371
+ puts
1372
+ puts "Interactive tool to manage Rails MCP Server configuration."
1373
+ puts "Uses Gum (https://github.com/charmbracelet/gum) for enhanced UI if available."
1374
+ puts
1375
+ puts "Features:"
1376
+ puts " • Manage Rails projects"
1377
+ puts " • Download and import documentation guides"
1378
+ puts " • Configure Claude Desktop integration"
1379
+ puts
1380
+ puts "Options:"
1381
+ puts " -h, --help Show this help message"
1382
+ puts " --list List all projects and exit"
1383
+ puts " --validate Validate all projects and exit"
1384
+ puts
1385
+ puts "Install Gum for best experience:"
1386
+ puts " brew install gum # macOS"
1387
+ puts " sudo apt install gum # Debian/Ubuntu"
1388
+ puts " yay -S gum # Arch Linux"
1389
+ exit 0
1390
+ end
1391
+
1392
+ manager = RailsMcpConfig::ProjectManager.new
1393
+
1394
+ # Handle non-interactive commands
1395
+ if ARGV.include?("--list")
1396
+ manager.send(:list_projects)
1397
+ exit 0
1398
+ end
1399
+
1400
+ if ARGV.include?("--validate")
1401
+ manager.send(:validate_projects)
1402
+ exit 0
1403
+ end
1404
+
1405
+ # Run interactive mode
1406
+ manager.run
1407
+ end