rails-mcp-server 1.2.3 → 1.4.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +168 -166
  3. data/docs/AGENT.md +345 -0
  4. data/exe/rails-mcp-config +1411 -0
  5. data/exe/rails-mcp-server +23 -10
  6. data/exe/rails-mcp-setup-claude +1 -1
  7. data/lib/rails-mcp-server/analyzers/analyze_controller_views.rb +253 -0
  8. data/lib/rails-mcp-server/analyzers/analyze_environment_config.rb +79 -0
  9. data/lib/rails-mcp-server/analyzers/analyze_models.rb +251 -0
  10. data/lib/rails-mcp-server/analyzers/base_analyzer.rb +42 -0
  11. data/lib/rails-mcp-server/analyzers/get_file.rb +40 -0
  12. data/lib/rails-mcp-server/analyzers/get_routes.rb +212 -0
  13. data/lib/rails-mcp-server/analyzers/get_schema.rb +216 -0
  14. data/lib/rails-mcp-server/analyzers/list_files.rb +43 -0
  15. data/lib/rails-mcp-server/analyzers/load_guide.rb +84 -0
  16. data/lib/rails-mcp-server/analyzers/project_info.rb +136 -0
  17. data/lib/rails-mcp-server/tools/base_tool.rb +2 -0
  18. data/lib/rails-mcp-server/tools/execute_ruby.rb +409 -0
  19. data/lib/rails-mcp-server/tools/execute_tool.rb +115 -0
  20. data/lib/rails-mcp-server/tools/search_tools.rb +186 -0
  21. data/lib/rails-mcp-server/tools/switch_project.rb +16 -1
  22. data/lib/rails-mcp-server/version.rb +1 -1
  23. data/lib/rails_mcp_server.rb +19 -53
  24. metadata +65 -18
  25. data/lib/rails-mcp-server/extensions/resource_templating.rb +0 -182
  26. data/lib/rails-mcp-server/extensions/server_templating.rb +0 -333
  27. data/lib/rails-mcp-server/tools/analyze_controller_views.rb +0 -239
  28. data/lib/rails-mcp-server/tools/analyze_environment_config.rb +0 -427
  29. data/lib/rails-mcp-server/tools/analyze_models.rb +0 -116
  30. data/lib/rails-mcp-server/tools/get_file.rb +0 -55
  31. data/lib/rails-mcp-server/tools/get_routes.rb +0 -24
  32. data/lib/rails-mcp-server/tools/get_schema.rb +0 -141
  33. data/lib/rails-mcp-server/tools/list_files.rb +0 -54
  34. data/lib/rails-mcp-server/tools/load_guide.rb +0 -370
  35. data/lib/rails-mcp-server/tools/project_info.rb +0 -86
@@ -0,0 +1,1411 @@
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 = options + ["Cancel"] if allow_cancel
124
+
125
+ if gum_available?
126
+ result = `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 = `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 >= 1 && i <= 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
+ while !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
+ begin
753
+ downloader = RailsMcpServer::ResourceDownloader.new(
754
+ guide,
755
+ config_dir: config_dir,
756
+ force: force,
757
+ verbose: false
758
+ )
759
+ downloader.download
760
+ rescue => e
761
+ { downloaded: 0, skipped: 0, failed: 1, error: e.message }
762
+ end
763
+ end
764
+
765
+ if results[:error]
766
+ ui.error(" #{results[:error]}")
767
+ elsif results[:failed] > 0
768
+ ui.error(" Failed: #{results[:failed]} files")
769
+ else
770
+ ui.success(" #{results[:downloaded]} downloaded, #{results[:skipped]} skipped")
771
+ end
772
+
773
+ total_results[:downloaded] += results[:downloaded]
774
+ total_results[:skipped] += results[:skipped]
775
+ total_results[:failed] += results[:failed]
776
+ end
777
+
778
+ puts
779
+ ui.info("Total: #{total_results[:downloaded]} downloaded, #{total_results[:skipped]} skipped, #{total_results[:failed]} failed")
780
+ end
781
+
782
+ def import_custom_guides
783
+ ui.clear
784
+ ui.header("Import Custom Guides")
785
+
786
+ # Try to load the resource importer
787
+ begin
788
+ require_relative "../lib/rails-mcp-server/helpers/resource_importer"
789
+ rescue LoadError
790
+ ui.error("Resource importer not available.")
791
+ ui.info("Run from the gem installation directory or check your installation.")
792
+ return
793
+ end
794
+
795
+ ui.info("Import markdown files as custom guides.")
796
+ ui.info("Files will be available via the load_guide tool.")
797
+ ui.info("Starting from: #{Dir.pwd}")
798
+ puts
799
+
800
+ # Get path - start from current directory
801
+ path = if ui.gum_available? && ui.confirm("Use file picker?", default: true)
802
+ ui.file_picker("Select file or directory:", directory: Dir.pwd)
803
+ else
804
+ ui.input("Path to file or directory:", placeholder: "./")
805
+ end
806
+
807
+ return ui.warning("Cancelled.") if path.nil?
808
+
809
+ expanded = File.expand_path(path)
810
+ unless File.exist?(expanded)
811
+ ui.error("Path not found: #{expanded}")
812
+ return
813
+ end
814
+
815
+ force = ui.confirm("Overwrite existing files?", default: false)
816
+
817
+ puts
818
+ puts Colors.sky("Importing from #{File.basename(path)}...")
819
+
820
+ results = ui.spin(" Processing files...") do
821
+ begin
822
+ importer = RailsMcpServer::ResourceImporter.new(
823
+ "custom",
824
+ config_dir: config_dir,
825
+ source_path: expanded,
826
+ force: force,
827
+ verbose: false
828
+ )
829
+ importer.import
830
+ rescue => e
831
+ { imported: 0, skipped: 0, failed: 1, error: e.message }
832
+ end
833
+ end
834
+
835
+ if results[:error]
836
+ ui.error(" #{results[:error]}")
837
+ elsif results[:failed] > 0
838
+ ui.error(" Import completed with errors")
839
+ ui.info(" Imported: #{results[:imported]}, Skipped: #{results[:skipped]}, Failed: #{results[:failed]}")
840
+ else
841
+ ui.success(" #{results[:imported]} imported, #{results[:skipped]} skipped")
842
+ end
843
+ end
844
+
845
+ def get_custom_guides
846
+ custom_dir = File.join(config_dir, "resources", "custom")
847
+ return [] unless Dir.exist?(custom_dir)
848
+
849
+ # Get all markdown files in custom directory
850
+ files = Dir.glob(File.join(custom_dir, "*.md"))
851
+ files.map do |file|
852
+ name = File.basename(file, ".md")
853
+ size = File.size(file)
854
+ mtime = File.mtime(file)
855
+ { name: name, path: file, size: size, mtime: mtime }
856
+ end.sort_by { |f| f[:name] }
857
+ end
858
+
859
+ def format_size(bytes)
860
+ if bytes < 1024
861
+ "#{bytes} B"
862
+ elsif bytes < 1024 * 1024
863
+ "#{(bytes / 1024.0).round(1)} KB"
864
+ else
865
+ "#{(bytes / 1024.0 / 1024.0).round(1)} MB"
866
+ end
867
+ end
868
+
869
+ def manage_custom_guides
870
+ ui.clear
871
+ ui.header("Manage Custom Guides")
872
+
873
+ custom_guides = get_custom_guides
874
+
875
+ if custom_guides.empty?
876
+ ui.warning("No custom guides found.")
877
+ ui.info("Use 'Import custom guides' to add your own markdown files.")
878
+ return
879
+ end
880
+
881
+ # Display location
882
+ custom_dir = File.join(config_dir, "resources", "custom")
883
+ ui.info("Location: #{custom_dir}")
884
+ puts
885
+
886
+ # Display table with max widths to prevent overflow
887
+ headers = ["Name", "Size", "Modified"]
888
+ rows = custom_guides.map do |g|
889
+ [g[:name], format_size(g[:size]), g[:mtime].strftime("%Y-%m-%d %H:%M")]
890
+ end
891
+
892
+ # Use max widths for fallback mode
893
+ ui.table(headers, rows, max_widths: [30, 10, 16])
894
+
895
+ ui.info("Total: #{custom_guides.length} guide(s)")
896
+ puts
897
+
898
+ # Ask what to do
899
+ action = ui.choose("What would you like to do?", ["Delete guides", "Back to menu"])
900
+ return if action.nil? || action.match?(/Back/)
901
+
902
+ # Multi-select guides to delete
903
+ options = custom_guides.map { |g| g[:name] }
904
+ selected = ui.multi_select("Select guides to delete:", options)
905
+
906
+ if selected.empty?
907
+ ui.warning("No guides selected.")
908
+ return
909
+ end
910
+
911
+ # Confirm deletion
912
+ puts
913
+ ui.warning("This will permanently delete #{selected.length} guide(s):")
914
+ selected.each { |name| puts Colors.text(" - #{name}") }
915
+ puts
916
+
917
+ return unless ui.confirm("Are you sure?", default: false)
918
+
919
+ # Delete selected guides
920
+ deleted = 0
921
+ failed = 0
922
+
923
+ selected.each do |name|
924
+ guide = custom_guides.find { |g| g[:name] == name }
925
+ next unless guide
926
+
927
+ begin
928
+ File.delete(guide[:path])
929
+ deleted += 1
930
+ rescue => e
931
+ ui.error("Failed to delete #{name}: #{e.message}")
932
+ failed += 1
933
+ end
934
+ end
935
+
936
+ # Update manifest if it exists
937
+ manifest_path = File.join(config_dir, "resources", "custom", "manifest.yaml")
938
+ if File.exist?(manifest_path)
939
+ begin
940
+ manifest = YAML.load_file(manifest_path)
941
+ if manifest && manifest["files"]
942
+ selected.each do |name|
943
+ manifest["files"].delete("#{name}.md")
944
+ end
945
+ manifest["updated_at"] = Time.now.to_s
946
+ File.write(manifest_path, manifest.to_yaml)
947
+ end
948
+ rescue => e
949
+ ui.warning("Could not update manifest: #{e.message}")
950
+ end
951
+ end
952
+
953
+ puts
954
+ if failed > 0
955
+ ui.warning("Deleted #{deleted} guide(s), #{failed} failed")
956
+ else
957
+ ui.success("Deleted #{deleted} guide(s)")
958
+ end
959
+ end
960
+
961
+ # Claude Desktop Integration
962
+ def claude_config_path
963
+ if RUBY_PLATFORM.match?(/mswin|mingw|cygwin/)
964
+ File.join(ENV["APPDATA"], "Claude", "claude_desktop_config.json")
965
+ elsif RUBY_PLATFORM.match?(/darwin/)
966
+ File.expand_path("~/Library/Application Support/Claude/claude_desktop_config.json")
967
+ else
968
+ # Linux - check common locations
969
+ xdg = ENV["XDG_CONFIG_HOME"] || File.expand_path("~/.config")
970
+ File.join(xdg, "Claude", "claude_desktop_config.json")
971
+ end
972
+ end
973
+
974
+ def load_claude_config
975
+ return nil unless File.exist?(claude_config_path)
976
+ JSON.parse(File.read(claude_config_path))
977
+ rescue JSON::ParserError => e
978
+ ui.error("Invalid JSON in Claude config: #{e.message}")
979
+ nil
980
+ end
981
+
982
+ def rails_mcp_server_path
983
+ # Find the rails-mcp-server executable
984
+ gem_exe = File.expand_path("../rails-mcp-server", __FILE__)
985
+ return gem_exe if File.exist?(gem_exe)
986
+
987
+ # Try to find via which
988
+ path = `which rails-mcp-server 2>/dev/null`.strip
989
+ return path unless path.empty?
990
+
991
+ nil
992
+ end
993
+
994
+ def ruby_executable_path
995
+ # Get full path to ruby
996
+ RbConfig.ruby
997
+ end
998
+
999
+ def npx_executable_path
1000
+ `which npx 2>/dev/null`.strip.then { |p| p.empty? ? nil : p }
1001
+ end
1002
+
1003
+ def generate_stdio_config
1004
+ server_path = rails_mcp_server_path
1005
+ ruby_path = ruby_executable_path
1006
+
1007
+ {
1008
+ "command" => ruby_path,
1009
+ "args" => [server_path]
1010
+ }
1011
+ end
1012
+
1013
+ def generate_http_config(url)
1014
+ npx_path = npx_executable_path
1015
+
1016
+ {
1017
+ "command" => npx_path,
1018
+ "args" => ["mcp-remote", url]
1019
+ }
1020
+ end
1021
+
1022
+ def generate_example_config(mode, url = nil)
1023
+ config = { "mcpServers" => {} }
1024
+
1025
+ if mode == :stdio
1026
+ config["mcpServers"]["railsMcpServer"] = generate_stdio_config
1027
+ else
1028
+ config["mcpServers"]["railsMcpServer"] = generate_http_config(url || "http://localhost:3001/mcp")
1029
+ end
1030
+
1031
+ config
1032
+ end
1033
+
1034
+ def claude_desktop_integration
1035
+ ui.clear
1036
+ ui.header("Claude Desktop Integration")
1037
+
1038
+ config_file = claude_config_path
1039
+ ui.info("Config file: #{config_file}")
1040
+ puts
1041
+
1042
+ # Check if file exists
1043
+ if File.exist?(config_file)
1044
+ ui.success("Claude Desktop config file found")
1045
+
1046
+ config = load_claude_config
1047
+ if config.nil?
1048
+ ui.error("Could not parse config file")
1049
+ return
1050
+ end
1051
+
1052
+ # Check if railsMcpServer is configured
1053
+ mcp_servers = config["mcpServers"] || {}
1054
+ rails_config = mcp_servers["railsMcpServer"]
1055
+
1056
+ if rails_config
1057
+ ui.success("Rails MCP Server is configured")
1058
+ puts
1059
+ puts Colors.lavender("Current configuration:")
1060
+ puts Colors.text(" command: #{rails_config['command']}")
1061
+ puts Colors.text(" args: #{rails_config['args']&.join(' ') || '(none)'}")
1062
+ else
1063
+ ui.warning("Rails MCP Server is NOT configured")
1064
+ end
1065
+
1066
+ puts
1067
+
1068
+ # Menu options
1069
+ options = ["View full config"]
1070
+ options << (rails_config ? "Update Rails MCP Server config" : "Add Rails MCP Server config")
1071
+ options << "Back to menu"
1072
+
1073
+ action = ui.choose("What would you like to do?", options, allow_cancel: false)
1074
+
1075
+ case action
1076
+ when /View/
1077
+ puts
1078
+ ui.pager(JSON.pretty_generate(config))
1079
+ when /Update|Add/
1080
+ configure_rails_mcp_server(config)
1081
+ end
1082
+
1083
+ else
1084
+ ui.warning("Claude Desktop config file not found")
1085
+ puts
1086
+ ui.info("Claude Desktop stores its configuration at:")
1087
+ puts Colors.text(" #{config_file}")
1088
+ puts
1089
+
1090
+ action = ui.choose("What would you like to do?", [
1091
+ "Create config file",
1092
+ "Show example configuration",
1093
+ "Back to menu"
1094
+ ], allow_cancel: false)
1095
+
1096
+ case action
1097
+ when /Create/
1098
+ create_claude_config
1099
+ when /Show example/
1100
+ show_example_config
1101
+ end
1102
+ end
1103
+ end
1104
+
1105
+ def configure_rails_mcp_server(config)
1106
+ ui.clear
1107
+ ui.header("Configure Rails MCP Server")
1108
+
1109
+ ui.info("Choose connection mode:")
1110
+ puts
1111
+ puts Colors.text(" STDIO - Direct communication (recommended)")
1112
+ puts Colors.text(" Requires: Ruby")
1113
+ puts
1114
+ puts Colors.text(" HTTP - Network-based communication")
1115
+ puts Colors.text(" Requires: Node.js and mcp-remote package")
1116
+ puts
1117
+
1118
+ mode = ui.choose("Select mode:", ["STDIO (recommended)", "HTTP"])
1119
+ return ui.warning("Cancelled.") if mode.nil?
1120
+
1121
+ if mode.match?(/STDIO/)
1122
+ configure_stdio_mode(config)
1123
+ else
1124
+ configure_http_mode(config)
1125
+ end
1126
+ end
1127
+
1128
+ def configure_stdio_mode(config)
1129
+ ruby_path = ruby_executable_path
1130
+ server_path = rails_mcp_server_path
1131
+
1132
+ unless server_path
1133
+ ui.error("Could not find rails-mcp-server executable")
1134
+ ui.info("Make sure the gem is properly installed")
1135
+ return
1136
+ end
1137
+
1138
+ ui.info("Ruby executable: #{ruby_path}")
1139
+ ui.info("Server executable: #{server_path}")
1140
+ puts
1141
+
1142
+ return unless ui.confirm("Apply this configuration?", default: true)
1143
+
1144
+ # Backup existing config
1145
+ backup_claude_config
1146
+
1147
+ # Update config
1148
+ config["mcpServers"] ||= {}
1149
+ config["mcpServers"]["railsMcpServer"] = generate_stdio_config
1150
+
1151
+ save_claude_config(config)
1152
+ end
1153
+
1154
+ def configure_http_mode(config)
1155
+ npx_path = npx_executable_path
1156
+
1157
+ unless npx_path
1158
+ ui.error("npx not found. Please install Node.js first.")
1159
+ puts
1160
+ ui.info("After installing Node.js, install mcp-remote:")
1161
+ puts Colors.text(" npm install -g mcp-remote")
1162
+ return
1163
+ end
1164
+
1165
+ ui.info("npx executable: #{npx_path}")
1166
+ puts
1167
+ ui.warning("Make sure mcp-remote is installed:")
1168
+ puts Colors.text(" npm install -g mcp-remote")
1169
+ puts
1170
+
1171
+ # Get server URL
1172
+ default_url = "http://localhost:3001/mcp"
1173
+ url = ui.input("Server URL:", placeholder: default_url, default_value: default_url)
1174
+ return ui.warning("Cancelled.") if url.nil?
1175
+
1176
+ puts
1177
+ ui.info("Configuration:")
1178
+ puts Colors.text(" command: #{npx_path}")
1179
+ puts Colors.text(" args: mcp-remote #{url}")
1180
+ puts
1181
+
1182
+ return unless ui.confirm("Apply this configuration?", default: true)
1183
+
1184
+ # Backup existing config
1185
+ backup_claude_config
1186
+
1187
+ # Update config
1188
+ config["mcpServers"] ||= {}
1189
+ config["mcpServers"]["railsMcpServer"] = generate_http_config(url)
1190
+
1191
+ save_claude_config(config)
1192
+ end
1193
+
1194
+ def create_claude_config
1195
+ ui.clear
1196
+ ui.header("Create Claude Desktop Config")
1197
+
1198
+ mode = ui.choose("Select connection mode:", ["STDIO (recommended)", "HTTP"])
1199
+ return ui.warning("Cancelled.") if mode.nil?
1200
+
1201
+ if mode.match?(/STDIO/)
1202
+ ruby_path = ruby_executable_path
1203
+ server_path = rails_mcp_server_path
1204
+
1205
+ unless server_path
1206
+ ui.error("Could not find rails-mcp-server executable")
1207
+ return
1208
+ end
1209
+
1210
+ config = generate_example_config(:stdio)
1211
+
1212
+ ui.info("Will create config with:")
1213
+ puts Colors.text(" command: #{ruby_path}")
1214
+ puts Colors.text(" args: #{server_path}")
1215
+
1216
+ else
1217
+ npx_path = npx_executable_path
1218
+
1219
+ unless npx_path
1220
+ ui.error("npx not found. Please install Node.js first.")
1221
+ return
1222
+ end
1223
+
1224
+ default_url = "http://localhost:3001/mcp"
1225
+ url = ui.input("Server URL:", placeholder: default_url, default_value: default_url)
1226
+ return ui.warning("Cancelled.") if url.nil?
1227
+
1228
+ config = generate_example_config(:http, url)
1229
+
1230
+ ui.info("Will create config with:")
1231
+ puts Colors.text(" command: #{npx_path}")
1232
+ puts Colors.text(" args: mcp-remote #{url}")
1233
+ end
1234
+
1235
+ puts
1236
+ return unless ui.confirm("Create config file?", default: true)
1237
+
1238
+ # Create directory if needed
1239
+ config_file = claude_config_path
1240
+ FileUtils.mkdir_p(File.dirname(config_file))
1241
+
1242
+ save_claude_config(config)
1243
+ end
1244
+
1245
+ def show_example_config
1246
+ ui.clear
1247
+ ui.header("Example Configuration")
1248
+
1249
+ mode = ui.choose("Select connection mode:", ["STDIO", "HTTP"])
1250
+ return if mode.nil?
1251
+
1252
+ if mode == "STDIO"
1253
+ config = generate_example_config(:stdio)
1254
+ ui.info("STDIO mode configuration:")
1255
+ else
1256
+ config = generate_example_config(:http)
1257
+ ui.info("HTTP mode configuration:")
1258
+ puts
1259
+ ui.warning("For HTTP mode, install mcp-remote first:")
1260
+ puts Colors.text(" npm install -g mcp-remote")
1261
+ end
1262
+
1263
+ puts
1264
+ puts Colors.lavender("#{claude_config_path}:")
1265
+ puts Colors.overlay("─" * 50)
1266
+ puts JSON.pretty_generate(config)
1267
+ puts Colors.overlay("─" * 50)
1268
+ puts
1269
+ ui.info("Copy the above JSON to your Claude Desktop config file.")
1270
+ end
1271
+
1272
+ def backup_claude_config
1273
+ config_file = claude_config_path
1274
+ return unless File.exist?(config_file)
1275
+
1276
+ backup_file = "#{config_file}.backup.#{Time.now.strftime('%Y%m%d_%H%M%S')}"
1277
+ FileUtils.cp(config_file, backup_file)
1278
+ ui.success("Backed up existing config to: #{File.basename(backup_file)}")
1279
+ end
1280
+
1281
+ def save_claude_config(config)
1282
+ config_file = claude_config_path
1283
+
1284
+ begin
1285
+ File.write(config_file, JSON.pretty_generate(config))
1286
+ ui.success("Configuration saved!")
1287
+ puts
1288
+ ui.warning("Please restart Claude Desktop to apply changes.")
1289
+ rescue => e
1290
+ ui.error("Failed to save config: #{e.message}")
1291
+ end
1292
+ end
1293
+
1294
+ def open_config
1295
+ ui.clear
1296
+ ui.header("Open Config File")
1297
+
1298
+ ui.info("Config file: #{config_path}")
1299
+ puts
1300
+
1301
+ editor = ENV["EDITOR"] || ENV["VISUAL"]
1302
+
1303
+ if editor
1304
+ if ui.confirm("Open in #{editor}?", default: true)
1305
+ system("#{editor} '#{config_path}'")
1306
+ @projects = load_projects
1307
+ ui.success("Reloaded configuration")
1308
+ end
1309
+ else
1310
+ ui.warning("No EDITOR environment variable set.")
1311
+ ui.info("You can manually edit: #{config_path}")
1312
+
1313
+ if RUBY_PLATFORM.match?(/darwin/)
1314
+ if ui.confirm("Open in TextEdit?", default: true)
1315
+ system("open '#{config_path}'")
1316
+ end
1317
+ elsif RUBY_PLATFORM.match?(/linux/)
1318
+ if ui.confirm("Open with xdg-open?", default: true)
1319
+ system("xdg-open '#{config_path}'")
1320
+ end
1321
+ end
1322
+ end
1323
+ end
1324
+
1325
+ def validate_projects
1326
+ ui.clear
1327
+ ui.header("Validate All Projects")
1328
+
1329
+ if projects.empty?
1330
+ ui.warning("No projects to validate.")
1331
+ return
1332
+ end
1333
+
1334
+ valid_count = 0
1335
+ invalid_count = 0
1336
+
1337
+ projects.each do |name, path|
1338
+ expanded = File.expand_path(path)
1339
+
1340
+ print "#{Colors.text(name)}: "
1341
+
1342
+ issues = []
1343
+ issues << "directory not found" unless File.directory?(expanded)
1344
+
1345
+ if File.directory?(expanded)
1346
+ issues << "no Gemfile" unless File.exist?(File.join(expanded, "Gemfile"))
1347
+ issues << "no config/application.rb" unless File.exist?(File.join(expanded, "config", "application.rb"))
1348
+ issues << "no bin/rails" unless File.exist?(File.join(expanded, "bin", "rails"))
1349
+ end
1350
+
1351
+ if issues.empty?
1352
+ ui.success("Valid Rails project")
1353
+ valid_count += 1
1354
+ else
1355
+ ui.error(issues.join(", "))
1356
+ invalid_count += 1
1357
+ end
1358
+ end
1359
+
1360
+ puts
1361
+ ui.info("Summary: #{valid_count} valid, #{invalid_count} invalid")
1362
+ end
1363
+ end
1364
+ end
1365
+
1366
+ # Entry point
1367
+ if __FILE__ == $0
1368
+ require "shellwords"
1369
+
1370
+ # Handle --help
1371
+ if ARGV.include?("-h") || ARGV.include?("--help")
1372
+ puts "Rails MCP Server - Configuration Tool"
1373
+ puts
1374
+ puts "Usage: rails-mcp-config"
1375
+ puts
1376
+ puts "Interactive tool to manage Rails MCP Server configuration."
1377
+ puts "Uses Gum (https://github.com/charmbracelet/gum) for enhanced UI if available."
1378
+ puts
1379
+ puts "Features:"
1380
+ puts " • Manage Rails projects"
1381
+ puts " • Download and import documentation guides"
1382
+ puts " • Configure Claude Desktop integration"
1383
+ puts
1384
+ puts "Options:"
1385
+ puts " -h, --help Show this help message"
1386
+ puts " --list List all projects and exit"
1387
+ puts " --validate Validate all projects and exit"
1388
+ puts
1389
+ puts "Install Gum for best experience:"
1390
+ puts " brew install gum # macOS"
1391
+ puts " sudo apt install gum # Debian/Ubuntu"
1392
+ puts " yay -S gum # Arch Linux"
1393
+ exit 0
1394
+ end
1395
+
1396
+ manager = RailsMcpConfig::ProjectManager.new
1397
+
1398
+ # Handle non-interactive commands
1399
+ if ARGV.include?("--list")
1400
+ manager.send(:list_projects)
1401
+ exit 0
1402
+ end
1403
+
1404
+ if ARGV.include?("--validate")
1405
+ manager.send(:validate_projects)
1406
+ exit 0
1407
+ end
1408
+
1409
+ # Run interactive mode
1410
+ manager.run
1411
+ end