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,182 @@
1
+ module RailsMcpServer
2
+ module Extensions
3
+ # Extension module to add URI templating capabilities to FastMcp::Resource
4
+ # Uses module prepending for clean method override behavior
5
+ module ResourceTemplating
6
+ # Class methods to be prepended to the singleton class
7
+ module ClassMethods
8
+ attr_reader :template_params
9
+
10
+ def variabilized_uri(params = {})
11
+ addressable_template.partial_expand(params).pattern
12
+ end
13
+
14
+ def addressable_template
15
+ @addressable_template ||= Addressable::Template.new(uri)
16
+ end
17
+
18
+ def template_variables
19
+ addressable_template.variables
20
+ end
21
+
22
+ def templated?
23
+ template_variables.any?
24
+ end
25
+
26
+ def non_templated?
27
+ !templated?
28
+ end
29
+
30
+ def match(uri)
31
+ addressable_template.match(uri)
32
+ end
33
+
34
+ def initialize_from_uri(uri)
35
+ new(params_from_uri(uri))
36
+ end
37
+
38
+ def params_from_uri(uri)
39
+ match(uri).mapping.transform_keys(&:to_sym)
40
+ end
41
+
42
+ def instance(uri = self.uri)
43
+ @instances ||= {}
44
+ @instances[uri] ||= begin
45
+ resource_class = Class.new(self)
46
+ params = params_from_uri(uri)
47
+ resource_class.instance_variable_set(:@params, params)
48
+
49
+ resource_class.define_singleton_method(:instance) do
50
+ @instance ||= begin
51
+ instance = new
52
+ instance.instance_variable_set(:@params, params)
53
+ instance
54
+ end
55
+ end
56
+
57
+ resource_class.instance
58
+ end
59
+ end
60
+
61
+ def params
62
+ @params || {}
63
+ end
64
+
65
+ def name
66
+ return resource_name if resource_name
67
+ super
68
+ end
69
+
70
+ def metadata
71
+ if templated?
72
+ {
73
+ uriTemplate: uri,
74
+ name: resource_name,
75
+ description: description,
76
+ mimeType: mime_type
77
+ }.compact
78
+ else
79
+ super
80
+ end
81
+ end
82
+ end
83
+
84
+ # Instance methods to be prepended
85
+ module InstanceMethods
86
+ def initialize
87
+ @params = self.class.params
88
+ super if defined?(super)
89
+ end
90
+
91
+ def params
92
+ @params || self.class.params
93
+ end
94
+
95
+ def name
96
+ self.class.resource_name
97
+ end
98
+ end
99
+
100
+ # Called when this module is prepended to a class
101
+ def self.prepended(base)
102
+ base.singleton_class.prepend(ClassMethods)
103
+ base.prepend(InstanceMethods)
104
+ end
105
+ end
106
+
107
+ # Main setup class for resource extensions
108
+ class ResourceExtensionSetup
109
+ class << self
110
+ def setup!
111
+ return if @setup_complete
112
+
113
+ ensure_dependencies_loaded!
114
+ apply_extensions!
115
+
116
+ @setup_complete = true
117
+ RailsMcpServer.log(:info, "FastMcp::Resource extensions loaded successfully")
118
+ rescue => e
119
+ RailsMcpServer.log(:error, "Failed to setup resource extensions: #{e.message}")
120
+ raise
121
+ end
122
+
123
+ def reset!
124
+ @setup_complete = false
125
+ end
126
+
127
+ def setup_complete?
128
+ @setup_complete || false
129
+ end
130
+
131
+ private
132
+
133
+ def ensure_dependencies_loaded!
134
+ # Check that FastMcp::Resource exists
135
+ unless defined?(FastMcp::Resource)
136
+ begin
137
+ require "fast-mcp"
138
+ rescue LoadError => e
139
+ raise LoadError, "fast-mcp gem is required but not available. Ensure it's in your Gemfile: #{e.message}"
140
+ end
141
+ end
142
+
143
+ # Verify the expected interface exists
144
+ unless FastMcp::Resource.respond_to?(:uri)
145
+ raise "FastMcp::Resource doesn't have expected interface. Check fast-mcp gem version."
146
+ end
147
+
148
+ # Load addressable template dependency
149
+ begin
150
+ require "addressable/template"
151
+ rescue LoadError => e
152
+ raise LoadError, "addressable gem is required for URI templating: #{e.message}"
153
+ end
154
+
155
+ # Optional: Version checking
156
+ if defined?(FastMcp::VERSION)
157
+ version = Gem::Version.new(FastMcp::VERSION)
158
+ minimum_version = Gem::Version.new("1.4.0")
159
+
160
+ if version < minimum_version
161
+ RailsMcpServer.log(:warn, "FastMcp version #{FastMcp::VERSION} detected. Extensions tested with #{minimum_version}+")
162
+ end
163
+ end
164
+ end
165
+
166
+ def apply_extensions!
167
+ # Apply extensions to FastMcp::Resource
168
+ FastMcp::Resource.prepend(ResourceTemplating)
169
+
170
+ # Also ensure our BaseResource gets the extensions
171
+ if defined?(RailsMcpServer::BaseResource)
172
+ # BaseResource already inherits from FastMcp::Resource, so it gets extensions automatically
173
+ RailsMcpServer.log(:debug, "BaseResource will inherit templating extensions")
174
+ end
175
+
176
+ # Setup server extensions as well
177
+ RailsMcpServer::Extensions::ServerExtensionSetup.setup!
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,333 @@
1
+ module RailsMcpServer
2
+ module Extensions
3
+ # Extension module to add any missing templated resource support to FastMcp::Server
4
+ # This version of the server already has most templated resource functionality
5
+ module ServerTemplating
6
+ # Instance methods to be prepended
7
+ module InstanceMethods
8
+ # The target server already has most functionality, but we can add defensive checks
9
+ def read_resource(uri)
10
+ # Handle both hash-based and array-based resource storage
11
+ if @resources.is_a?(Hash)
12
+ # First try exact match (hash lookup)
13
+ exact_match = @resources[uri]
14
+ return exact_match if exact_match
15
+
16
+ # Then try templated resource matching
17
+ @resources.values.find { |r| r.respond_to?(:match) && r.match(uri) }
18
+ else
19
+ # Array-based storage (original target server behavior)
20
+ resource = @resources.find { |r| r.respond_to?(:match) && r.match(uri) }
21
+
22
+ # Fallback: if no templated match, try exact URI match for backward compatibility
23
+ resource ||= @resources.find { |r| r.respond_to?(:uri) && r.uri == uri }
24
+
25
+ resource
26
+ end
27
+ end
28
+
29
+ # Add some defensive programming to handle_resources_read
30
+ def handle_resources_read(params, id)
31
+ uri = params["uri"]
32
+
33
+ return send_error(-32_602, "Invalid params: missing resource URI", id) unless uri
34
+
35
+ @logger.debug("Looking for resource with URI: #{uri}")
36
+
37
+ begin
38
+ resource = read_resource(uri)
39
+ return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
40
+
41
+ # Defensive check for templated method
42
+ is_templated = resource.respond_to?(:templated?) ? resource.templated? : false
43
+ @logger.debug("Found resource: #{resource.respond_to?(:resource_name) ? resource.resource_name : resource.name}, templated: #{is_templated}")
44
+
45
+ base_content = {uri: uri}
46
+ base_content[:mimeType] = resource.mime_type if resource.mime_type
47
+
48
+ # Handle both templated and non-templated resources
49
+ resource_instance = if is_templated && resource.respond_to?(:instance)
50
+ resource.instance(uri)
51
+ else
52
+ # Fallback for non-templated resources or resources without instance method
53
+ resource.respond_to?(:instance) ? resource.instance : resource
54
+ end
55
+
56
+ # Defensive check for params method
57
+ if resource_instance.respond_to?(:params)
58
+ @logger.debug("Resource instance params: #{resource_instance.params.inspect}")
59
+ end
60
+
61
+ result = if resource_instance.respond_to?(:binary?) && resource_instance.binary?
62
+ {
63
+ contents: [base_content.merge(blob: Base64.strict_encode64(resource_instance.content))]
64
+ }
65
+ else
66
+ {
67
+ contents: [base_content.merge(text: resource_instance.content)]
68
+ }
69
+ end
70
+
71
+ send_result(result, id)
72
+ rescue => e
73
+ @logger.error("Error reading resource: #{e.message}")
74
+ @logger.error(e.backtrace.join("\n"))
75
+ send_error(-32_600, "Internal error reading resource: #{e.message}", id)
76
+ end
77
+ end
78
+
79
+ # The target server already has these methods, but we can add defensive checks
80
+ def handle_resources_list(id)
81
+ # Handle both hash-based and array-based resource storage
82
+ resources_collection = @resources.is_a?(Hash) ? @resources.values : @resources
83
+
84
+ resources_list = resources_collection.select do |resource|
85
+ !resource.respond_to?(:templated?) || resource.non_templated?
86
+ end.map(&:metadata) # rubocop:disable Performance/ChainArrayAllocation
87
+
88
+ send_result({resources: resources_list}, id)
89
+ end
90
+
91
+ def handle_resources_templates_list(id)
92
+ @logger.debug("Handling resources/templates/list request")
93
+
94
+ # Handle both hash-based and array-based resource storage
95
+ resources_collection = @resources.is_a?(Hash) ? @resources.values : @resources
96
+
97
+ templated_resources_list = resources_collection.select do |resource|
98
+ resource.respond_to?(:templated?) && resource.templated?
99
+ end.map do |resource| # rubocop:disable Performance/ChainArrayAllocation
100
+ metadata = resource.metadata
101
+ @logger.debug("Template resource metadata: #{metadata}")
102
+ metadata
103
+ end
104
+
105
+ @logger.info("Returning #{templated_resources_list.length} templated resources")
106
+ send_result({resourceTemplates: templated_resources_list}, id)
107
+ end
108
+
109
+ # Override handle_request to ensure resources/templates/list endpoint is available
110
+ def handle_request(*args)
111
+ # Extract arguments - handle different signatures
112
+ if args.length == 2
113
+ json_str, headers = args
114
+ headers ||= {}
115
+ else
116
+ json_str = args[0]
117
+ headers = {}
118
+ end
119
+
120
+ begin
121
+ request = JSON.parse(json_str)
122
+ rescue JSON::ParserError, TypeError
123
+ return send_error(-32_600, "Invalid Request", nil)
124
+ end
125
+
126
+ @logger.debug("Received request: #{request.inspect}")
127
+
128
+ # Check if it's a valid JSON-RPC 2.0 request
129
+ unless request["jsonrpc"] == "2.0" && request["method"]
130
+ return send_error(-32_600, "Invalid Request", request["id"])
131
+ end
132
+
133
+ method = request["method"]
134
+ params = request["params"] || {}
135
+ id = request["id"]
136
+
137
+ # Handle the resources/templates/list endpoint specifically since it might not exist in original
138
+ if method == "resources/templates/list"
139
+ @logger.debug("Handling resources/templates/list via extension")
140
+ return handle_resources_templates_list(id)
141
+ end
142
+
143
+ # For all other methods, call the original implementation
144
+ begin
145
+ super
146
+ rescue NoMethodError => e
147
+ # If super doesn't work, provide our own fallback
148
+ @logger.debug("Original handle_request not available, using fallback: #{e.message}")
149
+ handle_request_fallback(method, params, id, headers)
150
+ end
151
+ rescue => e
152
+ @logger.error("Error handling request: #{e.message}, #{e.backtrace.join("\n")}")
153
+ send_error(-32_600, "Internal error: #{e.message}", id)
154
+ end
155
+
156
+ private
157
+
158
+ def handle_request_fallback(method, params, id, headers)
159
+ @logger.debug("Using fallback handler for method: #{method}")
160
+
161
+ case method
162
+ when "ping"
163
+ send_result({}, id)
164
+ when "initialize"
165
+ handle_initialize(params, id)
166
+ when "notifications/initialized"
167
+ handle_initialized_notification
168
+ when "tools/list"
169
+ handle_tools_list(id)
170
+ when "tools/call"
171
+ # Handle different method signatures for tools/call
172
+ if method(:handle_tools_call).arity == 3
173
+ handle_tools_call(params, headers, id)
174
+ else
175
+ handle_tools_call(params, id)
176
+ end
177
+ when "resources/list"
178
+ handle_resources_list(id)
179
+ when "resources/templates/list"
180
+ handle_resources_templates_list(id)
181
+ when "resources/read"
182
+ handle_resources_read(params, id)
183
+ when "resources/subscribe"
184
+ handle_resources_subscribe(params, id)
185
+ when "resources/unsubscribe"
186
+ handle_resources_unsubscribe(params, id)
187
+ else
188
+ send_error(-32_601, "Method not found: #{method}", id)
189
+ end
190
+ end
191
+
192
+ # Add defensive programming to resource subscription methods
193
+ def handle_resources_subscribe(params, id)
194
+ return unless @client_initialized
195
+
196
+ uri = params["uri"]
197
+
198
+ unless uri
199
+ send_error(-32_602, "Invalid params: missing resource URI", id)
200
+ return
201
+ end
202
+
203
+ # Use the read_resource method which supports templated resources
204
+ resource = read_resource(uri)
205
+ return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
206
+
207
+ # Add to subscriptions
208
+ @resource_subscriptions[uri] ||= []
209
+ @resource_subscriptions[uri] << id
210
+
211
+ send_result({subscribed: true}, id)
212
+ end
213
+
214
+ # Enhanced logging for resource registration
215
+ def register_resource(resource)
216
+ # Handle both hash-based and array-based resource storage
217
+ if @resources.is_a?(Hash)
218
+ @resources[resource.uri] = resource
219
+ else
220
+ @resources << resource
221
+ end
222
+
223
+ resource_name = if resource.respond_to?(:resource_name)
224
+ resource.resource_name
225
+ else
226
+ (resource.respond_to?(:name) ? resource.name : "Unknown")
227
+ end
228
+ is_templated = resource.respond_to?(:templated?) ? resource.templated? : false
229
+
230
+ @logger.debug("Registered resource: #{resource_name} (#{resource.uri}) - Templated: #{is_templated}")
231
+ resource.server = self if resource.respond_to?(:server=)
232
+
233
+ # Notify subscribers about the list change
234
+ notify_resource_list_changed if @transport
235
+
236
+ resource
237
+ end
238
+ end
239
+
240
+ # Called when this module is prepended to a class
241
+ def self.prepended(base)
242
+ base.prepend(InstanceMethods)
243
+ end
244
+ end
245
+
246
+ # Setup class for server extensions
247
+ class ServerExtensionSetup
248
+ class << self
249
+ def setup!
250
+ return if @setup_complete
251
+
252
+ ensure_dependencies_loaded!
253
+ check_server_compatibility!
254
+ apply_extensions_if_needed!
255
+
256
+ @setup_complete = true
257
+ RailsMcpServer.log(:info, "FastMcp::Server extensions checked and applied if needed")
258
+ rescue => e
259
+ RailsMcpServer.log(:error, "Failed to setup server extensions: #{e.message}")
260
+ raise
261
+ end
262
+
263
+ def reset!
264
+ @setup_complete = false
265
+ end
266
+
267
+ def setup_complete?
268
+ @setup_complete || false
269
+ end
270
+
271
+ private
272
+
273
+ def ensure_dependencies_loaded!
274
+ # Check that FastMcp::Server exists
275
+ unless defined?(FastMcp::Server)
276
+ begin
277
+ require "fast-mcp"
278
+ rescue LoadError => e
279
+ raise LoadError, "fast-mcp gem is required but not available: #{e.message}"
280
+ end
281
+ end
282
+
283
+ # Verify the expected interface exists
284
+ unless FastMcp::Server.instance_methods.include?(:read_resource)
285
+ raise "FastMcp::Server doesn't have expected read_resource method. Check fast-mcp gem version."
286
+ end
287
+
288
+ # Check handle_request method signature
289
+ handle_request_method = FastMcp::Server.instance_method(:handle_request)
290
+ arity = handle_request_method.arity
291
+ RailsMcpServer.log(:debug, "FastMcp::Server#handle_request arity: #{arity}")
292
+
293
+ # Check if resources/templates/list is already supported
294
+ test_server = FastMcp::Server.new(name: "test", version: "1.0.0")
295
+ has_templates_method = test_server.respond_to?(:handle_resources_templates_list)
296
+ RailsMcpServer.log(:debug, "Original server has handle_resources_templates_list: #{has_templates_method}")
297
+ end
298
+
299
+ def check_server_compatibility!
300
+ # Check if the server already has templated resource support
301
+ server_instance = FastMcp::Server.new(name: "test", version: "1.0.0")
302
+
303
+ @server_has_templates = server_instance.respond_to?(:handle_resources_templates_list)
304
+ @server_has_advanced_read = begin
305
+ # Check if read_resource method body includes 'match'
306
+ method_source = FastMcp::Server.instance_method(:read_resource).source_location
307
+ method_source ? true : false
308
+ rescue
309
+ false
310
+ end
311
+
312
+ RailsMcpServer.log(:debug, "Server template support detected: #{@server_has_templates}")
313
+ RailsMcpServer.log(:debug, "Server advanced read support detected: #{@server_has_advanced_read}")
314
+ end
315
+
316
+ def apply_extensions_if_needed!
317
+ # Always apply extensions to ensure resources/templates/list endpoint is available
318
+ # The MCP inspector error shows this endpoint is missing
319
+ RailsMcpServer.log(:info, "Applying server extensions to ensure full MCP compliance")
320
+ FastMcp::Server.prepend(ServerTemplating)
321
+
322
+ # Verify the extension was applied by checking if our methods are available
323
+ test_server = FastMcp::Server.new(name: "test", version: "1.0.0")
324
+ has_templates_list = test_server.respond_to?(:handle_resources_templates_list)
325
+ RailsMcpServer.log(:info, "Server extension verification - handle_resources_templates_list available: #{has_templates_list}")
326
+ rescue => e
327
+ RailsMcpServer.log(:error, "Error applying server extensions: #{e.message}")
328
+ raise
329
+ end
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,143 @@
1
+ require "fileutils"
2
+ require "digest"
3
+ require "yaml"
4
+
5
+ module RailsMcpServer
6
+ class ResourceBase
7
+ attr_reader :resource_name, :config_dir, :resource_folder, :manifest_file
8
+
9
+ def initialize(resource_name, config_dir:, force: false, verbose: false)
10
+ @resource_name = resource_name.to_s
11
+ @config_dir = config_dir
12
+ @force = force
13
+ @verbose = verbose
14
+ setup_paths
15
+ end
16
+
17
+ protected
18
+
19
+ def setup_paths
20
+ @resource_folder = File.join(@config_dir, "resources", @resource_name)
21
+ @manifest_file = File.join(@resource_folder, "manifest.yaml")
22
+ end
23
+
24
+ def setup_directories
25
+ FileUtils.mkdir_p(@resource_folder)
26
+ end
27
+
28
+ def load_manifest
29
+ @manifest = if File.exist?(@manifest_file)
30
+ YAML.load_file(@manifest_file)
31
+ else
32
+ create_manifest
33
+ end
34
+ end
35
+
36
+ def save_manifest
37
+ @manifest["updated_at"] = Time.now.to_s
38
+ File.write(@manifest_file, @manifest.to_yaml)
39
+ end
40
+
41
+ def file_unchanged?(filename, file_path)
42
+ return false unless File.exist?(file_path)
43
+ current_hash = file_hash(file_path)
44
+ @manifest["files"][filename] && @manifest["files"][filename]["hash"] == current_hash
45
+ end
46
+
47
+ def save_file_to_manifest(filename, file_path, additional_data = {})
48
+ metadata = extract_metadata(File.read(file_path), filename)
49
+
50
+ @manifest["files"][filename] = {
51
+ "hash" => file_hash(file_path),
52
+ "size" => File.size(file_path)
53
+ }.merge(timestamp_key => Time.now.to_s)
54
+ .merge(additional_data)
55
+ .merge(metadata)
56
+ end
57
+
58
+ def extract_metadata(content, filename = nil)
59
+ metadata = {}
60
+
61
+ title = find_title(content) || (filename ? humanize_filename(filename) : nil)
62
+ metadata["title"] = title if title
63
+
64
+ description = find_description(content)
65
+ metadata["description"] = description if description && !description.empty?
66
+
67
+ metadata
68
+ end
69
+
70
+ def find_title(content)
71
+ lines = content.lines
72
+
73
+ # H1 header
74
+ lines.each do |line|
75
+ return $1.strip if line.strip =~ /^#\s+(.+)$/
76
+ end
77
+
78
+ # Underlined title
79
+ lines.each_with_index do |line, index|
80
+ next if index >= lines.length - 1
81
+ return line.strip if /^=+$/.match?(lines[index + 1].strip)
82
+ end
83
+
84
+ nil
85
+ end
86
+
87
+ def find_description(content)
88
+ # Clean content
89
+ clean = content.dup
90
+ clean = clean.sub(/^---\s*\n.*?\n---\s*\n/m, "") # Remove YAML frontmatter
91
+ clean = clean.gsub(/^#\s+.*?\n/, "") # Remove H1 headers
92
+ clean = clean.gsub(/^.+\n=+\s*\n/, "") # Remove underlined titles
93
+ clean = clean.strip.gsub(/\n+/, " ").gsub(/\s+/, " ")
94
+
95
+ return "" if clean.empty?
96
+
97
+ if clean.length > 200
98
+ truncate_at = clean.rindex(" ", 200) || 200
99
+ clean[0...truncate_at] + "..."
100
+ else
101
+ clean
102
+ end
103
+ end
104
+
105
+ def humanize_filename(filename)
106
+ base = File.basename(filename, File.extname(filename))
107
+
108
+ title = base.gsub(/[_-]/, " ")
109
+ .gsub(/^\d+[.\-_\s]*/, "")
110
+ .split(" ").map(&:capitalize).join(" ")
111
+
112
+ # Common abbreviations
113
+ replacements = {
114
+ /\bApi\b/ => "API", /\bHtml\b/ => "HTML", /\bCss\b/ => "CSS",
115
+ /\bJs\b/ => "JavaScript", /\bUi\b/ => "UI", /\bUrl\b/ => "URL",
116
+ /\bRest\b/ => "REST", /\bJson\b/ => "JSON", /\bXml\b/ => "XML",
117
+ /\bSql\b/ => "SQL"
118
+ }
119
+
120
+ replacements.each { |pattern, replacement| title = title.gsub(pattern, replacement) }
121
+
122
+ title.strip.empty? ? "Untitled Guide" : title
123
+ end
124
+
125
+ def file_hash(file_path)
126
+ Digest::SHA256.file(file_path).hexdigest
127
+ end
128
+
129
+ def log(message, newline: true)
130
+ return unless @verbose
131
+ newline ? puts(message) : print(message)
132
+ end
133
+
134
+ # Abstract methods to be implemented by subclasses
135
+ def create_manifest
136
+ raise NotImplementedError
137
+ end
138
+
139
+ def timestamp_key
140
+ raise NotImplementedError
141
+ end
142
+ end
143
+ end