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.
- checksums.yaml +4 -4
- data/README.md +168 -166
- data/docs/AGENT.md +345 -0
- data/exe/rails-mcp-config +1411 -0
- data/exe/rails-mcp-server +23 -10
- data/exe/rails-mcp-setup-claude +1 -1
- data/lib/rails-mcp-server/analyzers/analyze_controller_views.rb +253 -0
- data/lib/rails-mcp-server/analyzers/analyze_environment_config.rb +79 -0
- data/lib/rails-mcp-server/analyzers/analyze_models.rb +251 -0
- data/lib/rails-mcp-server/analyzers/base_analyzer.rb +42 -0
- data/lib/rails-mcp-server/analyzers/get_file.rb +40 -0
- data/lib/rails-mcp-server/analyzers/get_routes.rb +212 -0
- data/lib/rails-mcp-server/analyzers/get_schema.rb +216 -0
- data/lib/rails-mcp-server/analyzers/list_files.rb +43 -0
- data/lib/rails-mcp-server/analyzers/load_guide.rb +84 -0
- data/lib/rails-mcp-server/analyzers/project_info.rb +136 -0
- data/lib/rails-mcp-server/tools/base_tool.rb +2 -0
- data/lib/rails-mcp-server/tools/execute_ruby.rb +409 -0
- data/lib/rails-mcp-server/tools/execute_tool.rb +115 -0
- data/lib/rails-mcp-server/tools/search_tools.rb +186 -0
- data/lib/rails-mcp-server/tools/switch_project.rb +16 -1
- data/lib/rails-mcp-server/version.rb +1 -1
- data/lib/rails_mcp_server.rb +19 -53
- metadata +65 -18
- data/lib/rails-mcp-server/extensions/resource_templating.rb +0 -182
- data/lib/rails-mcp-server/extensions/server_templating.rb +0 -333
- data/lib/rails-mcp-server/tools/analyze_controller_views.rb +0 -239
- data/lib/rails-mcp-server/tools/analyze_environment_config.rb +0 -427
- data/lib/rails-mcp-server/tools/analyze_models.rb +0 -116
- data/lib/rails-mcp-server/tools/get_file.rb +0 -55
- data/lib/rails-mcp-server/tools/get_routes.rb +0 -24
- data/lib/rails-mcp-server/tools/get_schema.rb +0 -141
- data/lib/rails-mcp-server/tools/list_files.rb +0 -54
- data/lib/rails-mcp-server/tools/load_guide.rb +0 -370
- 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
|