rails-ai-context 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +23 -0
  4. data/CHANGELOG.md +26 -0
  5. data/LICENSE +21 -0
  6. data/README.md +212 -0
  7. data/Rakefile +8 -0
  8. data/exe/rails-ai-context +67 -0
  9. data/lib/generators/rails_ai_context/install/install_generator.rb +85 -0
  10. data/lib/rails-ai-context.rb +5 -0
  11. data/lib/rails_ai_context/configuration.rb +52 -0
  12. data/lib/rails_ai_context/engine.rb +21 -0
  13. data/lib/rails_ai_context/introspector.rb +61 -0
  14. data/lib/rails_ai_context/introspectors/convention_detector.rb +125 -0
  15. data/lib/rails_ai_context/introspectors/gem_introspector.rb +128 -0
  16. data/lib/rails_ai_context/introspectors/job_introspector.rb +82 -0
  17. data/lib/rails_ai_context/introspectors/model_introspector.rb +163 -0
  18. data/lib/rails_ai_context/introspectors/route_introspector.rb +83 -0
  19. data/lib/rails_ai_context/introspectors/schema_introspector.rb +143 -0
  20. data/lib/rails_ai_context/serializers/context_file_serializer.rb +60 -0
  21. data/lib/rails_ai_context/serializers/json_serializer.rb +19 -0
  22. data/lib/rails_ai_context/serializers/markdown_serializer.rb +158 -0
  23. data/lib/rails_ai_context/server.rb +90 -0
  24. data/lib/rails_ai_context/tasks/rails_ai_context.rake +89 -0
  25. data/lib/rails_ai_context/tools/base_tool.rb +37 -0
  26. data/lib/rails_ai_context/tools/get_conventions.rb +87 -0
  27. data/lib/rails_ai_context/tools/get_gems.rb +49 -0
  28. data/lib/rails_ai_context/tools/get_model_details.rb +103 -0
  29. data/lib/rails_ai_context/tools/get_routes.rb +50 -0
  30. data/lib/rails_ai_context/tools/get_schema.rb +93 -0
  31. data/lib/rails_ai_context/tools/search_code.rb +111 -0
  32. data/lib/rails_ai_context/version.rb +5 -0
  33. data/lib/rails_ai_context.rb +74 -0
  34. data/rails-ai-context.gemspec +52 -0
  35. metadata +223 -0
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Tools
5
+ class GetGems < BaseTool
6
+ tool_name "rails_get_gems"
7
+ description "Analyze the app's Gemfile.lock to identify notable gems, their categories (auth, jobs, frontend, API, database, testing, deploy), and what they mean for the app's architecture."
8
+
9
+ input_schema(
10
+ properties: {
11
+ category: {
12
+ type: "string",
13
+ enum: %w[auth jobs frontend api database files testing deploy all],
14
+ description: "Filter by category. Default: all."
15
+ }
16
+ }
17
+ )
18
+
19
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
20
+
21
+ def self.call(category: "all", server_context: nil)
22
+ gems = cached_context[:gems]
23
+ return text_response("Gem introspection failed: #{gems[:error]}") if gems[:error]
24
+
25
+ notable = gems[:notable_gems] || []
26
+ notable = notable.select { |g| g[:category] == category } unless category == "all"
27
+
28
+ lines = ["# Gem Analysis", ""]
29
+ lines << "Total gems: #{gems[:total_gems]}"
30
+ lines << ""
31
+
32
+ if notable.any?
33
+ current_cat = nil
34
+ notable.sort_by { |g| [g[:category], g[:name]] }.each do |g|
35
+ if g[:category] != current_cat
36
+ current_cat = g[:category]
37
+ lines << "" << "## #{current_cat.capitalize}"
38
+ end
39
+ lines << "- **#{g[:name]}** (#{g[:version]}): #{g[:note]}"
40
+ end
41
+ else
42
+ lines << "_No notable gems found#{" in category '#{category}'" unless category == 'all'}._"
43
+ end
44
+
45
+ text_response(lines.join("\n"))
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Tools
5
+ class GetModelDetails < BaseTool
6
+ tool_name "rails_get_model_details"
7
+ description "Get detailed information about a specific ActiveRecord model including associations, validations, scopes, enums, callbacks, and concerns. If no model specified, lists all available models."
8
+
9
+ input_schema(
10
+ properties: {
11
+ model: {
12
+ type: "string",
13
+ description: "Model class name (e.g. 'User', 'Post'). Omit to list all models."
14
+ }
15
+ }
16
+ )
17
+
18
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
19
+
20
+ def self.call(model: nil, server_context: nil)
21
+ models = cached_context[:models]
22
+ return text_response("Model introspection failed: #{models[:error]}") if models.is_a?(Hash) && models[:error]
23
+
24
+ unless model
25
+ model_list = models.keys.sort.map { |m| "- #{m}" }.join("\n")
26
+ return text_response("# Available models (#{models.size})\n\n#{model_list}")
27
+ end
28
+
29
+ # Support both "User" and "user" lookups
30
+ key = models.keys.find { |k| k.downcase == model.downcase } || model
31
+ data = models[key]
32
+ return text_response("Model '#{model}' not found. Available: #{models.keys.sort.join(', ')}") unless data
33
+ return text_response("Error inspecting #{key}: #{data[:error]}") if data[:error]
34
+
35
+ text_response(format_model(key, data))
36
+ end
37
+
38
+ private_class_method def self.format_model(name, data)
39
+ lines = ["# #{name}", ""]
40
+ lines << "**Table:** `#{data[:table_name]}`" if data[:table_name]
41
+
42
+ # Associations
43
+ if data[:associations]&.any?
44
+ lines << "" << "## Associations"
45
+ data[:associations].each do |a|
46
+ detail = "- `#{a[:type]}` **#{a[:name]}**"
47
+ detail += " (class: #{a[:class_name]})" if a[:class_name] && a[:class_name] != a[:name].to_s.classify
48
+ detail += " through: #{a[:through]}" if a[:through]
49
+ detail += " [polymorphic]" if a[:polymorphic]
50
+ detail += " dependent: #{a[:dependent]}" if a[:dependent]
51
+ lines << detail
52
+ end
53
+ end
54
+
55
+ # Validations
56
+ if data[:validations]&.any?
57
+ lines << "" << "## Validations"
58
+ data[:validations].each do |v|
59
+ attrs = v[:attributes].join(", ")
60
+ opts = v[:options]&.any? ? " (#{v[:options].map { |k, val| "#{k}: #{val}" }.join(', ')})" : ""
61
+ lines << "- `#{v[:kind]}` on #{attrs}#{opts}"
62
+ end
63
+ end
64
+
65
+ # Enums
66
+ if data[:enums]&.any?
67
+ lines << "" << "## Enums"
68
+ data[:enums].each do |attr, values|
69
+ lines << "- `#{attr}`: #{values.join(', ')}"
70
+ end
71
+ end
72
+
73
+ # Scopes
74
+ if data[:scopes]&.any?
75
+ lines << "" << "## Scopes"
76
+ lines << data[:scopes].map { |s| "- `#{s}`" }.join("\n")
77
+ end
78
+
79
+ # Callbacks
80
+ if data[:callbacks]&.any?
81
+ lines << "" << "## Callbacks"
82
+ data[:callbacks].each do |type, methods|
83
+ lines << "- `#{type}`: #{methods.join(', ')}"
84
+ end
85
+ end
86
+
87
+ # Concerns
88
+ if data[:concerns]&.any?
89
+ lines << "" << "## Concerns"
90
+ lines << data[:concerns].map { |c| "- #{c}" }.join("\n")
91
+ end
92
+
93
+ # Key instance methods
94
+ if data[:instance_methods]&.any?
95
+ lines << "" << "## Key instance methods"
96
+ lines << data[:instance_methods].first(15).map { |m| "- `#{m}`" }.join("\n")
97
+ end
98
+
99
+ lines.join("\n")
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Tools
5
+ class GetRoutes < BaseTool
6
+ tool_name "rails_get_routes"
7
+ description "Get all routes for the Rails app, optionally filtered by controller. Shows HTTP verb, path, controller#action, and route name."
8
+
9
+ input_schema(
10
+ properties: {
11
+ controller: {
12
+ type: "string",
13
+ description: "Optional: filter routes by controller name (e.g. 'users', 'api/v1/posts')."
14
+ }
15
+ }
16
+ )
17
+
18
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
19
+
20
+ def self.call(controller: nil, server_context: nil)
21
+ routes = cached_context[:routes]
22
+ return text_response("Route introspection failed: #{routes[:error]}") if routes[:error]
23
+
24
+ by_controller = routes[:by_controller]
25
+
26
+ if controller
27
+ filtered = by_controller.select { |k, _| k.include?(controller) }
28
+ return text_response("No routes found for '#{controller}'. Controllers: #{by_controller.keys.sort.join(', ')}") if filtered.empty?
29
+ by_controller = filtered
30
+ end
31
+
32
+ lines = ["# Routes (#{routes[:total_routes]} total)", ""]
33
+ lines << "| Verb | Path | Controller#Action | Name |"
34
+ lines << "|------|------|-------------------|------|"
35
+
36
+ by_controller.sort.each do |ctrl, actions|
37
+ actions.each do |r|
38
+ lines << "| #{r[:verb]} | `#{r[:path]}` | #{ctrl}##{r[:action]} | #{r[:name] || '-'} |"
39
+ end
40
+ end
41
+
42
+ if routes[:api_namespaces]&.any?
43
+ lines << "" << "## API namespaces: #{routes[:api_namespaces].join(', ')}"
44
+ end
45
+
46
+ text_response(lines.join("\n"))
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Tools
5
+ class GetSchema < BaseTool
6
+ tool_name "rails_get_schema"
7
+ description "Get the database schema for the Rails app including tables, columns, indexes, and foreign keys. Optionally filter by table name."
8
+
9
+ input_schema(
10
+ properties: {
11
+ table: {
12
+ type: "string",
13
+ description: "Optional: specific table name to inspect. Omit for full schema."
14
+ },
15
+ format: {
16
+ type: "string",
17
+ enum: %w[json markdown],
18
+ description: "Output format. Default: markdown."
19
+ }
20
+ }
21
+ )
22
+
23
+ annotations(
24
+ read_only_hint: true,
25
+ destructive_hint: false,
26
+ idempotent_hint: true,
27
+ open_world_hint: false
28
+ )
29
+
30
+ def self.call(table: nil, format: "markdown", server_context: nil)
31
+ schema = cached_context[:schema]
32
+ return text_response("Schema introspection not available: #{schema[:error]}") if schema[:error]
33
+
34
+ if table
35
+ table_data = schema.dig(:tables, table)
36
+ return text_response("Table '#{table}' not found. Available: #{schema[:tables].keys.join(', ')}") unless table_data
37
+
38
+ output = format == "json" ? table_data.to_json : format_table_markdown(table, table_data)
39
+ else
40
+ output = format == "json" ? schema.to_json : format_schema_markdown(schema)
41
+ end
42
+
43
+ text_response(output)
44
+ end
45
+
46
+ private_class_method def self.format_table_markdown(name, data)
47
+ lines = ["## Table: #{name}", ""]
48
+ lines << "| Column | Type | Nullable | Default |"
49
+ lines << "|--------|------|----------|---------|"
50
+
51
+ data[:columns].each do |col|
52
+ lines << "| #{col[:name]} | #{col[:type]} | #{col[:null] ? 'yes' : 'no'} | #{col[:default] || '-'} |"
53
+ end
54
+
55
+ if data[:indexes]&.any?
56
+ lines << "" << "### Indexes"
57
+ data[:indexes].each do |idx|
58
+ unique = idx[:unique] ? " (unique)" : ""
59
+ lines << "- `#{idx[:name]}` on (#{Array(idx[:columns]).join(', ')})#{unique}"
60
+ end
61
+ end
62
+
63
+ if data[:foreign_keys]&.any?
64
+ lines << "" << "### Foreign keys"
65
+ data[:foreign_keys].each do |fk|
66
+ lines << "- `#{fk[:column]}` → `#{fk[:to_table]}.#{fk[:primary_key]}`"
67
+ end
68
+ end
69
+
70
+ lines.join("\n")
71
+ end
72
+
73
+ private_class_method def self.format_schema_markdown(schema)
74
+ lines = [
75
+ "# Database Schema",
76
+ "",
77
+ "- Adapter: #{schema[:adapter]}",
78
+ "- Tables: #{schema[:total_tables]}",
79
+ ""
80
+ ]
81
+
82
+ schema[:tables].each do |name, data|
83
+ cols = data[:columns].map { |c| "#{c[:name]}:#{c[:type]}" }.join(", ")
84
+ lines << "### #{name}"
85
+ lines << cols
86
+ lines << ""
87
+ end
88
+
89
+ lines.join("\n")
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Tools
5
+ class SearchCode < BaseTool
6
+ tool_name "rails_search_code"
7
+ description "Search the Rails codebase for a pattern using ripgrep (rg) or Ruby fallback. Returns matching lines with file paths and line numbers. Useful for finding usages, implementations, and patterns."
8
+
9
+ input_schema(
10
+ properties: {
11
+ pattern: {
12
+ type: "string",
13
+ description: "Search pattern (regex supported)."
14
+ },
15
+ path: {
16
+ type: "string",
17
+ description: "Subdirectory to search in (e.g. 'app/models', 'config'). Default: entire app."
18
+ },
19
+ file_type: {
20
+ type: "string",
21
+ description: "Filter by file extension (e.g. 'rb', 'js', 'erb'). Default: all files."
22
+ },
23
+ max_results: {
24
+ type: "integer",
25
+ description: "Maximum number of results. Default: 30."
26
+ }
27
+ },
28
+ required: ["pattern"]
29
+ )
30
+
31
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
32
+
33
+ def self.call(pattern:, path: nil, file_type: nil, max_results: 30, server_context: nil)
34
+ root = Rails.root.to_s
35
+ search_path = path ? File.join(root, path) : root
36
+
37
+ unless Dir.exist?(search_path)
38
+ return text_response("Path not found: #{path}")
39
+ end
40
+
41
+ results = if ripgrep_available?
42
+ search_with_ripgrep(pattern, search_path, file_type, max_results, root)
43
+ else
44
+ search_with_ruby(pattern, search_path, file_type, max_results, root)
45
+ end
46
+
47
+ if results.empty?
48
+ return text_response("No results found for '#{pattern}' in #{path || 'app'}.")
49
+ end
50
+
51
+ output = results.map { |r| "#{r[:file]}:#{r[:line_number]}: #{r[:content].strip}" }.join("\n")
52
+ header = "# Search: `#{pattern}`\n**#{results.size} results**#{" in #{path}" if path}\n\n```\n"
53
+ footer = "\n```"
54
+
55
+ text_response("#{header}#{output}#{footer}")
56
+ end
57
+
58
+ private_class_method def self.ripgrep_available?
59
+ @rg_available ||= system("which rg > /dev/null 2>&1")
60
+ end
61
+
62
+ private_class_method def self.search_with_ripgrep(pattern, search_path, file_type, max_results, root)
63
+ excluded = RailsAiContext.configuration.excluded_paths.map { |p| "--glob=!#{p}" }.join(" ")
64
+ type_flag = file_type ? "--type-add 'custom:*.#{file_type}' --type custom" : ""
65
+
66
+ cmd = "rg --no-heading --line-number --max-count #{max_results} #{excluded} #{type_flag} #{Shellwords.escape(pattern)} #{Shellwords.escape(search_path)} 2>/dev/null"
67
+
68
+ output = `#{cmd}`
69
+ parse_rg_output(output, root)
70
+ rescue => e
71
+ [{ file: "error", line_number: 0, content: e.message }]
72
+ end
73
+
74
+ private_class_method def self.search_with_ruby(pattern, search_path, file_type, max_results, root)
75
+ results = []
76
+ regex = Regexp.new(pattern, Regexp::IGNORECASE)
77
+ glob = file_type ? "**/*.#{file_type}" : "**/*.{rb,js,erb,yml,yaml,json}"
78
+ excluded = RailsAiContext.configuration.excluded_paths
79
+
80
+ Dir.glob(File.join(search_path, glob)).each do |file|
81
+ relative = file.sub("#{root}/", "")
82
+ next if excluded.any? { |ex| relative.start_with?(ex) }
83
+
84
+ File.readlines(file).each_with_index do |line, idx|
85
+ if line.match?(regex)
86
+ results << { file: relative, line_number: idx + 1, content: line }
87
+ return results if results.size >= max_results
88
+ end
89
+ end
90
+ rescue => _e
91
+ next # Skip binary/unreadable files
92
+ end
93
+
94
+ results
95
+ end
96
+
97
+ private_class_method def self.parse_rg_output(output, root)
98
+ output.lines.filter_map do |line|
99
+ match = line.match(/^(.+?):(\d+):(.*)$/)
100
+ next unless match
101
+
102
+ {
103
+ file: match[1].sub("#{root}/", ""),
104
+ line_number: match[2].to_i,
105
+ content: match[3]
106
+ }
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails_ai_context/version"
4
+
5
+ module RailsAiContext
6
+ class Error < StandardError; end
7
+ class ConfigurationError < Error; end
8
+ class IntrospectionError < Error; end
9
+
10
+ class << self
11
+ # Global configuration
12
+ attr_writer :configuration
13
+
14
+ def configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ def configure
19
+ yield(configuration)
20
+ end
21
+
22
+ # Quick access to introspect the current Rails app
23
+ # Returns a hash of all discovered context
24
+ def introspect(app = nil)
25
+ app ||= Rails.application
26
+ Introspector.new(app).call
27
+ end
28
+
29
+ # Generate context files (CLAUDE.md, .cursorrules, etc.)
30
+ def generate_context(app = nil, format: :all)
31
+ app ||= Rails.application
32
+ context = introspect(app)
33
+ Serializers::ContextFileSerializer.new(context, format: format).call
34
+ end
35
+
36
+ # Start the MCP server programmatically
37
+ def start_mcp_server(app = nil, transport: :stdio)
38
+ app ||= Rails.application
39
+ Server.new(app, transport: transport).start
40
+ end
41
+ end
42
+ end
43
+
44
+ # Configuration
45
+ require_relative "rails_ai_context/configuration"
46
+
47
+ # Core introspection
48
+ require_relative "rails_ai_context/introspector"
49
+ require_relative "rails_ai_context/introspectors/schema_introspector"
50
+ require_relative "rails_ai_context/introspectors/model_introspector"
51
+ require_relative "rails_ai_context/introspectors/route_introspector"
52
+ require_relative "rails_ai_context/introspectors/job_introspector"
53
+ require_relative "rails_ai_context/introspectors/gem_introspector"
54
+ require_relative "rails_ai_context/introspectors/convention_detector"
55
+
56
+ # MCP Tools
57
+ require_relative "rails_ai_context/tools/base_tool"
58
+ require_relative "rails_ai_context/tools/get_schema"
59
+ require_relative "rails_ai_context/tools/get_routes"
60
+ require_relative "rails_ai_context/tools/get_model_details"
61
+ require_relative "rails_ai_context/tools/get_gems"
62
+ require_relative "rails_ai_context/tools/search_code"
63
+ require_relative "rails_ai_context/tools/get_conventions"
64
+
65
+ # Serializers
66
+ require_relative "rails_ai_context/serializers/context_file_serializer"
67
+ require_relative "rails_ai_context/serializers/markdown_serializer"
68
+ require_relative "rails_ai_context/serializers/json_serializer"
69
+
70
+ # MCP Server
71
+ require_relative "rails_ai_context/server"
72
+
73
+ # Rails integration — loaded by Bundler.require after Rails is booted
74
+ require_relative "rails_ai_context/engine" if defined?(Rails::Engine)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/rails_ai_context/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rails-ai-context"
7
+ spec.version = RailsAiContext::VERSION
8
+ spec.authors = ["crisnahine"]
9
+ spec.email = ["crisjosephnahine@gmail.com"]
10
+
11
+ spec.summary = "Turn any Rails app into an AI-ready codebase — one gem install."
12
+ spec.description = <<~DESC
13
+ rails-ai-context automatically introspects your Rails application and exposes
14
+ models, routes, schema, jobs, mailers, and conventions through the Model Context
15
+ Protocol (MCP). Works with Claude Code, Cursor, Windsurf, GitHub Copilot, and
16
+ any MCP-compatible AI tool. Zero configuration required.
17
+ DESC
18
+
19
+ spec.homepage = "https://github.com/crisnahine/rails-ai-context"
20
+ spec.license = "MIT"
21
+
22
+ spec.required_ruby_version = ">= 3.2.0"
23
+
24
+ spec.metadata["homepage_uri"] = spec.homepage
25
+ spec.metadata["source_code_uri"] = spec.homepage
26
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
27
+ spec.metadata["rubygems_mfa_required"] = "true"
28
+
29
+ spec.files = Dir.chdir(__dir__) do
30
+ `git ls-files -z`.split("\x0").reject do |f|
31
+ (File.expand_path(f) == __FILE__) ||
32
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
33
+ end
34
+ end
35
+
36
+ spec.bindir = "exe"
37
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
38
+ spec.require_paths = ["lib"]
39
+
40
+ # Core dependencies
41
+ spec.add_dependency "mcp", "~> 0.8" # Official MCP Ruby SDK
42
+ spec.add_dependency "railties", ">= 7.1", "< 9.0"
43
+ spec.add_dependency "thor", ">= 1.0", "< 3.0"
44
+
45
+ # Dev dependencies
46
+ spec.add_development_dependency "rspec", "~> 3.13"
47
+ spec.add_development_dependency "rake", "~> 13.0"
48
+ spec.add_development_dependency "rubocop", "~> 1.65"
49
+ spec.add_development_dependency "rubocop-rails-omakase", "~> 1.0"
50
+ spec.add_development_dependency "yard", "~> 0.9"
51
+ spec.add_development_dependency "combustion", "~> 1.4" # Test Rails engines in isolation
52
+ end