rails-mcp-server 1.1.4 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +216 -0
  3. data/README.md +156 -46
  4. data/config/resources.yml +203 -0
  5. data/docs/RESOURCES.md +339 -0
  6. data/exe/rails-mcp-server +8 -5
  7. data/exe/rails-mcp-server-download-resources +120 -0
  8. data/lib/rails-mcp-server/config.rb +7 -1
  9. data/lib/rails-mcp-server/extensions/resource_templating.rb +182 -0
  10. data/lib/rails-mcp-server/extensions/server_templating.rb +333 -0
  11. data/lib/rails-mcp-server/helpers/resource_base.rb +143 -0
  12. data/lib/rails-mcp-server/helpers/resource_downloader.rb +104 -0
  13. data/lib/rails-mcp-server/helpers/resource_importer.rb +113 -0
  14. data/lib/rails-mcp-server/resources/base_resource.rb +7 -0
  15. data/lib/rails-mcp-server/resources/custom_guides_resource.rb +54 -0
  16. data/lib/rails-mcp-server/resources/custom_guides_resources.rb +37 -0
  17. data/lib/rails-mcp-server/resources/guide_content_formatter.rb +130 -0
  18. data/lib/rails-mcp-server/resources/guide_error_handler.rb +85 -0
  19. data/lib/rails-mcp-server/resources/guide_file_finder.rb +100 -0
  20. data/lib/rails-mcp-server/resources/guide_framework_contract.rb +65 -0
  21. data/lib/rails-mcp-server/resources/guide_loader_template.rb +122 -0
  22. data/lib/rails-mcp-server/resources/guide_manifest_operations.rb +52 -0
  23. data/lib/rails-mcp-server/resources/kamal_guides_resource.rb +80 -0
  24. data/lib/rails-mcp-server/resources/kamal_guides_resources.rb +110 -0
  25. data/lib/rails-mcp-server/resources/rails_guides_resource.rb +29 -0
  26. data/lib/rails-mcp-server/resources/rails_guides_resources.rb +37 -0
  27. data/lib/rails-mcp-server/resources/stimulus_guides_resource.rb +29 -0
  28. data/lib/rails-mcp-server/resources/stimulus_guides_resources.rb +37 -0
  29. data/lib/rails-mcp-server/resources/turbo_guides_resource.rb +29 -0
  30. data/lib/rails-mcp-server/resources/turbo_guides_resources.rb +37 -0
  31. data/lib/rails-mcp-server/tools/analyze_models.rb +1 -1
  32. data/lib/rails-mcp-server/tools/load_guide.rb +370 -0
  33. data/lib/rails-mcp-server/version.rb +1 -1
  34. data/lib/rails_mcp_server.rb +51 -283
  35. metadata +49 -6
@@ -0,0 +1,104 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require_relative "resource_base"
4
+
5
+ module RailsMcpServer
6
+ class ResourceDownloader < ResourceBase
7
+ class DownloadError < StandardError; end
8
+
9
+ def initialize(resource_name, config_dir:, force: false, verbose: false)
10
+ super
11
+ load_config
12
+ end
13
+
14
+ def download
15
+ setup_directories
16
+ load_manifest
17
+
18
+ log "Downloading #{@resource_name} resources..."
19
+
20
+ results = {downloaded: 0, skipped: 0, failed: 0}
21
+
22
+ @config["files"].each do |file|
23
+ result = download_file(file)
24
+ results[result] += 1
25
+ end
26
+
27
+ save_manifest
28
+ results
29
+ end
30
+
31
+ def self.available_resources(config_dir)
32
+ config_file = File.join(File.dirname(__FILE__), "..", "..", "..", "config", "resources.yml")
33
+ return [] unless File.exist?(config_file)
34
+
35
+ YAML.load_file(config_file).keys
36
+ rescue => e
37
+ warn "Failed to load resource configuration: #{e.message}"
38
+ []
39
+ end
40
+
41
+ protected
42
+
43
+ def create_manifest
44
+ {
45
+ "resource" => @resource_name,
46
+ "base_url" => @config["base_url"],
47
+ "description" => @config["description"],
48
+ "version" => @config["version"],
49
+ "files" => {},
50
+ "created_at" => Time.now.to_s,
51
+ "updated_at" => Time.now.to_s
52
+ }
53
+ end
54
+
55
+ def timestamp_key
56
+ "downloaded_at"
57
+ end
58
+
59
+ private
60
+
61
+ def load_config
62
+ config_file = File.join(File.dirname(__FILE__), "..", "..", "..", "config", "resources.yml")
63
+
64
+ raise DownloadError, "Resource configuration file not found" unless File.exist?(config_file)
65
+
66
+ all_configs = YAML.load_file(config_file)
67
+ @config = all_configs[@resource_name]
68
+
69
+ raise DownloadError, "Unknown resource: #{@resource_name}" unless @config
70
+ end
71
+
72
+ def download_file(filename)
73
+ file_path = File.join(@resource_folder, filename)
74
+ url = "#{@config["base_url"]}/#{filename}"
75
+
76
+ # Skip if unchanged
77
+ if !@force && file_unchanged?(filename, file_path)
78
+ log "Skipping #{filename} (unchanged)"
79
+ return :skipped
80
+ end
81
+
82
+ log "Downloading #{filename}... ", newline: false
83
+
84
+ begin
85
+ uri = URI(url)
86
+ response = Net::HTTP.get_response(uri)
87
+
88
+ if response.code == "200"
89
+ FileUtils.mkdir_p(File.dirname(file_path))
90
+ File.write(file_path, response.body)
91
+ save_file_to_manifest(filename, file_path)
92
+ log "done"
93
+ :downloaded
94
+ else
95
+ log "failed (HTTP #{response.code})"
96
+ :failed
97
+ end
98
+ rescue => e
99
+ log "failed (#{e.message})"
100
+ :failed
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,113 @@
1
+ require_relative "resource_base"
2
+
3
+ module RailsMcpServer
4
+ class ResourceImporter < ResourceBase
5
+ class ImportError < StandardError; end
6
+
7
+ def initialize(resource_name, config_dir:, source_path:, force: false, verbose: false)
8
+ @source_path = source_path
9
+ super(resource_name, config_dir: config_dir, force: force, verbose: verbose)
10
+ validate_source
11
+ end
12
+
13
+ def import
14
+ setup_directories
15
+ load_manifest
16
+
17
+ log "Importing custom files from #{@source_path}..."
18
+
19
+ results = {imported: 0, skipped: 0, failed: 0}
20
+ files = collect_files
21
+
22
+ if files.empty?
23
+ log "No markdown files found"
24
+ save_manifest
25
+ return results
26
+ end
27
+
28
+ files.each do |file_path|
29
+ result = import_file(file_path)
30
+ results[result] += 1
31
+ end
32
+
33
+ save_manifest
34
+ results
35
+ end
36
+
37
+ protected
38
+
39
+ def create_manifest
40
+ {
41
+ "resource" => @resource_name,
42
+ "base_url" => "local",
43
+ "description" => "Custom imported documentation",
44
+ "files" => {},
45
+ "created_at" => Time.now.to_s,
46
+ "updated_at" => Time.now.to_s
47
+ }
48
+ end
49
+
50
+ def timestamp_key
51
+ "imported_at"
52
+ end
53
+
54
+ private
55
+
56
+ def validate_source
57
+ raise ImportError, "Source not found: #{@source_path}" unless File.exist?(@source_path)
58
+ raise ImportError, "Source not readable: #{@source_path}" unless File.readable?(@source_path)
59
+ end
60
+
61
+ def collect_files
62
+ if File.file?(@source_path)
63
+ markdown?(@source_path) ? [@source_path] : []
64
+ elsif File.directory?(@source_path)
65
+ Dir.glob(File.join(@source_path, "*.md")).select { |f| File.file?(f) }.sort # rubocop:disable Performance/ChainArrayAllocation
66
+ else
67
+ []
68
+ end
69
+ end
70
+
71
+ def markdown?(file_path)
72
+ File.extname(file_path).casecmp(".md").zero?
73
+ end
74
+
75
+ def import_file(file_path)
76
+ original_filename = File.basename(file_path)
77
+ normalized_filename = normalize_filename(original_filename)
78
+ destination_path = File.join(@resource_folder, normalized_filename)
79
+
80
+ # Skip if unchanged
81
+ if !@force && file_unchanged?(normalized_filename, file_path)
82
+ log "Skipping #{original_filename} (unchanged)"
83
+ return :skipped
84
+ end
85
+
86
+ log "Importing #{original_filename} -> #{normalized_filename}... ", newline: false
87
+
88
+ begin
89
+ FileUtils.cp(file_path, destination_path)
90
+ save_file_to_manifest(normalized_filename, destination_path,
91
+ {"original_filename" => original_filename})
92
+ log "done"
93
+ :imported
94
+ rescue => e
95
+ log "failed (#{e.message})"
96
+ :failed
97
+ end
98
+ end
99
+
100
+ def normalize_filename(filename)
101
+ basename = File.basename(filename, ".md")
102
+ extension = File.extname(filename)
103
+
104
+ normalized = basename.downcase
105
+ .gsub(/[^a-z0-9_\-.]/, "_")
106
+ .squeeze("_")
107
+ .gsub(/^_+|_+$/, "")
108
+
109
+ normalized = "untitled" if normalized.empty?
110
+ "#{normalized}#{extension}"
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,7 @@
1
+ module RailsMcpServer
2
+ class BaseResource < FastMcp::Resource
3
+ extend Forwardable
4
+
5
+ def_delegators :RailsMcpServer, :log, :config_dir
6
+ end
7
+ end
@@ -0,0 +1,54 @@
1
+ module RailsMcpServer
2
+ class CustomGuidesResource < BaseResource
3
+ include GuideLoaderTemplate
4
+
5
+ uri "custom://guides/{guide_name}"
6
+ resource_name "Custom Guides"
7
+ description "Access to specific custom imported documentation"
8
+ mime_type "text/markdown"
9
+
10
+ protected
11
+
12
+ def framework_name
13
+ "Custom"
14
+ end
15
+
16
+ def resource_directory
17
+ "custom"
18
+ end
19
+
20
+ def download_command
21
+ "rails-mcp-server-download-resources --file /path/to/files"
22
+ end
23
+
24
+ # Custom guides don't use handbook/reference sections (flat structure like Rails)
25
+ def supports_sections?
26
+ false
27
+ end
28
+
29
+ # Custom display name to show original filename
30
+ def customize_display_name(guide_name, guide_data)
31
+ guide_data["original_filename"] || guide_name
32
+ end
33
+
34
+ # Custom error message for imports
35
+ def customize_not_found_message(message, guide_name)
36
+ message + "\n**Note:** Make sure you've imported your custom guides with `#{download_command}`\n"
37
+ end
38
+
39
+ # Custom manifest error handling
40
+ def handle_manifest_error(error)
41
+ case error.message
42
+ when /No Custom guides found/
43
+ format_error_message(
44
+ "No custom guides found. Import guides with:\n" \
45
+ "`rails-mcp-server-download-resources --file /path/to/guide.md`\n" \
46
+ "or\n" \
47
+ "`rails-mcp-server-download-resources --file /path/to/guides/`"
48
+ )
49
+ else
50
+ super
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,37 @@
1
+ module RailsMcpServer
2
+ class CustomGuidesResources < BaseResource
3
+ include GuideLoaderTemplate
4
+
5
+ uri "custom://guides"
6
+ resource_name "Custom Guides"
7
+ description "Access to available custom imported guides"
8
+ mime_type "text/markdown"
9
+
10
+ protected
11
+
12
+ def framework_name
13
+ "Custom"
14
+ end
15
+
16
+ def resource_directory
17
+ "custom"
18
+ end
19
+
20
+ def download_command
21
+ "rails-mcp-server-download-resources --file /path/to/files"
22
+ end
23
+
24
+ def example_guides
25
+ [
26
+ {guide: "api_documentation", comment: "Load API documentation"},
27
+ {guide: "setup_guide", comment: "Load setup instructions"},
28
+ {guide: "user_manual", comment: "Load user manual"}
29
+ ]
30
+ end
31
+
32
+ # Custom guides don't use handbook/reference sections (flat structure like Rails)
33
+ def supports_sections?
34
+ false
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,130 @@
1
+ module RailsMcpServer
2
+ # Module for formatting guide content and messages
3
+ module GuideContentFormatter
4
+ protected
5
+
6
+ # Format the guide content with appropriate headers
7
+ def format_guide_content(content, guide_name, guide_data, filename)
8
+ title = guide_data["title"] || generate_title_from_filename(filename)
9
+
10
+ header = if supports_sections?
11
+ section = determine_section(filename)
12
+ <<~HEADER
13
+ # #{title}
14
+
15
+ **Source:** #{framework_name} #{section}
16
+ **Guide:** #{guide_name}
17
+ **File:** #{filename}
18
+
19
+ ---
20
+
21
+ HEADER
22
+ else
23
+ <<~HEADER
24
+ # #{title}
25
+
26
+ **Source:** #{framework_name} Guides
27
+ **Guide:** #{guide_name}
28
+
29
+ ---
30
+
31
+ HEADER
32
+ end
33
+
34
+ header + content
35
+ end
36
+
37
+ # Format individual guide entry for listings
38
+ def format_guide_entry(title, short_name, full_name, description)
39
+ if supports_sections? && short_name != full_name
40
+ <<~GUIDE
41
+ ### #{title}
42
+ **Guide name:** `#{short_name}` or `#{full_name}`
43
+ #{description.empty? ? "" : "**Description:** #{description}"}
44
+ GUIDE
45
+ else
46
+ <<~GUIDE
47
+ ## #{title}
48
+ **Guide name:** `#{short_name}`
49
+ #{description.empty? ? "" : "**Description:** #{description}"}
50
+ GUIDE
51
+ end
52
+ end
53
+
54
+ # Format usage examples section
55
+ def format_usage_examples
56
+ examples = example_guides
57
+
58
+ usage = "\n## Example Usage:\n"
59
+ usage += "```\n"
60
+
61
+ examples.each do |example|
62
+ usage += "load_guide guides: \"#{framework_name.downcase}\", guide: \"#{example[:guide]}\"#{example[:comment] ? " # " + example[:comment] : ""}\n"
63
+ end
64
+
65
+ usage += "```\n"
66
+ usage
67
+ end
68
+
69
+ # Determine section from filename (handbook/reference/etc)
70
+ def determine_section(filename)
71
+ return "Handbook" if filename.start_with?("handbook/")
72
+ return "Reference" if filename.start_with?("reference/")
73
+
74
+ # Framework-specific section detection can be overridden
75
+ framework_specific_section(filename) if respond_to?(:framework_specific_section, true)
76
+
77
+ "Documentation"
78
+ end
79
+
80
+ # Format guides organized by sections (handbook/reference)
81
+ def format_sectioned_guides(guide_files)
82
+ sectioned = get_sectioned_guide_files(guide_files.keys.zip(guide_files.values).to_h)
83
+ guides = []
84
+
85
+ # Add handbook section
86
+ if sectioned[:handbook].any?
87
+ guides << "\n## Handbook (Main Documentation)\n"
88
+ sectioned[:handbook].each do |filename, file_data|
89
+ metadata = extract_guide_metadata(filename, file_data)
90
+ short_name = metadata[:guide_name].sub("handbook/", "")
91
+ guides << format_guide_entry(metadata[:title], short_name, metadata[:guide_name], metadata[:description])
92
+ end
93
+ end
94
+
95
+ # Add reference section
96
+ if sectioned[:reference].any?
97
+ guides << "\n## Reference (API Documentation)\n"
98
+ sectioned[:reference].each do |filename, file_data|
99
+ metadata = extract_guide_metadata(filename, file_data)
100
+ short_name = metadata[:guide_name].sub("reference/", "")
101
+ guides << format_guide_entry(metadata[:title], short_name, metadata[:guide_name], metadata[:description])
102
+ end
103
+ end
104
+
105
+ # Add other sections
106
+ if sectioned[:other].any?
107
+ guides << "\n## Other Guides\n"
108
+ sectioned[:other].each do |filename, file_data|
109
+ metadata = extract_guide_metadata(filename, file_data)
110
+ guides << format_guide_entry(metadata[:title], metadata[:guide_name], metadata[:guide_name], metadata[:description])
111
+ end
112
+ end
113
+
114
+ guides
115
+ end
116
+
117
+ # Format guides in a flat structure (no sections)
118
+ def format_flat_guides(guide_files)
119
+ guides = []
120
+
121
+ guide_files.each do |filename, file_data|
122
+ log(:debug, "Processing guide: #{filename}")
123
+ metadata = extract_guide_metadata(filename, file_data)
124
+ guides << format_guide_entry(metadata[:title], metadata[:guide_name], metadata[:guide_name], metadata[:description])
125
+ end
126
+
127
+ guides
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,85 @@
1
+ module RailsMcpServer
2
+ # Module for handling guide-related errors and messages
3
+ module GuideErrorHandler
4
+ protected
5
+
6
+ # Format error messages consistently
7
+ def format_error_message(message)
8
+ "# Error\n\n#{message}"
9
+ end
10
+
11
+ # Create standardized not found message
12
+ def create_not_found_message(guide_name, available_guides)
13
+ normalized_guide_name = guide_name.gsub(/[^a-zA-Z0-9_\/.-]/, "").downcase
14
+ suggestions = find_suggestions(normalized_guide_name, available_guides)
15
+
16
+ message = "# Guide Not Found\n\n"
17
+ message += "Guide '#{guide_name}' not found in #{framework_name} guides.\n\n"
18
+
19
+ if suggestions.any?
20
+ message += "## Did you mean one of these?\n\n"
21
+ suggestions.each { |suggestion| message += "- #{suggestion}\n" }
22
+ message += "\n**Try:** `load_guide guides: \"#{framework_name.downcase}\", guide: \"#{suggestions.first}\"`\n"
23
+ else
24
+ message += format_available_guides_section(available_guides)
25
+ message += "Use `load_guide guides: \"#{framework_name.downcase}\"` to see all available guides with descriptions.\n"
26
+ end
27
+
28
+ message
29
+ end
30
+
31
+ # Format available guides section for error messages
32
+ def format_available_guides_section(available_guides)
33
+ return "\n" unless supports_sections?
34
+
35
+ handbook_guides = available_guides.select { |g| g.start_with?("handbook/") }
36
+ reference_guides = available_guides.select { |g| g.start_with?("reference/") }
37
+
38
+ message = "## Available #{framework_name} Guides:\n\n"
39
+
40
+ if handbook_guides.any?
41
+ message += "### Handbook:\n"
42
+ handbook_guides.each { |guide| message += "- #{guide.sub("handbook/", "")}\n" }
43
+ message += "\n"
44
+ end
45
+
46
+ if reference_guides.any?
47
+ message += "### Reference:\n"
48
+ reference_guides.each { |guide| message += "- #{guide.sub("reference/", "")}\n" }
49
+ message += "\n"
50
+ end
51
+
52
+ message
53
+ end
54
+
55
+ # Handle manifest loading errors with user-friendly messages
56
+ def handle_manifest_error(error)
57
+ case error.message
58
+ when /No .* guides found/
59
+ error.message
60
+ when /Permission denied/
61
+ format_error_message("Permission denied accessing guides. Check file permissions.")
62
+ when /No such file/
63
+ format_error_message("Guide files not found. Run '#{download_command}' to download guides.")
64
+ else
65
+ format_error_message("Error loading guides: #{error.message}")
66
+ end
67
+ end
68
+
69
+ # Handle guide loading errors
70
+ def handle_guide_loading_error(guide_name, error)
71
+ log(:error, "Error loading guide #{guide_name}: #{error.message}")
72
+
73
+ case error.message
74
+ when /Multiple guides found/
75
+ format_error_message(error.message)
76
+ when /Permission denied/
77
+ format_error_message("Permission denied reading guide '#{guide_name}'.")
78
+ when /No such file/
79
+ format_error_message("Guide file for '#{guide_name}' not found on disk.")
80
+ else
81
+ format_error_message("Error loading guide '#{guide_name}': #{error.message}")
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,100 @@
1
+ module RailsMcpServer
2
+ # Module for finding and matching guide files
3
+ module GuideFileFinder
4
+ protected
5
+
6
+ # Find guide file with exact and fuzzy matching
7
+ def find_guide_file(normalized_guide_name, manifest)
8
+ # Try exact matches first
9
+ possible_filenames = generate_possible_filenames(normalized_guide_name)
10
+
11
+ possible_filenames.each do |possible_filename|
12
+ if manifest["files"][possible_filename]
13
+ return [possible_filename, manifest["files"][possible_filename]]
14
+ end
15
+ end
16
+
17
+ # If not found, try fuzzy matching
18
+ matching_files = fuzzy_match_files(normalized_guide_name, manifest)
19
+
20
+ case matching_files.size
21
+ when 1
22
+ matching_files.first
23
+ when 0
24
+ [nil, nil]
25
+ else
26
+ matches = matching_files.map(&:first).map { |f| f.sub(".md", "") }.join(", ") # rubocop:disable Performance/ChainArrayAllocation
27
+ raise StandardError, "Multiple guides found matching '#{normalized_guide_name}': #{matches}. Please be more specific."
28
+ end
29
+ end
30
+
31
+ # Generate possible filename variations for exact matching
32
+ def generate_possible_filenames(normalized_guide_name)
33
+ possible_files = ["#{normalized_guide_name}.md"]
34
+
35
+ # Add section prefixes for frameworks that support them
36
+ if supports_sections?
37
+ possible_files += [
38
+ "handbook/#{normalized_guide_name}.md",
39
+ "reference/#{normalized_guide_name}.md"
40
+ ]
41
+ end
42
+
43
+ # Framework-specific filename generation can be overridden
44
+ possible_files += framework_specific_filenames(normalized_guide_name) if respond_to?(:framework_specific_filenames, true)
45
+
46
+ possible_files.uniq
47
+ end
48
+
49
+ # Perform fuzzy matching on guide files
50
+ def fuzzy_match_files(normalized_guide_name, manifest)
51
+ search_terms = generate_search_terms(normalized_guide_name)
52
+
53
+ manifest["files"].select do |file, _|
54
+ next false unless file.end_with?(".md")
55
+
56
+ file_matches_any_search_term?(file, search_terms)
57
+ end.to_a
58
+ end
59
+
60
+ # Generate search terms for fuzzy matching
61
+ def generate_search_terms(normalized_guide_name)
62
+ base_term = normalized_guide_name.split("/").last.downcase
63
+
64
+ [
65
+ base_term,
66
+ base_term.gsub(/[_-]/, ""),
67
+ base_term.gsub(/[_-]/, "_"),
68
+ base_term.gsub(/[_-]/, "-")
69
+ ].uniq
70
+ end
71
+
72
+ # Check if file matches any search term
73
+ def file_matches_any_search_term?(file, search_terms)
74
+ file_name_base = file.sub(".md", "").split("/").last.downcase
75
+ file_name_normalized = file_name_base.gsub(/[_-]/, "")
76
+
77
+ search_terms.any? do |term|
78
+ term_normalized = term.gsub(/[_-]/, "")
79
+
80
+ file_name_base.include?(term) ||
81
+ term.include?(file_name_base) ||
82
+ file_name_normalized.include?(term_normalized) ||
83
+ term_normalized.include?(file_name_normalized)
84
+ end
85
+ end
86
+
87
+ # Generate suggestions for similar guide names
88
+ def find_suggestions(normalized_guide_name, available_guides)
89
+ search_base = normalized_guide_name.split("/").last.downcase
90
+
91
+ available_guides.select do |guide|
92
+ guide_base = guide.split("/").last.downcase
93
+
94
+ guide_base.include?(search_base) ||
95
+ search_base.include?(guide_base) ||
96
+ guide_base.gsub(/[_-]/, "").include?(search_base.gsub(/[_-]/, ""))
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,65 @@
1
+ module RailsMcpServer
2
+ # Module defining the contract that guide resources must implement
3
+ module GuideFrameworkContract
4
+ def self.included(base)
5
+ base.extend(ClassMethods)
6
+ end
7
+
8
+ module ClassMethods
9
+ # Validate that required methods are implemented
10
+ def validate_contract!
11
+ required_methods = [:framework_name, :resource_directory, :download_command]
12
+
13
+ required_methods.each do |method|
14
+ unless method_defined?(method)
15
+ raise NotImplementedError, "#{self} must implement ##{method}"
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ protected
22
+
23
+ # Abstract methods that must be implemented by including classes
24
+ def framework_name
25
+ raise NotImplementedError, "#{self.class} must implement #framework_name"
26
+ end
27
+
28
+ def resource_directory
29
+ raise NotImplementedError, "#{self.class} must implement #resource_directory"
30
+ end
31
+
32
+ def download_command
33
+ raise NotImplementedError, "#{self.class} must implement #download_command"
34
+ end
35
+
36
+ # Optional methods with default implementations
37
+ def supports_sections?
38
+ false
39
+ end
40
+
41
+ # Optional method for list resources
42
+ def example_guides
43
+ []
44
+ end
45
+
46
+ # Optional framework-specific methods (can be overridden)
47
+ def framework_specific_filenames(normalized_guide_name)
48
+ []
49
+ end
50
+
51
+ def framework_specific_section(filename)
52
+ "Documentation"
53
+ end
54
+
55
+ # Utility method to check if this is a list resource
56
+ def list_resource?
57
+ respond_to?(:example_guides)
58
+ end
59
+
60
+ # Utility method to check if this is a single guide resource
61
+ def single_guide_resource?
62
+ respond_to?(:params) && params.key?(:guide_name)
63
+ end
64
+ end
65
+ end