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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +23 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE +21 -0
- data/README.md +212 -0
- data/Rakefile +8 -0
- data/exe/rails-ai-context +67 -0
- data/lib/generators/rails_ai_context/install/install_generator.rb +85 -0
- data/lib/rails-ai-context.rb +5 -0
- data/lib/rails_ai_context/configuration.rb +52 -0
- data/lib/rails_ai_context/engine.rb +21 -0
- data/lib/rails_ai_context/introspector.rb +61 -0
- data/lib/rails_ai_context/introspectors/convention_detector.rb +125 -0
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +128 -0
- data/lib/rails_ai_context/introspectors/job_introspector.rb +82 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +163 -0
- data/lib/rails_ai_context/introspectors/route_introspector.rb +83 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +143 -0
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +60 -0
- data/lib/rails_ai_context/serializers/json_serializer.rb +19 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +158 -0
- data/lib/rails_ai_context/server.rb +90 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +89 -0
- data/lib/rails_ai_context/tools/base_tool.rb +37 -0
- data/lib/rails_ai_context/tools/get_conventions.rb +87 -0
- data/lib/rails_ai_context/tools/get_gems.rb +49 -0
- data/lib/rails_ai_context/tools/get_model_details.rb +103 -0
- data/lib/rails_ai_context/tools/get_routes.rb +50 -0
- data/lib/rails_ai_context/tools/get_schema.rb +93 -0
- data/lib/rails_ai_context/tools/search_code.rb +111 -0
- data/lib/rails_ai_context/version.rb +5 -0
- data/lib/rails_ai_context.rb +74 -0
- data/rails-ai-context.gemspec +52 -0
- 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,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
|