rails-ai-context 0.10.1 → 0.11.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/CLAUDE.md +1 -1
- data/README.md +43 -16
- data/demo_script.sh +1 -1
- data/lib/rails_ai_context/introspectors/model_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +107 -4
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +51 -4
- data/lib/rails_ai_context/serializers/claude_serializer.rb +17 -0
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +25 -0
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +13 -0
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +28 -0
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +17 -0
- data/lib/rails_ai_context/serializers/rules_serializer.rb +13 -0
- data/lib/rails_ai_context/serializers/windsurf_rules_serializer.rb +14 -0
- data/lib/rails_ai_context/serializers/windsurf_serializer.rb +11 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +3 -3
- data/lib/rails_ai_context/tools/get_view.rb +24 -2
- data/lib/rails_ai_context/tools/search_code.rb +5 -1
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bdd226d739429a34c2ea8cedc8faf12e854d0220de60f5ffb08fe8fed5459f71
|
|
4
|
+
data.tar.gz: 7b609ca0007e97206b168cbda0937fb9fb581888491464619ba727a8a1b2d945
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b54b6354d0236f8f46f813593a5472fcca35dd4ceb1aefc4631479829c541b53d8a34509602b2f5903ff1af4b34b3d43d758e41f2c8e84cb5498b3212011541a
|
|
7
|
+
data.tar.gz: 0ddd8d1b1d413c3313a3189168c790dbbe776de2d573be006163867732287e939c0c8ea4490b39d1b86239f6940cfd923fcfab27ce24f6d9ba9bc3cda0c750fb
|
data/CLAUDE.md
CHANGED
data/README.md
CHANGED
|
@@ -21,22 +21,49 @@ The AI doesn't know your schema, your Devise setup, your Sidekiq jobs, or that `
|
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
-
## Proof:
|
|
24
|
+
## Proof: 37% Token Savings (Real Benchmark)
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Same task — *"Add status and date range filters to the Cooks index page"* — 4 scenarios in parallel, same Rails app:
|
|
27
27
|
|
|
28
|
-
|
|
|
29
|
-
|
|
30
|
-
| **
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
|
|
|
28
|
+
| Setup | Tokens | Saved | What it knows |
|
|
29
|
+
|-------|--------|-------|---------------|
|
|
30
|
+
| **rails-ai-context (full)** | **28,834** | **37%** | 11 MCP tools + generated docs + rules |
|
|
31
|
+
| rails-ai-context CLAUDE.md only | 33,106 | 27% | Generated docs + rules, no MCP tools |
|
|
32
|
+
| Normal Claude `/init` | 40,700 | 11% | Generic CLAUDE.md only |
|
|
33
|
+
| No rails-ai-context at all | 45,477 | baseline | Nothing — discovers everything from scratch |
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
```
|
|
36
|
+
No rails-ai-context 45,477 tk █████████████████████████████████████████████
|
|
37
|
+
Normal Claude /init 40,700 tk █████████████████████████████████████████ -11%
|
|
38
|
+
rails-ai-context CLAUDE.md 33,106 tk █████████████████████████████████ -27%
|
|
39
|
+
rails-ai-context (full) 28,834 tk █████████████████████████████ -37%
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
https://github.com/user-attachments/assets/14476243-1210-4e62-9dc5-9d4aa9caef7e
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
**What each layer gives you:**
|
|
46
|
+
|
|
47
|
+
| | Normal `/init` | rails-ai-context CLAUDE.md | rails-ai-context full |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| Knows it's Rails + Tailwind | Yes | Yes | Yes |
|
|
50
|
+
| Knows model names, columns, associations | No | Yes | Yes |
|
|
51
|
+
| Knows controller actions, filters | No | Yes | Yes |
|
|
52
|
+
| Discovery overhead | ~8 calls | 0 calls | 0 calls |
|
|
53
|
+
| Structured MCP queries | No | No | Yes — 5 MCP calls replace file reads |
|
|
54
|
+
|
|
55
|
+
**~16,600 fewer tokens per task** vs no gem at all.
|
|
56
|
+
|
|
57
|
+
> **This was a simple task on a small 5-model app.** Real-world tasks are 3-10x more complex.
|
|
58
|
+
> A feature touching auth + payments + mailers + tests on a 50-model app? Without the gem, Claude reads `db/schema.rb` (2,000+ lines), every model file, every controller, every view — easily 200K+ tokens per session. With rails-ai-context, MCP tools return only what's needed: `rails_get_schema(table:"users")` returns 25 lines instead of 2,000. **The bigger your app and the harder the task, the more you save.**
|
|
36
59
|
|
|
37
|
-
|
|
60
|
+
| App size | Without gem | With rails-ai-context | Savings |
|
|
61
|
+
|----------|-------------|----------------------|---------|
|
|
62
|
+
| Small (5 models) | 45K tokens | 29K tokens | 37% |
|
|
63
|
+
| Medium (30 models) | ~150K tokens | ~60K tokens | ~60% |
|
|
64
|
+
| Large (100+ models) | ~500K+ tokens | ~100K tokens | ~80% |
|
|
38
65
|
|
|
39
|
-
|
|
66
|
+
*Medium/large estimates based on schema.rb scaling (40 lines/table), model file scaling, and MCP summary-first workflow eliminating full-file reads.*
|
|
40
67
|
|
|
41
68
|
---
|
|
42
69
|
|
|
@@ -60,10 +87,10 @@ The install generator creates `.mcp.json` for auto-discovery — Claude Code and
|
|
|
60
87
|
|
|
61
88
|

|
|
62
89
|
|
|
63
|
-
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
66
|
-
-
|
|
90
|
+
- `/init` saves 11% — knows the framework but wastes tokens discovering models and tables
|
|
91
|
+
- **CLAUDE.md saves 27%** — complete Rails-specific map, zero discovery overhead
|
|
92
|
+
- **Full MCP saves 37%** — structured queries replace expensive full-file reads
|
|
93
|
+
- MCP tools return `detail:"summary"` first (~55 tokens), then drill into specifics
|
|
67
94
|
- Split rule files only activate in relevant directories
|
|
68
95
|
|
|
69
96
|
---
|
|
@@ -362,7 +389,7 @@ The gem parses `db/schema.rb` as text when no database is connected. Works in CI
|
|
|
362
389
|
```bash
|
|
363
390
|
git clone https://github.com/crisnahine/rails-ai-context.git
|
|
364
391
|
cd rails-ai-context && bundle install
|
|
365
|
-
bundle exec rspec #
|
|
392
|
+
bundle exec rspec # 468 examples
|
|
366
393
|
bundle exec rubocop # Lint
|
|
367
394
|
```
|
|
368
395
|
|
data/demo_script.sh
CHANGED
|
@@ -169,8 +169,8 @@ module RailsAiContext
|
|
|
169
169
|
macros = {}
|
|
170
170
|
|
|
171
171
|
macros[:has_secure_password] = true if source.match?(/\bhas_secure_password\b/)
|
|
172
|
-
macros[:encrypts] = source.scan(/\bencrypts\s+(
|
|
173
|
-
macros[:normalizes] = source.scan(/\bnormalizes\s+(
|
|
172
|
+
macros[:encrypts] = source.scan(/\bencrypts\s+(.+?)$/).flat_map { |m| m[0].scan(/:(\w+)/).flatten } if source.match?(/\bencrypts\s+:/)
|
|
173
|
+
macros[:normalizes] = source.scan(/\bnormalizes\s+(.+?)$/).flat_map { |m| m[0].scan(/:(\w+)/).flatten } if source.match?(/\bnormalizes\s+:/)
|
|
174
174
|
macros[:has_one_attached] = source.scan(/\bhas_one_attached\s+:(\w+)/).flatten if source.match?(/\bhas_one_attached\s+:/)
|
|
175
175
|
macros[:has_many_attached] = source.scan(/\bhas_many_attached\s+:(\w+)/).flatten if source.match?(/\bhas_many_attached\s+:/)
|
|
176
176
|
macros[:has_rich_text] = source.scan(/\bhas_rich_text\s+:(\w+)/).flatten if source.match?(/\bhas_rich_text\s+:/)
|
|
@@ -180,7 +180,7 @@ module RailsAiContext
|
|
|
180
180
|
macros[:store] = source.scan(/\bstore(?:_accessor)?\s+:(\w+)/).flatten if source.match?(/\bstore(?:_accessor)?\s+:/)
|
|
181
181
|
|
|
182
182
|
# Delegations
|
|
183
|
-
delegations = source.scan(/\bdelegate\s+(.+?),\s*to:\s*:(\w+)/).map do |methods_str, target|
|
|
183
|
+
delegations = source.scan(/\bdelegate\s+(.+?),\s*to:\s*:(\w+)/m).map do |methods_str, target|
|
|
184
184
|
{ methods: methods_str.scan(/:(\w+)/).flatten, to: target }
|
|
185
185
|
end
|
|
186
186
|
macros[:delegations] = delegations if delegations.any?
|
|
@@ -14,11 +14,13 @@ module RailsAiContext
|
|
|
14
14
|
|
|
15
15
|
def call
|
|
16
16
|
views_dir = File.join(app.root.to_s, "app", "views")
|
|
17
|
-
return { templates: {}, partials: {} } unless Dir.exist?(views_dir)
|
|
17
|
+
return { templates: {}, partials: {}, ui_patterns: {} } unless Dir.exist?(views_dir)
|
|
18
18
|
|
|
19
|
+
all_content = collect_all_view_content(views_dir)
|
|
19
20
|
{
|
|
20
21
|
templates: scan_templates(views_dir),
|
|
21
|
-
partials: scan_partials(views_dir)
|
|
22
|
+
partials: scan_partials(views_dir),
|
|
23
|
+
ui_patterns: extract_ui_patterns(all_content)
|
|
22
24
|
}
|
|
23
25
|
rescue => e
|
|
24
26
|
{ error: e.message }
|
|
@@ -49,12 +51,113 @@ module RailsAiContext
|
|
|
49
51
|
Dir.glob(File.join(views_dir, "**", "_*")).each do |path|
|
|
50
52
|
next if File.directory?(path)
|
|
51
53
|
relative = path.sub("#{views_dir}/", "")
|
|
52
|
-
|
|
53
|
-
partials[relative] = {
|
|
54
|
+
content = File.read(path) rescue next
|
|
55
|
+
partials[relative] = {
|
|
56
|
+
lines: content.lines.count,
|
|
57
|
+
fields: extract_model_fields(content),
|
|
58
|
+
helpers: extract_helper_calls(content)
|
|
59
|
+
}
|
|
54
60
|
end
|
|
55
61
|
partials
|
|
56
62
|
end
|
|
57
63
|
|
|
64
|
+
def collect_all_view_content(views_dir)
|
|
65
|
+
content = ""
|
|
66
|
+
Dir.glob(File.join(views_dir, "**", "*.{erb,haml,slim}")).each do |path|
|
|
67
|
+
next if File.directory?(path)
|
|
68
|
+
content += (File.read(path) rescue "")
|
|
69
|
+
end
|
|
70
|
+
content
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def extract_ui_patterns(all_content) # rubocop:disable Metrics/MethodLength
|
|
74
|
+
patterns = {}
|
|
75
|
+
class_groups = Hash.new(0)
|
|
76
|
+
|
|
77
|
+
# Match both double and single quoted class attributes
|
|
78
|
+
all_content.scan(/class="([^"]+)"/).each do |m|
|
|
79
|
+
classes = m[0].gsub(/<%=.*?%>/, "").strip # Strip ERB interpolation
|
|
80
|
+
next if classes.length < 5
|
|
81
|
+
class_groups[classes] += 1
|
|
82
|
+
end
|
|
83
|
+
all_content.scan(/class='([^']+)'/).each do |m|
|
|
84
|
+
classes = m[0].gsub(/<%=.*?%>/, "").strip
|
|
85
|
+
next if classes.length < 5
|
|
86
|
+
class_groups[classes] += 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Find repeated patterns (used 2+ times) grouped by element type
|
|
90
|
+
# Framework-agnostic: works with Tailwind, Bootstrap, or custom CSS
|
|
91
|
+
buttons = []
|
|
92
|
+
cards = []
|
|
93
|
+
inputs = []
|
|
94
|
+
labels = []
|
|
95
|
+
badges = []
|
|
96
|
+
links = []
|
|
97
|
+
|
|
98
|
+
class_groups.each do |classes, count|
|
|
99
|
+
next if count < 2
|
|
100
|
+
|
|
101
|
+
if classes.match?(/btn|button|submit|bg-\w+-\d+.*text-white|hover:bg|btn-primary|btn-secondary/)
|
|
102
|
+
buttons << classes
|
|
103
|
+
elsif classes.match?(/card|panel|shadow|border.*rounded.*p-\d|bg-white.*rounded/)
|
|
104
|
+
cards << classes
|
|
105
|
+
elsif classes.match?(/input|field|form-control|border.*rounded.*px-\d|focus:ring|focus:border/)
|
|
106
|
+
inputs << classes
|
|
107
|
+
elsif classes.match?(/label|font-semibold.*mb-|font-medium.*mb-|form-label|block.*text-sm/)
|
|
108
|
+
labels << classes
|
|
109
|
+
elsif classes.match?(/badge|rounded-full|pill|tag|px-2.*py-1.*text-xs/)
|
|
110
|
+
badges << classes
|
|
111
|
+
elsif classes.match?(/link|hover:text-|hover:underline|hover:bg-.*text-.*font-/)
|
|
112
|
+
links << classes
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
patterns[:buttons] = buttons.first(3) if buttons.any?
|
|
117
|
+
patterns[:cards] = cards.first(3) if cards.any?
|
|
118
|
+
patterns[:inputs] = inputs.first(3) if inputs.any?
|
|
119
|
+
patterns[:labels] = labels.first(3) if labels.any?
|
|
120
|
+
patterns[:badges] = badges.first(3) if badges.any?
|
|
121
|
+
patterns[:links] = links.first(3) if links.any?
|
|
122
|
+
|
|
123
|
+
patterns
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
EXCLUDED_METHODS = %w[
|
|
127
|
+
each map select reject first last size count any? empty? present? blank?
|
|
128
|
+
new build create find where order limit nil? join class html_safe
|
|
129
|
+
to_s to_i to_f inspect strip chomp downcase upcase capitalize
|
|
130
|
+
humanize pluralize singularize truncate gsub sub scan match split
|
|
131
|
+
freeze dup clone length bytes chars reverse uniq compact flatten
|
|
132
|
+
flat_map zip sort sort_by min max sum group_by
|
|
133
|
+
persisted? new_record? valid? errors reload save destroy update
|
|
134
|
+
delete respond_to? is_a? kind_of? send try
|
|
135
|
+
abs round ceil floor
|
|
136
|
+
strftime iso8601 beginning_of_day end_of_day ago from_now
|
|
137
|
+
].freeze
|
|
138
|
+
|
|
139
|
+
def extract_model_fields(content)
|
|
140
|
+
fields = []
|
|
141
|
+
content.scan(/(?:@?\w+)\.(\w+)/).each do |m|
|
|
142
|
+
field = m[0]
|
|
143
|
+
next if field.length < 3 || field.length > 40
|
|
144
|
+
next if field.match?(/\A[0-9a-f]+\z/)
|
|
145
|
+
next if field.match?(/\A[A-Z]/) # skip constant/class access
|
|
146
|
+
next if EXCLUDED_METHODS.include?(field)
|
|
147
|
+
next if field.start_with?("to_", "html_")
|
|
148
|
+
next if field.end_with?("?", "!")
|
|
149
|
+
fields << field
|
|
150
|
+
end
|
|
151
|
+
fields.uniq.first(15)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def extract_helper_calls(content)
|
|
155
|
+
helpers = []
|
|
156
|
+
# Custom helper methods (render_*, format_*, *_path, *_url)
|
|
157
|
+
content.scan(/\b(render_\w+|format_\w+)\b/).each { |m| helpers << m[0] }
|
|
158
|
+
helpers.uniq
|
|
159
|
+
end
|
|
160
|
+
|
|
58
161
|
def extract_partial_refs(content)
|
|
59
162
|
refs = []
|
|
60
163
|
# render "partial_name" or render partial: "name"
|
|
@@ -24,6 +24,7 @@ module RailsAiContext
|
|
|
24
24
|
"rails-context.md" => render_context_overview,
|
|
25
25
|
"rails-schema.md" => render_schema_reference,
|
|
26
26
|
"rails-models.md" => render_models_reference,
|
|
27
|
+
"rails-ui-patterns.md" => render_ui_patterns_reference,
|
|
27
28
|
"rails-mcp-tools.md" => render_mcp_tools_reference
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -96,11 +97,34 @@ module RailsAiContext
|
|
|
96
97
|
""
|
|
97
98
|
]
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
skip_cols = %w[id created_at updated_at]
|
|
101
|
+
# Always show these even if they end in _id/_type (important for AI)
|
|
102
|
+
keep_cols = %w[type deleted_at discarded_at]
|
|
103
|
+
|
|
104
|
+
tables.keys.sort.first(30).each do |name|
|
|
100
105
|
data = tables[name]
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
columns = data[:columns] || []
|
|
107
|
+
col_count = columns.size
|
|
108
|
+
pk = data[:primary_key]
|
|
109
|
+
pk_display = pk.is_a?(Array) ? pk.join(", ") : (pk || "id").to_s
|
|
110
|
+
|
|
111
|
+
key_cols = columns.map { |c| c[:name] }.select do |c|
|
|
112
|
+
next true if keep_cols.include?(c)
|
|
113
|
+
next true if c.end_with?("_type") # polymorphic associations
|
|
114
|
+
next false if skip_cols.include?(c)
|
|
115
|
+
next false if c.end_with?("_id")
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
col_sample = key_cols.first(12)
|
|
120
|
+
shown = col_sample.join(", ")
|
|
121
|
+
shown += ", ..." if key_cols.size > 12
|
|
122
|
+
col_names = col_sample.any? ? " — #{shown}" : ""
|
|
123
|
+
lines << "- #{name} (#{col_count} cols, pk: #{pk_display})#{col_names}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if tables.size > 30
|
|
127
|
+
lines << "- ...#{tables.size - 30} more tables (use `rails_get_schema` MCP tool)"
|
|
104
128
|
end
|
|
105
129
|
|
|
106
130
|
lines.join("\n")
|
|
@@ -133,6 +157,29 @@ module RailsAiContext
|
|
|
133
157
|
lines.join("\n")
|
|
134
158
|
end
|
|
135
159
|
|
|
160
|
+
def render_ui_patterns_reference
|
|
161
|
+
vt = context[:view_templates]
|
|
162
|
+
return nil unless vt.is_a?(Hash) && !vt[:error]
|
|
163
|
+
patterns = vt[:ui_patterns] || {}
|
|
164
|
+
return nil if patterns.empty?
|
|
165
|
+
|
|
166
|
+
lines = [
|
|
167
|
+
"# UI Patterns",
|
|
168
|
+
"",
|
|
169
|
+
"Common CSS class patterns found in this app's views.",
|
|
170
|
+
"Use these when creating new views to match the existing design.",
|
|
171
|
+
""
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
patterns.each do |type, classes_list|
|
|
175
|
+
classes_list.each do |classes|
|
|
176
|
+
lines << "- #{type.to_s.chomp('s').capitalize}: `#{classes}`"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
lines.join("\n")
|
|
181
|
+
end
|
|
182
|
+
|
|
136
183
|
def render_mcp_tools_reference # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
137
184
|
lines = [
|
|
138
185
|
"# Rails MCP Tools — ALWAYS Use These First",
|
|
@@ -29,6 +29,7 @@ module RailsAiContext
|
|
|
29
29
|
lines.concat(render_key_models)
|
|
30
30
|
lines.concat(render_notable_gems)
|
|
31
31
|
lines.concat(render_architecture)
|
|
32
|
+
lines.concat(render_ui_patterns)
|
|
32
33
|
lines.concat(render_mcp_guide)
|
|
33
34
|
lines.concat(render_conventions)
|
|
34
35
|
lines.concat(render_commands)
|
|
@@ -158,6 +159,22 @@ module RailsAiContext
|
|
|
158
159
|
lines
|
|
159
160
|
end
|
|
160
161
|
|
|
162
|
+
def render_ui_patterns
|
|
163
|
+
vt = context[:view_templates]
|
|
164
|
+
return [] unless vt.is_a?(Hash) && !vt[:error]
|
|
165
|
+
patterns = vt[:ui_patterns] || {}
|
|
166
|
+
return [] if patterns.empty?
|
|
167
|
+
|
|
168
|
+
lines = [ "## UI Patterns" ]
|
|
169
|
+
patterns.each do |type, classes_list|
|
|
170
|
+
classes_list.each do |classes|
|
|
171
|
+
lines << "- #{type.to_s.chomp('s').capitalize}: `#{classes}`"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
lines << ""
|
|
175
|
+
lines
|
|
176
|
+
end
|
|
177
|
+
|
|
161
178
|
def render_mcp_guide # rubocop:disable Metrics/MethodLength
|
|
162
179
|
[
|
|
163
180
|
"## MCP Tools (11) — ALWAYS Use These First",
|
|
@@ -22,6 +22,7 @@ module RailsAiContext
|
|
|
22
22
|
"rails-context.instructions.md" => render_context_instructions,
|
|
23
23
|
"rails-models.instructions.md" => render_models_instructions,
|
|
24
24
|
"rails-controllers.instructions.md" => render_controllers_instructions,
|
|
25
|
+
"rails-ui-patterns.instructions.md" => render_ui_patterns_instructions,
|
|
25
26
|
"rails-mcp-tools.instructions.md" => render_mcp_tools_instructions
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -134,6 +135,30 @@ module RailsAiContext
|
|
|
134
135
|
lines.join("\n")
|
|
135
136
|
end
|
|
136
137
|
|
|
138
|
+
def render_ui_patterns_instructions
|
|
139
|
+
vt = context[:view_templates]
|
|
140
|
+
return nil unless vt.is_a?(Hash) && !vt[:error]
|
|
141
|
+
patterns = vt[:ui_patterns] || {}
|
|
142
|
+
return nil if patterns.empty?
|
|
143
|
+
|
|
144
|
+
lines = [
|
|
145
|
+
"---",
|
|
146
|
+
"applyTo: \"app/views/**/*.erb\"",
|
|
147
|
+
"---",
|
|
148
|
+
"",
|
|
149
|
+
"# UI Patterns",
|
|
150
|
+
"",
|
|
151
|
+
"Use these CSS class patterns to match the existing design.",
|
|
152
|
+
""
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
patterns.each do |type, classes_list|
|
|
156
|
+
classes_list.each { |c| lines << "- #{type.to_s.chomp('s').capitalize}: `#{c}`" }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
lines.join("\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
137
162
|
def render_mcp_tools_instructions # rubocop:disable Metrics/MethodLength
|
|
138
163
|
lines = [
|
|
139
164
|
"---",
|
|
@@ -80,6 +80,19 @@ module RailsAiContext
|
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
# UI Patterns
|
|
84
|
+
vt = context[:view_templates]
|
|
85
|
+
if vt.is_a?(Hash) && !vt[:error]
|
|
86
|
+
patterns = vt[:ui_patterns] || {}
|
|
87
|
+
if patterns.any?
|
|
88
|
+
lines << "## UI Patterns"
|
|
89
|
+
patterns.each do |type, classes_list|
|
|
90
|
+
classes_list.each { |c| lines << "- #{type.to_s.chomp('s').capitalize}: `#{c}`" }
|
|
91
|
+
end
|
|
92
|
+
lines << ""
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
83
96
|
# MCP tools
|
|
84
97
|
lines << "## MCP Tool Reference"
|
|
85
98
|
lines << ""
|
|
@@ -25,6 +25,7 @@ module RailsAiContext
|
|
|
25
25
|
"rails-project.mdc" => render_project_rule,
|
|
26
26
|
"rails-models.mdc" => render_models_rule,
|
|
27
27
|
"rails-controllers.mdc" => render_controllers_rule,
|
|
28
|
+
"rails-ui-patterns.mdc" => render_ui_patterns_rule,
|
|
28
29
|
"rails-mcp-tools.mdc" => render_mcp_tools_rule
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -154,6 +155,33 @@ module RailsAiContext
|
|
|
154
155
|
lines.join("\n")
|
|
155
156
|
end
|
|
156
157
|
|
|
158
|
+
def render_ui_patterns_rule
|
|
159
|
+
vt = context[:view_templates]
|
|
160
|
+
return nil unless vt.is_a?(Hash) && !vt[:error]
|
|
161
|
+
patterns = vt[:ui_patterns] || {}
|
|
162
|
+
return nil if patterns.empty?
|
|
163
|
+
|
|
164
|
+
lines = [
|
|
165
|
+
"---",
|
|
166
|
+
"description: \"UI/CSS patterns used in this Rails app\"",
|
|
167
|
+
"globs:",
|
|
168
|
+
" - \"app/views/**/*.erb\"",
|
|
169
|
+
"alwaysApply: false",
|
|
170
|
+
"---",
|
|
171
|
+
"",
|
|
172
|
+
"# UI Patterns",
|
|
173
|
+
"",
|
|
174
|
+
"Use these CSS class patterns to match the existing design.",
|
|
175
|
+
""
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
patterns.each do |type, classes_list|
|
|
179
|
+
classes_list.each { |c| lines << "- #{type.to_s.chomp('s').capitalize}: `#{c}`" }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
lines.join("\n")
|
|
183
|
+
end
|
|
184
|
+
|
|
157
185
|
# Always-on MCP tool reference — strongest enforcement point for Cursor
|
|
158
186
|
def render_mcp_tools_rule # rubocop:disable Metrics/MethodLength
|
|
159
187
|
lines = [
|
|
@@ -29,6 +29,7 @@ module RailsAiContext
|
|
|
29
29
|
lines.concat(render_key_models)
|
|
30
30
|
lines.concat(render_notable_gems)
|
|
31
31
|
lines.concat(render_architecture)
|
|
32
|
+
lines.concat(render_ui_patterns)
|
|
32
33
|
lines.concat(render_mcp_guide)
|
|
33
34
|
lines.concat(render_conventions)
|
|
34
35
|
lines.concat(render_commands)
|
|
@@ -158,6 +159,22 @@ module RailsAiContext
|
|
|
158
159
|
lines
|
|
159
160
|
end
|
|
160
161
|
|
|
162
|
+
def render_ui_patterns
|
|
163
|
+
vt = context[:view_templates]
|
|
164
|
+
return [] unless vt.is_a?(Hash) && !vt[:error]
|
|
165
|
+
patterns = vt[:ui_patterns] || {}
|
|
166
|
+
return [] if patterns.empty?
|
|
167
|
+
|
|
168
|
+
lines = [ "## UI Patterns" ]
|
|
169
|
+
patterns.each do |type, classes_list|
|
|
170
|
+
classes_list.each do |classes|
|
|
171
|
+
lines << "- #{type.to_s.chomp('s').capitalize}: `#{classes}`"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
lines << ""
|
|
175
|
+
lines
|
|
176
|
+
end
|
|
177
|
+
|
|
161
178
|
def render_mcp_guide # rubocop:disable Metrics/MethodLength
|
|
162
179
|
[
|
|
163
180
|
"## MCP Tools (11) — ALWAYS Use These First",
|
|
@@ -73,6 +73,19 @@ module RailsAiContext
|
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
# UI Patterns
|
|
77
|
+
vt = context[:view_templates]
|
|
78
|
+
if vt.is_a?(Hash) && !vt[:error]
|
|
79
|
+
patterns = vt[:ui_patterns] || {}
|
|
80
|
+
if patterns.any?
|
|
81
|
+
lines << "## UI Patterns"
|
|
82
|
+
patterns.each do |type, classes_list|
|
|
83
|
+
classes_list.each { |c| lines << "- #{type.to_s.chomp('s').capitalize}: `#{c}`" }
|
|
84
|
+
end
|
|
85
|
+
lines << ""
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
76
89
|
# MCP tools
|
|
77
90
|
lines << "## MCP Tool Reference"
|
|
78
91
|
lines << ""
|
|
@@ -22,6 +22,7 @@ module RailsAiContext
|
|
|
22
22
|
|
|
23
23
|
files = {
|
|
24
24
|
"rails-context.md" => render_context_rule,
|
|
25
|
+
"rails-ui-patterns.md" => render_ui_patterns_rule,
|
|
25
26
|
"rails-mcp-tools.md" => render_mcp_tools_rule
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -49,6 +50,19 @@ module RailsAiContext
|
|
|
49
50
|
WindsurfSerializer.new(context).call
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
def render_ui_patterns_rule
|
|
54
|
+
vt = context[:view_templates]
|
|
55
|
+
return nil unless vt.is_a?(Hash) && !vt[:error]
|
|
56
|
+
patterns = vt[:ui_patterns] || {}
|
|
57
|
+
return nil if patterns.empty?
|
|
58
|
+
|
|
59
|
+
lines = [ "# UI Patterns", "", "Match these CSS classes when creating new views.", "" ]
|
|
60
|
+
patterns.each do |type, classes_list|
|
|
61
|
+
classes_list.first(2).each { |c| lines << "- #{type}: `#{c}`" }
|
|
62
|
+
end
|
|
63
|
+
lines.join("\n")
|
|
64
|
+
end
|
|
65
|
+
|
|
52
66
|
def render_mcp_tools_rule # rubocop:disable Metrics/MethodLength
|
|
53
67
|
lines = [
|
|
54
68
|
"# Rails MCP Tools (11) — Use These First",
|
|
@@ -71,6 +71,17 @@ module RailsAiContext
|
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# UI Patterns (compact — character budget is tight)
|
|
75
|
+
vt = context[:view_templates]
|
|
76
|
+
if vt.is_a?(Hash) && !vt[:error]
|
|
77
|
+
patterns = vt[:ui_patterns] || {}
|
|
78
|
+
if patterns.any?
|
|
79
|
+
lines << "# UI Patterns"
|
|
80
|
+
patterns.each { |type, list| list.first(1).each { |c| lines << "- #{type}: `#{c}`" } }
|
|
81
|
+
lines << ""
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
74
85
|
# MCP tools — compact but complete (character budget is tight)
|
|
75
86
|
lines << "# MCP Tools (detail:\"summary\"|\"standard\"|\"full\")"
|
|
76
87
|
lines << "- rails_get_schema(table:\"name\"|detail:\"summary\"|limit:N|offset:N)"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
ASSISTANT_TABLE = <<~TABLE
|
|
3
|
+
ASSISTANT_TABLE = <<~TABLE unless defined?(ASSISTANT_TABLE)
|
|
4
4
|
AI Assistant Context File Command
|
|
5
5
|
-- -- --
|
|
6
6
|
Claude Code CLAUDE.md + .claude/rules/ rails ai:context:claude
|
|
@@ -14,7 +14,7 @@ TABLE
|
|
|
14
14
|
def print_result(result)
|
|
15
15
|
result[:written].each { |f| puts " ✅ #{f}" }
|
|
16
16
|
result[:skipped].each { |f| puts " ⏭️ #{f} (unchanged)" }
|
|
17
|
-
end
|
|
17
|
+
end unless defined?(print_result)
|
|
18
18
|
|
|
19
19
|
def apply_context_mode_override
|
|
20
20
|
if ENV["CONTEXT_MODE"]
|
|
@@ -22,7 +22,7 @@ def apply_context_mode_override
|
|
|
22
22
|
RailsAiContext.configuration.context_mode = mode
|
|
23
23
|
puts "📐 Context mode: #{mode}"
|
|
24
24
|
end
|
|
25
|
-
end
|
|
25
|
+
end unless defined?(apply_context_mode_override)
|
|
26
26
|
|
|
27
27
|
namespace :ai do
|
|
28
28
|
desc "Generate AI context files (CLAUDE.md, .cursor/rules/, .windsurfrules, .github/copilot-instructions.md)"
|
|
@@ -63,15 +63,32 @@ module RailsAiContext
|
|
|
63
63
|
text_response(lines.join("\n"))
|
|
64
64
|
|
|
65
65
|
when "standard"
|
|
66
|
-
|
|
66
|
+
all_partials = data[:partials] || {}
|
|
67
|
+
lines = [ "# Views (#{templates.size} templates, #{all_partials.size} partials)", "" ]
|
|
67
68
|
templates.keys.map { |k| k.split("/").first }.uniq.sort.each do |ctrl|
|
|
68
69
|
ctrl_templates = templates.select { |k, _| k.start_with?("#{ctrl}/") }
|
|
69
70
|
lines << "## #{ctrl}/"
|
|
70
71
|
ctrl_templates.sort.each do |name, meta|
|
|
71
|
-
parts = meta[:partials]&.any? ? "
|
|
72
|
+
parts = meta[:partials]&.any? ? " renders: #{meta[:partials].join(', ')}" : ""
|
|
72
73
|
stim = meta[:stimulus]&.any? ? " stimulus: #{meta[:stimulus].join(', ')}" : ""
|
|
73
74
|
lines << "- #{File.basename(name)} (#{meta[:lines]} lines)#{parts}#{stim}"
|
|
74
75
|
end
|
|
76
|
+
# Show partials for this controller with field/helper info
|
|
77
|
+
ctrl_partials = all_partials.select { |k, _| k.start_with?("#{ctrl}/") }
|
|
78
|
+
ctrl_partials.sort.each do |name, meta|
|
|
79
|
+
fields = meta[:fields]&.any? ? " fields: #{meta[:fields].join(', ')}" : ""
|
|
80
|
+
helpers = meta[:helpers]&.any? ? " helpers: #{meta[:helpers].join(', ')}" : ""
|
|
81
|
+
lines << "- #{File.basename(name)} (#{meta[:lines]} lines)#{fields}#{helpers}"
|
|
82
|
+
end
|
|
83
|
+
lines << ""
|
|
84
|
+
end
|
|
85
|
+
# Show shared partials
|
|
86
|
+
shared = all_partials.select { |k, _| k.start_with?("shared/") }
|
|
87
|
+
if shared.any?
|
|
88
|
+
lines << "## shared/"
|
|
89
|
+
shared.sort.each do |name, meta|
|
|
90
|
+
lines << "- #{File.basename(name)} (#{meta[:lines]} lines)"
|
|
91
|
+
end
|
|
75
92
|
lines << ""
|
|
76
93
|
end
|
|
77
94
|
text_response(lines.join("\n"))
|
|
@@ -92,6 +109,8 @@ module RailsAiContext
|
|
|
92
109
|
end
|
|
93
110
|
end
|
|
94
111
|
|
|
112
|
+
MAX_FILE_SIZE = 2_000_000 # 2MB safety limit
|
|
113
|
+
|
|
95
114
|
private_class_method def self.read_view_file(path)
|
|
96
115
|
views_dir = Rails.root.join("app", "views")
|
|
97
116
|
full_path = views_dir.join(path)
|
|
@@ -103,6 +122,9 @@ module RailsAiContext
|
|
|
103
122
|
unless File.exist?(full_path)
|
|
104
123
|
return text_response("View not found: #{path}")
|
|
105
124
|
end
|
|
125
|
+
if File.size(full_path) > MAX_FILE_SIZE
|
|
126
|
+
return text_response("File too large: #{path} (#{File.size(full_path)} bytes)")
|
|
127
|
+
end
|
|
106
128
|
|
|
107
129
|
content = File.read(full_path)
|
|
108
130
|
text_response("# #{path}\n\n```erb\n#{content}\n```")
|
|
@@ -106,7 +106,11 @@ module RailsAiContext
|
|
|
106
106
|
|
|
107
107
|
private_class_method def self.search_with_ruby(pattern, search_path, file_type, max_results, root)
|
|
108
108
|
results = []
|
|
109
|
-
|
|
109
|
+
begin
|
|
110
|
+
regex = Regexp.new(pattern, Regexp::IGNORECASE, timeout: 2)
|
|
111
|
+
rescue RegexpError => e
|
|
112
|
+
return [ { file: "error", line_number: 0, content: "Invalid regex: #{e.message}" } ]
|
|
113
|
+
end
|
|
110
114
|
glob = file_type ? "**/*.#{file_type}" : "**/*.{rb,js,erb,yml,yaml,json}"
|
|
111
115
|
excluded = RailsAiContext.configuration.excluded_paths
|
|
112
116
|
|
data/server.json
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
"url": "https://github.com/crisnahine/rails-ai-context",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.11.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "mcpb",
|
|
14
|
-
"identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v0.
|
|
14
|
+
"identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v0.11.0/rails-ai-context-mcp.mcpb",
|
|
15
15
|
"fileSha256": "dd711a0ad6c4de943ae4da94eaf59a6dc9494b9d57f726e24649ed4e2f156990",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|