rails-agent-kit 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/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +118 -0
- data/exe/rails-agent-kit +69 -0
- data/lib/rails_agent_kit/routes_inspector.rb +144 -0
- data/lib/rails_agent_kit/routes_parser.rb +83 -0
- data/lib/rails_agent_kit/schema_inspector.rb +51 -0
- data/lib/rails_agent_kit/schema_parser.rb +193 -0
- data/lib/rails_agent_kit/server.rb +81 -0
- data/lib/rails_agent_kit/skill_installer.rb +59 -0
- data/lib/rails_agent_kit/structure_sql_parser.rb +73 -0
- data/lib/rails_agent_kit/version.rb +5 -0
- data/lib/rails_agent_kit.rb +20 -0
- data/skills/rails-8-conventions/SKILL.md +117 -0
- metadata +76 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4204d99ae173d2d33485290dabf6cd45f769bf718fee6d9abe5bc6de4783dbc4
|
|
4
|
+
data.tar.gz: 4b1697ce2c95405f50edfb4106ae145b8d6aa9c6957fa9be92c08749d1c591cf
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b099152c12dc8939cdb776a0f582c2c6df5c36110a6e8c3af9607d42496505b1d6bba2d6492fcc060801add37c6e3bcbf53ab13d787ef8b8110deafbee3fa481
|
|
7
|
+
data.tar.gz: 7757d654bf17f8824a7d59168acbb9911fdf1086db0ab3ef1a85e3301da3a6727fa20b05345f81e966e57d2aa38754c15d34b7c27f357e52815fff64d64d9483
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-10-17
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- An MCP stdio server (`rails-agent-kit`, built on the official `mcp` Ruby SDK) with two read-only tools:
|
|
14
|
+
- `rails_schema`: tables, columns, indexes and foreign keys. Parsed statically from `db/schema.rb` without
|
|
15
|
+
booting the app, with a best-effort fallback to `db/structure.sql`. Takes an optional `table` filter.
|
|
16
|
+
- `rails_routes`: verb, path, `controller#action` and name for each route, from `bin/rails routes --expanded`.
|
|
17
|
+
Takes an optional `filter`. Use `--routes-file` or `RAILS_AGENT_KIT_ROUTES_COMMAND` when the app can't boot
|
|
18
|
+
locally. If the routes command fails, the tool falls back to the raw `config/routes.rb`.
|
|
19
|
+
- A Rails 8 conventions agent skill (`skills/rails-8-conventions/SKILL.md`) for Cursor and Claude Code.
|
|
20
|
+
- `rails-agent-kit install-skills [--agent cursor|claude] [--force]`, which installs the bundled skills into an app.
|
|
21
|
+
- CI on Ruby 3.2, 3.3 and 3.4.
|
|
22
|
+
|
|
23
|
+
[Unreleased]: https://github.com/afurm/rails-agent-kit/compare/v0.1.0...HEAD
|
|
24
|
+
[0.1.0]: https://github.com/afurm/rails-agent-kit/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrii Furmanets
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# rails-agent-kit
|
|
2
|
+
|
|
3
|
+
A tiny [Model Context Protocol](https://modelcontextprotocol.io) server that gives coding agents
|
|
4
|
+
(Cursor, Claude Code, …) **ground truth about your Rails app**: its database schema and its routes.
|
|
5
|
+
|
|
6
|
+
> Status: early scaffold (v0.1.0). Not published to RubyGems yet — run it from source.
|
|
7
|
+
|
|
8
|
+
## Why (the 30-second version)
|
|
9
|
+
|
|
10
|
+
Agents writing Rails code constantly guess: *is it `user.full_name` or `user.name`? is the column nullable?
|
|
11
|
+
is the helper `post_comments_path` or `comments_path`?* Wrong guesses become broken code and wasted review cycles.
|
|
12
|
+
With rails-agent-kit the agent just asks:
|
|
13
|
+
|
|
14
|
+
| Tool | What it returns | Boots the app? |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| `rails_schema` | Tables, columns (type, null, default, limit, …), indexes, foreign keys. Optional `table` filter. | **No** — statically parses `db/schema.rb` (falls back to a best-effort `db/structure.sql` parse). |
|
|
17
|
+
| `rails_routes` | Every route: name/prefix, verb, path, `controller#action`, source location. Optional `filter`. | Yes — runs `bin/rails routes --expanded` with the app's own bundle. If that fails or times out, it says why and returns the raw `config/routes.rb` instead. |
|
|
18
|
+
|
|
19
|
+
Both tools are read-only. Output is JSON.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
From source (today):
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
git clone https://github.com/afurm/rails-agent-kit
|
|
27
|
+
cd rails-agent-kit
|
|
28
|
+
bundle install
|
|
29
|
+
# try it against an app:
|
|
30
|
+
BUNDLE_GEMFILE=$PWD/Gemfile bundle exec rails-agent-kit --root /path/to/your/rails/app
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Once released (not yet): `gem install rails-agent-kit`.
|
|
34
|
+
|
|
35
|
+
Requires Ruby 3.2+. Runtime dependency: the official [`mcp`](https://github.com/modelcontextprotocol/ruby-sdk) Ruby SDK.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
rails-agent-kit [--root PATH] [--routes-file FILE]
|
|
41
|
+
|
|
42
|
+
--root PATH Rails app root (default: current directory)
|
|
43
|
+
--routes-file FILE Use a saved `bin/rails routes --expanded` dump instead of booting the app
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The server speaks MCP over stdio. Environment knobs:
|
|
47
|
+
|
|
48
|
+
- `RAILS_AGENT_KIT_ROUTES_TIMEOUT` — seconds to wait for `bin/rails routes` (default `60`).
|
|
49
|
+
- `RAILS_AGENT_KIT_ROUTES_COMMAND` — override the routes command, e.g. `"bundle exec rails routes --expanded"`
|
|
50
|
+
or `"docker compose exec -T web bin/rails routes --expanded"`. Runs in `--root`.
|
|
51
|
+
|
|
52
|
+
If your app can't boot locally (needs a DB, secrets, Docker…), dump the routes once and point the server at the file:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
bin/rails routes --expanded > tmp/routes.txt
|
|
56
|
+
rails-agent-kit --root . --routes-file tmp/routes.txt
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## MCP client config
|
|
60
|
+
|
|
61
|
+
Replace the paths with absolute paths on your machine.
|
|
62
|
+
|
|
63
|
+
**Cursor** — `.cursor/mcp.json` in your Rails app (or `~/.cursor/mcp.json`):
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"mcpServers": {
|
|
68
|
+
"rails-agent-kit": {
|
|
69
|
+
"command": "bundle",
|
|
70
|
+
"args": ["exec", "rails-agent-kit", "--root", "/path/to/your/rails/app"],
|
|
71
|
+
"env": { "BUNDLE_GEMFILE": "/path/to/rails-agent-kit/Gemfile" }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Claude Code**:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
claude mcp add rails-agent-kit \
|
|
81
|
+
-e BUNDLE_GEMFILE=/path/to/rails-agent-kit/Gemfile \
|
|
82
|
+
-- bundle exec rails-agent-kit --root /path/to/your/rails/app
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Claude Desktop** — `claude_desktop_config.json` uses the same `mcpServers` block as Cursor above.
|
|
86
|
+
|
|
87
|
+
`rails_routes` runs the app's `bin/rails` with Bundler's environment cleared, so your app uses its own Gemfile,
|
|
88
|
+
not rails-agent-kit's.
|
|
89
|
+
|
|
90
|
+
## Agent skill: Rails 8 conventions
|
|
91
|
+
|
|
92
|
+
The gem bundles a [Cursor skill](https://cursor.com/docs/skills),
|
|
93
|
+
[`skills/rails-8-conventions/SKILL.md`](skills/rails-8-conventions/SKILL.md). It tells the agent to check
|
|
94
|
+
`rails_schema`, `rails_routes` and `Gemfile.lock` before it edits code, then follow Rails 8 defaults:
|
|
95
|
+
`params.expect`, the built-in authentication generator, `rate_limit`, Solid Queue/Cache/Cable, Hotwire with
|
|
96
|
+
importmap/Propshaft, reversible migrations, and tests in the framework the app already uses. It also covers a
|
|
97
|
+
paired Next.js frontend. Install it into your app:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
rails-agent-kit install-skills --root /path/to/your/rails/app # -> .cursor/skills/rails-8-conventions/SKILL.md
|
|
101
|
+
rails-agent-kit install-skills --root /path/to/your/rails/app --agent claude # -> .claude/skills/...
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Running it again is safe. It won't overwrite a skill you've edited unless you pass `--force`.
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
bundle install
|
|
110
|
+
bundle exec rake test
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Tests use small fixture apps in `test/fixtures` (a `schema.rb`, a `structure.sql`, captured `rails routes`
|
|
114
|
+
output and a stand-in `bin/rails`), plus an end-to-end test that drives `exe/rails-agent-kit` over stdio JSON-RPC.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT — see [LICENSE](LICENSE).
|
data/exe/rails-agent-kit
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "../lib/rails_agent_kit"
|
|
6
|
+
|
|
7
|
+
if ARGV.first == "install-skills"
|
|
8
|
+
ARGV.shift
|
|
9
|
+
opts = { root: Dir.pwd, agent: "cursor", force: false }
|
|
10
|
+
skills_parser = OptionParser.new do |o|
|
|
11
|
+
o.banner = "Usage: rails-agent-kit install-skills [--root PATH] [--agent cursor|claude] [--force] [SKILL...]\n\n" \
|
|
12
|
+
"Copies the bundled agent skills into the app (e.g. .cursor/skills/<name>/SKILL.md)."
|
|
13
|
+
o.on("--root PATH", "Rails app root (default: current directory)") { |v| opts[:root] = v }
|
|
14
|
+
o.on("--agent AGENT", "cursor (default) or claude") { |v| opts[:agent] = v }
|
|
15
|
+
o.on("--force", "Overwrite skills you have edited locally") { opts[:force] = true }
|
|
16
|
+
o.on("-h", "--help", "Show this help") do
|
|
17
|
+
puts o
|
|
18
|
+
exit
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
skills_parser.parse!(ARGV)
|
|
24
|
+
installer = RailsAgentKit::SkillInstaller.new(opts[:root], agent: opts[:agent], force: opts[:force])
|
|
25
|
+
results = ARGV.empty? ? installer.install : installer.install(ARGV)
|
|
26
|
+
rescue OptionParser::ParseError, RailsAgentKit::Error => e
|
|
27
|
+
warn "rails-agent-kit: #{e.message}"
|
|
28
|
+
exit 64
|
|
29
|
+
end
|
|
30
|
+
results.each do |r|
|
|
31
|
+
note = r.status == :skipped ? " (differs from bundled copy; use --force to overwrite)" : ""
|
|
32
|
+
puts "#{r.status.to_s.ljust(9)} #{r.path}#{note}"
|
|
33
|
+
end
|
|
34
|
+
exit 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
options = { root: Dir.pwd, routes_file: nil }
|
|
38
|
+
parser = OptionParser.new do |o|
|
|
39
|
+
o.banner = "Usage: rails-agent-kit [--root PATH] [--routes-file FILE]\n" \
|
|
40
|
+
" rails-agent-kit install-skills --help\n\n" \
|
|
41
|
+
"Starts an MCP server on stdio exposing the Rails app's schema and routes."
|
|
42
|
+
o.on("--root PATH", "Rails app root (default: current directory)") { |v| options[:root] = v }
|
|
43
|
+
o.on("--routes-file FILE", "Use a saved `bin/rails routes --expanded` dump instead of booting the app") { |v| options[:routes_file] = v }
|
|
44
|
+
o.on("-v", "--version", "Print version") do
|
|
45
|
+
puts RailsAgentKit::VERSION
|
|
46
|
+
exit
|
|
47
|
+
end
|
|
48
|
+
o.on("-h", "--help", "Show this help") do
|
|
49
|
+
puts o
|
|
50
|
+
exit
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
parser.parse!(ARGV)
|
|
56
|
+
rescue OptionParser::ParseError => e
|
|
57
|
+
warn e.message
|
|
58
|
+
warn parser
|
|
59
|
+
exit 64
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
root = File.expand_path(options[:root])
|
|
63
|
+
unless File.directory?(root)
|
|
64
|
+
warn "rails-agent-kit: --root #{root} is not a directory"
|
|
65
|
+
exit 66
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
require_relative "../lib/rails_agent_kit/server"
|
|
69
|
+
RailsAgentKit::Server.run_stdio(root: root, routes_file: options[:routes_file])
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
module RailsAgentKit
|
|
9
|
+
# Lists routes for the Rails app at `root`.
|
|
10
|
+
#
|
|
11
|
+
# Listing routes needs a booted app, so this shells out to
|
|
12
|
+
# `bin/rails routes --expanded` in the app's own bundle. When that is
|
|
13
|
+
# not possible (no bin/rails, boot error, timeout) it degrades gracefully:
|
|
14
|
+
# it says why and returns the raw config/routes.rb so the agent can still read it.
|
|
15
|
+
# A pre-generated routes dump can be supplied with `routes_file:` to skip booting.
|
|
16
|
+
class RoutesInspector
|
|
17
|
+
DEFAULT_TIMEOUT = 60
|
|
18
|
+
|
|
19
|
+
def initialize(root, routes_file: nil, timeout: nil, command: nil)
|
|
20
|
+
@root = File.expand_path(root)
|
|
21
|
+
@routes_file = routes_file && File.expand_path(routes_file, @root)
|
|
22
|
+
@timeout = (timeout || ENV.fetch("RAILS_AGENT_KIT_ROUTES_TIMEOUT", DEFAULT_TIMEOUT)).to_f
|
|
23
|
+
@command = command
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(filter: nil)
|
|
27
|
+
output, source = fetch_output
|
|
28
|
+
routes = RoutesParser.parse(output)
|
|
29
|
+
if routes.empty?
|
|
30
|
+
return fallback("`#{source}` produced no parseable routes " \
|
|
31
|
+
"(expected `bin/rails routes --expanded` output).")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
matched = apply_filter(routes, filter)
|
|
35
|
+
{ source: source, total: routes.size, count: matched.size, routes: matched }
|
|
36
|
+
rescue RoutesUnavailable => e
|
|
37
|
+
fallback(e.message)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class RoutesUnavailable < StandardError; end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def fetch_output
|
|
45
|
+
if @routes_file
|
|
46
|
+
raise RoutesUnavailable, "Routes file not found: #{@routes_file}" unless File.file?(@routes_file)
|
|
47
|
+
|
|
48
|
+
return [File.read(@routes_file), File.basename(@routes_file)]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
cmd = @command || default_command
|
|
52
|
+
[run(cmd), cmd.join(" ")]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def default_command
|
|
56
|
+
custom = ENV.fetch("RAILS_AGENT_KIT_ROUTES_COMMAND", "").strip
|
|
57
|
+
return Shellwords.split(custom) unless custom.empty?
|
|
58
|
+
|
|
59
|
+
bin_rails = File.join(@root, "bin", "rails")
|
|
60
|
+
raise RoutesUnavailable, "No bin/rails found in #{@root}; cannot boot the app to list routes." unless File.file?(bin_rails)
|
|
61
|
+
|
|
62
|
+
[RbConfig.ruby, "bin/rails", "routes", "--expanded"]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def run(cmd)
|
|
66
|
+
with_app_env do
|
|
67
|
+
Open3.popen3(*cmd, chdir: @root, pgroup: true) do |stdin, stdout, stderr, wait|
|
|
68
|
+
stdin.close
|
|
69
|
+
out_reader = reader(stdout)
|
|
70
|
+
err_reader = reader(stderr)
|
|
71
|
+
unless wait.join(@timeout)
|
|
72
|
+
kill(wait.pid)
|
|
73
|
+
raise RoutesUnavailable, "`#{cmd.join(' ')}` timed out after #{@timeout.to_i}s while booting the app."
|
|
74
|
+
end
|
|
75
|
+
out = out_reader.value
|
|
76
|
+
err = err_reader.value
|
|
77
|
+
unless wait.value.success?
|
|
78
|
+
raise RoutesUnavailable, "`#{cmd.join(' ')}` failed (exit #{wait.value.exitstatus}): #{tail(err.empty? ? out : err)}"
|
|
79
|
+
end
|
|
80
|
+
out
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
rescue SystemCallError => e
|
|
84
|
+
raise RoutesUnavailable, "Could not run `#{cmd.join(' ')}`: #{e.message}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Run the app with its own Gemfile, not this gem's bundle.
|
|
88
|
+
def with_app_env(&block)
|
|
89
|
+
if defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env)
|
|
90
|
+
Bundler.with_unbundled_env(&block)
|
|
91
|
+
else
|
|
92
|
+
yield
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def reader(io)
|
|
97
|
+
Thread.new do
|
|
98
|
+
Thread.current.report_on_exception = false
|
|
99
|
+
io.read
|
|
100
|
+
rescue IOError
|
|
101
|
+
""
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Kill the whole process group (bin/rails may spawn spring or other children).
|
|
106
|
+
def kill(pid)
|
|
107
|
+
Process.kill("TERM", -pid)
|
|
108
|
+
sleep 0.2
|
|
109
|
+
Process.kill("KILL", -pid)
|
|
110
|
+
rescue SystemCallError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def tail(text, lines: 15)
|
|
115
|
+
text.to_s.lines.last(lines).join.strip
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def apply_filter(routes, filter)
|
|
119
|
+
return routes if filter.nil? || filter.to_s.strip.empty?
|
|
120
|
+
|
|
121
|
+
needle = filter.to_s.downcase
|
|
122
|
+
routes.select do |r|
|
|
123
|
+
[r[:name], r[:path], r[:controller_action], r[:verb]].compact.any? { |v| v.downcase.include?(needle) }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def fallback(reason)
|
|
128
|
+
routes_rb = File.join(@root, "config", "routes.rb")
|
|
129
|
+
result = {
|
|
130
|
+
routes_unavailable: true,
|
|
131
|
+
message: "Could not list routes: #{reason} Falling back to the raw config/routes.rb " \
|
|
132
|
+
"(no route expansion, helpers or engine routes). Tip: run " \
|
|
133
|
+
"`bin/rails routes --expanded > tmp/routes.txt` and start the server with --routes-file tmp/routes.txt."
|
|
134
|
+
}
|
|
135
|
+
if File.file?(routes_rb)
|
|
136
|
+
result[:fallback_source] = "config/routes.rb"
|
|
137
|
+
result[:routes_rb] = File.read(routes_rb)
|
|
138
|
+
else
|
|
139
|
+
result[:message] += " config/routes.rb was not found either."
|
|
140
|
+
end
|
|
141
|
+
result
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentKit
|
|
4
|
+
# Parses the output of `bin/rails routes --expanded` (preferred) or the
|
|
5
|
+
# default table output of `bin/rails routes`.
|
|
6
|
+
class RoutesParser
|
|
7
|
+
HEADER = /\A--\[\s*Route\s+\d+\s*\]-*\s*\z/
|
|
8
|
+
FIELD = /\A([^|]+?)\s*\|\s?(.*)\z/
|
|
9
|
+
KEYS = {
|
|
10
|
+
"Prefix" => :name,
|
|
11
|
+
"Verb" => :verb,
|
|
12
|
+
"URI" => :path,
|
|
13
|
+
"Controller#Action" => :action,
|
|
14
|
+
"Source Location" => :source_location
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def self.parse(output)
|
|
18
|
+
new(output).parse
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(output)
|
|
22
|
+
@output = output.to_s
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
TABLE_HEADER = /\A\s*Prefix\s+Verb\s+URI Pattern\s+Controller#Action/
|
|
26
|
+
|
|
27
|
+
def parse
|
|
28
|
+
lines = @output.lines.map(&:chomp)
|
|
29
|
+
header = lines.find { |l| TABLE_HEADER.match?(l) }
|
|
30
|
+
return parse_table(lines, header) if header && lines.none? { |l| HEADER.match?(l.strip) }
|
|
31
|
+
|
|
32
|
+
parse_expanded
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def parse_table(lines, header)
|
|
38
|
+
verb_at = header.index("Verb")
|
|
39
|
+
uri_at = header.index("URI Pattern")
|
|
40
|
+
action_at = header.index("Controller#Action")
|
|
41
|
+
lines.drop(lines.index(header) + 1).filter_map do |line|
|
|
42
|
+
next if line.strip.empty? || line.length <= uri_at
|
|
43
|
+
|
|
44
|
+
# Long values can push later columns right, so split the tail on whitespace.
|
|
45
|
+
name = line[0...verb_at].strip
|
|
46
|
+
verb = line[verb_at...uri_at].strip
|
|
47
|
+
path, action = line[uri_at..].strip.split(/\s+/, 2)
|
|
48
|
+
next if path.nil? || !path.start_with?("/")
|
|
49
|
+
|
|
50
|
+
normalize({ name: name.empty? ? nil : name, verb: verb, path: path, action: action&.strip })
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_expanded
|
|
55
|
+
routes = []
|
|
56
|
+
current = nil
|
|
57
|
+
@output.each_line do |raw|
|
|
58
|
+
line = raw.chomp
|
|
59
|
+
if HEADER.match?(line.strip)
|
|
60
|
+
routes << current if current
|
|
61
|
+
current = {}
|
|
62
|
+
elsif current && (m = FIELD.match(line))
|
|
63
|
+
key = KEYS[m[1].strip] || m[1].strip.downcase.gsub(/\W+/, "_").to_sym
|
|
64
|
+
value = m[2].strip
|
|
65
|
+
current[key] = value.empty? ? nil : value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
routes << current if current
|
|
69
|
+
routes.map { |r| normalize(r) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def normalize(route)
|
|
73
|
+
route[:verb] = route[:verb].to_s.empty? ? nil : route[:verb]
|
|
74
|
+
if route[:action]&.include?("#")
|
|
75
|
+
controller, action = route[:action].split("#", 2)
|
|
76
|
+
route[:controller] = controller
|
|
77
|
+
route[:action] = action.split(/\s/).first
|
|
78
|
+
route[:controller_action] = "#{controller}##{route[:action]}"
|
|
79
|
+
end
|
|
80
|
+
route.compact
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentKit
|
|
4
|
+
# Reads the schema of a Rails app at `root` without booting it.
|
|
5
|
+
# Prefers db/schema.rb, falls back to db/structure.sql.
|
|
6
|
+
class SchemaInspector
|
|
7
|
+
def initialize(root)
|
|
8
|
+
@root = File.expand_path(root)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Returns a Hash with :source, :tables, ... or :error.
|
|
12
|
+
def call(table: nil)
|
|
13
|
+
schema_rb = File.join(@root, "db", "schema.rb")
|
|
14
|
+
structure_sql = File.join(@root, "db", "structure.sql")
|
|
15
|
+
|
|
16
|
+
result =
|
|
17
|
+
if File.file?(schema_rb)
|
|
18
|
+
SchemaParser.parse_file(schema_rb).to_h.merge(source: "db/schema.rb")
|
|
19
|
+
elsif File.file?(structure_sql)
|
|
20
|
+
StructureSqlParser.parse_file(structure_sql).merge(
|
|
21
|
+
source: "db/structure.sql",
|
|
22
|
+
note: "Parsed from db/structure.sql on a best-effort basis; column types are raw SQL and " \
|
|
23
|
+
"foreign keys/constraints are not extracted. db/schema.rb gives more precise results."
|
|
24
|
+
)
|
|
25
|
+
else
|
|
26
|
+
return { error: "No db/schema.rb or db/structure.sql found under #{@root}. " \
|
|
27
|
+
"Run `bin/rails db:schema:dump` in the app, or pass --root pointing at the Rails app." }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
filter(result, table)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def filter(result, table)
|
|
36
|
+
return result if table.nil? || table.to_s.strip.empty?
|
|
37
|
+
|
|
38
|
+
tables = result[:tables].select { |t| t[:name] == table.to_s }
|
|
39
|
+
if tables.empty?
|
|
40
|
+
names = result[:tables].map { |t| t[:name] }
|
|
41
|
+
return { error: "Table #{table.inspect} not found in #{result[:source]}.", available_tables: names }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
out = result.merge(tables: tables)
|
|
45
|
+
if out.key?(:foreign_keys)
|
|
46
|
+
out[:foreign_keys] = out[:foreign_keys].select { |fk| fk[:from_table] == table.to_s || fk[:to_table] == table.to_s }
|
|
47
|
+
end
|
|
48
|
+
out
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentKit
|
|
4
|
+
# Parses db/schema.rb statically (no eval, no app boot) into tables, columns,
|
|
5
|
+
# indexes and foreign keys.
|
|
6
|
+
class SchemaParser
|
|
7
|
+
Result = Struct.new(:version, :extensions, :tables, :foreign_keys, keyword_init: true) do
|
|
8
|
+
def to_h
|
|
9
|
+
{ version: version, extensions: extensions, tables: tables, foreign_keys: foreign_keys }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
CREATE_TABLE = /\A\s*create_table\s+["']([^"']+)["'](?:\s*,\s*(.*?))?\s+do\s*\|(\w+)\|\s*\z/
|
|
14
|
+
COLUMN = /\A\s*(\w+)\.(\w+)\s+["']([^"']+)["']\s*(?:,\s*(.*))?\z/
|
|
15
|
+
T_INDEX = /\A\s*(\w+)\.index\s+(\[[^\]]*\]|["'][^"']*["'])\s*(?:,\s*(.*))?\z/
|
|
16
|
+
ADD_INDEX = /\A\s*add_index\s+["']([^"']+)["']\s*,\s*(\[[^\]]*\]|["'][^"']*["'])\s*(?:,\s*(.*))?\z/
|
|
17
|
+
ADD_FK = /\A\s*add_foreign_key\s+["']([^"']+)["']\s*,\s*["']([^"']+)["']\s*(?:,\s*(.*))?\z/
|
|
18
|
+
EXTENSION = /\A\s*enable_extension\s+["']([^"']+)["']/
|
|
19
|
+
VERSION_RE = /define\(\s*version:\s*([\d_]+)\s*\)/
|
|
20
|
+
|
|
21
|
+
def self.parse_file(path)
|
|
22
|
+
new(File.read(path)).parse
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(source)
|
|
26
|
+
@source = source
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parse
|
|
30
|
+
tables = []
|
|
31
|
+
foreign_keys = []
|
|
32
|
+
extensions = []
|
|
33
|
+
version = @source[VERSION_RE, 1]&.delete("_")
|
|
34
|
+
current = nil
|
|
35
|
+
block_var = nil
|
|
36
|
+
|
|
37
|
+
@source.each_line do |raw|
|
|
38
|
+
line = strip_comment(raw).rstrip
|
|
39
|
+
next if line.strip.empty?
|
|
40
|
+
|
|
41
|
+
if current
|
|
42
|
+
if line.strip == "end"
|
|
43
|
+
tables << current
|
|
44
|
+
current = nil
|
|
45
|
+
elsif (m = T_INDEX.match(line)) && m[1] == block_var
|
|
46
|
+
current[:indexes] << build_index(current[:name], m[2], m[3])
|
|
47
|
+
elsif (m = COLUMN.match(line)) && m[1] == block_var
|
|
48
|
+
current[:columns] << build_column(m[2], m[3], m[4])
|
|
49
|
+
end
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if (m = CREATE_TABLE.match(line))
|
|
54
|
+
opts = parse_options(m[2])
|
|
55
|
+
block_var = m[3]
|
|
56
|
+
current = { name: m[1], primary_key: primary_key_for(opts), columns: [], indexes: [] }
|
|
57
|
+
current[:comment] = opts[:comment] if opts[:comment]
|
|
58
|
+
if current[:primary_key]
|
|
59
|
+
current[:columns] << { name: current[:primary_key], type: id_type(opts), null: false, primary_key: true }
|
|
60
|
+
end
|
|
61
|
+
elsif (m = ADD_INDEX.match(line))
|
|
62
|
+
index = build_index(m[1], m[2], m[3])
|
|
63
|
+
table = tables.find { |t| t[:name] == m[1] }
|
|
64
|
+
table ? table[:indexes] << index : nil
|
|
65
|
+
elsif (m = ADD_FK.match(line))
|
|
66
|
+
opts = parse_options(m[3])
|
|
67
|
+
fk = { from_table: m[1], to_table: m[2] }
|
|
68
|
+
fk[:column] = opts[:column] || "#{singularize(m[2])}_id"
|
|
69
|
+
fk[:primary_key] = opts[:primary_key] if opts[:primary_key]
|
|
70
|
+
fk[:on_delete] = opts[:on_delete] if opts[:on_delete]
|
|
71
|
+
fk[:on_update] = opts[:on_update] if opts[:on_update]
|
|
72
|
+
fk[:name] = opts[:name] if opts[:name]
|
|
73
|
+
foreign_keys << fk
|
|
74
|
+
elsif (m = EXTENSION.match(line))
|
|
75
|
+
extensions << m[1]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Result.new(version: version, extensions: extensions, tables: tables, foreign_keys: foreign_keys)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def build_column(type, name, opts_src)
|
|
85
|
+
opts = parse_options(opts_src)
|
|
86
|
+
col = { name: name, type: type, null: opts.key?(:null) ? opts[:null] : true }
|
|
87
|
+
%i[default limit precision scale array comment].each { |k| col[k] = opts[k] if opts.key?(k) }
|
|
88
|
+
col
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_index(table, cols_src, opts_src)
|
|
92
|
+
opts = parse_options(opts_src)
|
|
93
|
+
columns = parse_value(cols_src)
|
|
94
|
+
index = { table: table, name: opts[:name], columns: Array(columns), unique: opts[:unique] == true }
|
|
95
|
+
%i[where using order].each { |k| index[k] = opts[k] if opts.key?(k) }
|
|
96
|
+
index
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def primary_key_for(opts)
|
|
100
|
+
return nil if opts[:id] == false
|
|
101
|
+
return nil if opts[:primary_key].is_a?(Array)
|
|
102
|
+
|
|
103
|
+
opts[:primary_key] || "id"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def id_type(opts)
|
|
107
|
+
case opts[:id]
|
|
108
|
+
when nil, true then "bigint"
|
|
109
|
+
else opts[:id].to_s
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def singularize(word)
|
|
114
|
+
if word.end_with?("ies") then word[0..-4] + "y"
|
|
115
|
+
elsif word.end_with?("ses") then word[0..-3]
|
|
116
|
+
elsif word.end_with?("s") then word[0..-2]
|
|
117
|
+
else word
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Removes a trailing `# comment` that is not inside a string literal.
|
|
122
|
+
def strip_comment(line)
|
|
123
|
+
in_str = nil
|
|
124
|
+
line.each_char.with_index do |ch, i|
|
|
125
|
+
if in_str
|
|
126
|
+
in_str = nil if ch == in_str && line[i - 1] != "\\"
|
|
127
|
+
elsif ch == '"' || ch == "'"
|
|
128
|
+
in_str = ch
|
|
129
|
+
elsif ch == "#"
|
|
130
|
+
return line[0...i]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
line
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Parses `key: value, key2: value2` into a symbol-keyed hash.
|
|
137
|
+
def parse_options(src)
|
|
138
|
+
return {} if src.nil? || src.strip.empty?
|
|
139
|
+
|
|
140
|
+
split_top_level(src).each_with_object({}) do |part, h|
|
|
141
|
+
if (m = part.match(/\A\s*(\w+):\s*(.*)\z/m))
|
|
142
|
+
h[m[1].to_sym] = parse_value(m[2].strip)
|
|
143
|
+
elsif (m = part.match(/\A\s*["'](\w+)["']\s*=>\s*(.*)\z/m))
|
|
144
|
+
h[m[1].to_sym] = parse_value(m[2].strip)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def parse_value(src)
|
|
150
|
+
s = src.strip
|
|
151
|
+
case s
|
|
152
|
+
when "true" then true
|
|
153
|
+
when "false" then false
|
|
154
|
+
when "nil" then nil
|
|
155
|
+
when /\A-?\d+\z/ then s.to_i
|
|
156
|
+
when /\A-?\d+\.\d+\z/ then s.to_f
|
|
157
|
+
when /\A"(.*)"\z/m then Regexp.last_match(1).gsub('\\"', '"')
|
|
158
|
+
when /\A'(.*)'\z/m then Regexp.last_match(1)
|
|
159
|
+
when /\A:(\w+)\z/ then Regexp.last_match(1)
|
|
160
|
+
when /\A\[(.*)\]\z/m then split_top_level(Regexp.last_match(1)).map { |v| parse_value(v) }
|
|
161
|
+
else s # lambdas (-> { "now()" }), constants, etc. are kept verbatim
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def split_top_level(src)
|
|
166
|
+
parts = []
|
|
167
|
+
depth = 0
|
|
168
|
+
in_str = nil
|
|
169
|
+
buf = +""
|
|
170
|
+
src.each_char.with_index do |ch, i|
|
|
171
|
+
if in_str
|
|
172
|
+
in_str = nil if ch == in_str && src[i - 1] != "\\"
|
|
173
|
+
buf << ch
|
|
174
|
+
next
|
|
175
|
+
end
|
|
176
|
+
case ch
|
|
177
|
+
when '"', "'" then in_str = ch
|
|
178
|
+
when "[", "{", "(" then depth += 1
|
|
179
|
+
when "]", "}", ")" then depth -= 1
|
|
180
|
+
when ","
|
|
181
|
+
if depth.zero?
|
|
182
|
+
parts << buf
|
|
183
|
+
buf = +""
|
|
184
|
+
next
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
buf << ch
|
|
188
|
+
end
|
|
189
|
+
parts << buf unless buf.strip.empty?
|
|
190
|
+
parts.map(&:strip)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "mcp"
|
|
5
|
+
|
|
6
|
+
module RailsAgentKit
|
|
7
|
+
# Builds the MCP server (official `mcp` Ruby SDK) exposing the app's schema and routes.
|
|
8
|
+
module Server
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def build(root:, routes_file: nil)
|
|
12
|
+
context = { root: File.expand_path(root), routes_file: routes_file }
|
|
13
|
+
MCP::Server.new(
|
|
14
|
+
name: "rails-agent-kit",
|
|
15
|
+
version: RailsAgentKit::VERSION,
|
|
16
|
+
instructions: "Ground truth about the Rails app at #{context[:root]}: use rails_schema for tables/columns/indexes " \
|
|
17
|
+
"and rails_routes for HTTP routes before guessing model attributes or URL helpers.",
|
|
18
|
+
tools: [Tools::RailsSchema, Tools::RailsRoutes],
|
|
19
|
+
server_context: context
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run_stdio(root:, routes_file: nil)
|
|
24
|
+
server = build(root: root, routes_file: routes_file)
|
|
25
|
+
MCP::Server::Transports::StdioTransport.new(server).open
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module Tools
|
|
30
|
+
# Shared helpers for tool classes.
|
|
31
|
+
module Helpers
|
|
32
|
+
def context_value(server_context, key)
|
|
33
|
+
server_context.respond_to?(:[]) ? server_context[key] : nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def json_response(data, error: false)
|
|
37
|
+
MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(data) }], error: error)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class RailsSchema < MCP::Tool
|
|
42
|
+
extend Helpers
|
|
43
|
+
|
|
44
|
+
tool_name "rails_schema"
|
|
45
|
+
description "Database schema of the Rails app (tables, columns with types/null/default, indexes, foreign keys), " \
|
|
46
|
+
"parsed from db/schema.rb (or db/structure.sql) without booting the app."
|
|
47
|
+
input_schema(
|
|
48
|
+
properties: {
|
|
49
|
+
table: { type: "string", description: "Only return this table (exact name). Omit for all tables." }
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
53
|
+
|
|
54
|
+
def self.call(server_context:, table: nil)
|
|
55
|
+
root = context_value(server_context, :root) || Dir.pwd
|
|
56
|
+
result = SchemaInspector.new(root).call(table: table)
|
|
57
|
+
json_response(result, error: result.key?(:error))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class RailsRoutes < MCP::Tool
|
|
62
|
+
extend Helpers
|
|
63
|
+
|
|
64
|
+
tool_name "rails_routes"
|
|
65
|
+
description "HTTP routes of the Rails app (verb, path, controller#action, route name). Runs " \
|
|
66
|
+
"`bin/rails routes --expanded` (boots the app); if that fails it explains why and returns config/routes.rb."
|
|
67
|
+
input_schema(
|
|
68
|
+
properties: {
|
|
69
|
+
filter: { type: "string", description: "Case-insensitive substring matched against name, path, verb and controller#action." }
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
73
|
+
|
|
74
|
+
def self.call(server_context:, filter: nil)
|
|
75
|
+
root = context_value(server_context, :root) || Dir.pwd
|
|
76
|
+
routes_file = context_value(server_context, :routes_file)
|
|
77
|
+
json_response(RoutesInspector.new(root, routes_file: routes_file).call(filter: filter))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module RailsAgentKit
|
|
6
|
+
# Copies the agent skills bundled with the gem (skills/<name>/SKILL.md) into a Rails app,
|
|
7
|
+
# e.g. <root>/.cursor/skills/<name>/SKILL.md for Cursor.
|
|
8
|
+
class SkillInstaller
|
|
9
|
+
SKILLS_DIR = File.expand_path("../../skills", __dir__)
|
|
10
|
+
TARGETS = {
|
|
11
|
+
"cursor" => ".cursor/skills",
|
|
12
|
+
"claude" => ".claude/skills"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
Result = Struct.new(:skill, :path, :status, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
def self.available_skills
|
|
18
|
+
Dir.glob(File.join(SKILLS_DIR, "*", "SKILL.md")).map { |f| File.basename(File.dirname(f)) }.sort
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(root, agent: "cursor", force: false)
|
|
22
|
+
@root = File.expand_path(root)
|
|
23
|
+
@agent = agent.to_s
|
|
24
|
+
@force = force
|
|
25
|
+
raise Error, "unknown agent #{@agent.inspect} (supported: #{TARGETS.keys.join(', ')})" unless TARGETS.key?(@agent)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Installs the named skills (default: all bundled skills). Existing files that differ are left
|
|
29
|
+
# untouched unless force: true. Returns one Result per skill with status :created, :updated,
|
|
30
|
+
# :unchanged or :skipped.
|
|
31
|
+
def install(skills = self.class.available_skills)
|
|
32
|
+
skills.map do |name|
|
|
33
|
+
source = File.join(SKILLS_DIR, name, "SKILL.md")
|
|
34
|
+
raise Error, "unknown skill #{name.inspect} (available: #{self.class.available_skills.join(', ')})" unless File.file?(source)
|
|
35
|
+
|
|
36
|
+
install_one(name, source)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def install_one(name, source)
|
|
43
|
+
dest = File.join(@root, TARGETS.fetch(@agent), name, "SKILL.md")
|
|
44
|
+
content = File.read(source)
|
|
45
|
+
status =
|
|
46
|
+
if !File.exist?(dest) then :created
|
|
47
|
+
elsif File.read(dest) == content then :unchanged
|
|
48
|
+
elsif @force then :updated
|
|
49
|
+
else :skipped
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if %i[created updated].include?(status)
|
|
53
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
54
|
+
File.write(dest, content)
|
|
55
|
+
end
|
|
56
|
+
Result.new(skill: name, path: dest, status: status)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentKit
|
|
4
|
+
# Best-effort parser for db/structure.sql (CREATE TABLE / CREATE INDEX).
|
|
5
|
+
# SQL dumps vary a lot between adapters, so results are approximate.
|
|
6
|
+
class StructureSqlParser
|
|
7
|
+
CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?((?:[\w"`]+\.)?[\w"`]+)\s*\((.*?)\)\s*;/mi
|
|
8
|
+
CREATE_INDEX = /CREATE\s+(UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w"`]+)\s+ON\s+((?:[\w"`]+\.)?[\w"`]+)(?:\s+USING\s+\w+)?\s*\((.*?)\)\s*(?:WHERE\s+(.*?))?;/mi
|
|
9
|
+
CONSTRAINT_WORDS = /\A(CONSTRAINT|PRIMARY|FOREIGN|UNIQUE|CHECK|KEY|INDEX|EXCLUDE)\b/i
|
|
10
|
+
|
|
11
|
+
def self.parse_file(path)
|
|
12
|
+
new(File.read(path)).parse
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(source)
|
|
16
|
+
@source = source.gsub(/--[^\n]*/, "")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parse
|
|
20
|
+
tables = @source.scan(CREATE_TABLE).map do |name, body|
|
|
21
|
+
columns = split_columns(body).filter_map do |defn|
|
|
22
|
+
next if defn.match?(CONSTRAINT_WORDS)
|
|
23
|
+
|
|
24
|
+
m = defn.match(/\A([\w"`]+)\s+(.+)\z/m) or next
|
|
25
|
+
rest = m[2]
|
|
26
|
+
type = rest.sub(/\s+(NOT\s+NULL|NULL|DEFAULT|PRIMARY|REFERENCES|UNIQUE|CHECK|COLLATE|GENERATED|CONSTRAINT|AUTO_INCREMENT)\b.*\z/mi, "").strip
|
|
27
|
+
{
|
|
28
|
+
name: unquote(m[1]),
|
|
29
|
+
type: type,
|
|
30
|
+
null: !rest.match?(/NOT\s+NULL/i) && !rest.match?(/PRIMARY\s+KEY/i),
|
|
31
|
+
primary_key: rest.match?(/PRIMARY\s+KEY/i) || nil
|
|
32
|
+
}.compact
|
|
33
|
+
end
|
|
34
|
+
{ name: unquote(name.split(".").last), columns: columns, indexes: [] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@source.scan(CREATE_INDEX).each do |unique, idx_name, table, cols, where|
|
|
38
|
+
t = tables.find { |tt| tt[:name] == unquote(table.split(".").last) }
|
|
39
|
+
next unless t
|
|
40
|
+
|
|
41
|
+
idx = { table: t[:name], name: unquote(idx_name), columns: cols.split(",").map { |c| unquote(c.strip.split(/\s+/).first) }, unique: !unique.nil? }
|
|
42
|
+
idx[:where] = where.strip if where
|
|
43
|
+
t[:indexes] << idx
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
{ tables: tables }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def unquote(s)
|
|
52
|
+
s.to_s.delete('"`')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def split_columns(body)
|
|
56
|
+
parts = []
|
|
57
|
+
depth = 0
|
|
58
|
+
buf = +""
|
|
59
|
+
body.each_char do |ch|
|
|
60
|
+
depth += 1 if ch == "("
|
|
61
|
+
depth -= 1 if ch == ")"
|
|
62
|
+
if ch == "," && depth.zero?
|
|
63
|
+
parts << buf.strip
|
|
64
|
+
buf = +""
|
|
65
|
+
else
|
|
66
|
+
buf << ch
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
parts << buf.strip unless buf.strip.empty?
|
|
70
|
+
parts
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rails_agent_kit/version"
|
|
4
|
+
require_relative "rails_agent_kit/schema_parser"
|
|
5
|
+
require_relative "rails_agent_kit/structure_sql_parser"
|
|
6
|
+
require_relative "rails_agent_kit/schema_inspector"
|
|
7
|
+
require_relative "rails_agent_kit/routes_parser"
|
|
8
|
+
require_relative "rails_agent_kit/routes_inspector"
|
|
9
|
+
require_relative "rails_agent_kit/skill_installer"
|
|
10
|
+
|
|
11
|
+
# Gives coding agents ground truth about a Rails app (schema, routes) over MCP.
|
|
12
|
+
module RailsAgentKit
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
# The MCP server is loaded lazily so the parsers can be used without the `mcp` gem.
|
|
16
|
+
def self.server(root: Dir.pwd)
|
|
17
|
+
require_relative "rails_agent_kit/server"
|
|
18
|
+
Server.build(root: root)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-8-conventions
|
|
3
|
+
description: Rails 8 conventions and a ground-truth-first workflow. Use when adding or changing models, migrations, controllers, routes, views, jobs, mailers or tests in a Rails 8 app, so changes follow the app's actual schema, routes and stack instead of guesses.
|
|
4
|
+
paths:
|
|
5
|
+
- "app/**"
|
|
6
|
+
- "config/**"
|
|
7
|
+
- "db/**"
|
|
8
|
+
- "lib/**"
|
|
9
|
+
- "test/**"
|
|
10
|
+
- "spec/**"
|
|
11
|
+
- "Gemfile"
|
|
12
|
+
metadata:
|
|
13
|
+
source: rails-agent-kit
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Rails 8 conventions
|
|
17
|
+
|
|
18
|
+
Follow the app as it is, not Rails as you remember it. Check the facts first, then
|
|
19
|
+
change the code the way this app already does things.
|
|
20
|
+
|
|
21
|
+
## 1. Check the facts before you edit
|
|
22
|
+
|
|
23
|
+
- **Schema:** call the `rails_schema` MCP tool (pass `table:` to get one table) before
|
|
24
|
+
you write a model attribute, query, validation, fixture or migration. Without the
|
|
25
|
+
tool, read `db/schema.rb` (or `db/structure.sql`). Never guess a column name, type,
|
|
26
|
+
nullability or default.
|
|
27
|
+
- **Routes:** call the `rails_routes` MCP tool (pass `filter:`) before you use a
|
|
28
|
+
`*_path`/`*_url` helper, link, form `url:` or redirect. Without it, read
|
|
29
|
+
`config/routes.rb` or run `bin/rails routes -g <term>`.
|
|
30
|
+
- **Stack:** read `Gemfile.lock` before you pick a library. Use what the app already
|
|
31
|
+
uses (RSpec or Minitest, Sidekiq or Solid Queue, Pundit, Devise or the built-in
|
|
32
|
+
auth generator, importmap or jsbundling). Don't add a gem when the app or Rails
|
|
33
|
+
already covers it.
|
|
34
|
+
- **Local patterns:** before you add a model, controller or job, open one or two
|
|
35
|
+
nearby files and copy their structure: concerns, base classes, naming, and how
|
|
36
|
+
they render and respond.
|
|
37
|
+
|
|
38
|
+
## 2. Rails 8 defaults (unless the Gemfile says otherwise)
|
|
39
|
+
|
|
40
|
+
- **Strong params:** use `params.expect(post: [:title, :body])` (Rails 8). It returns
|
|
41
|
+
400 on a malformed payload instead of raising a 500. Use
|
|
42
|
+
`params.require(...).permit(...)` only if the codebase still uses it.
|
|
43
|
+
- **Authentication:** `bin/rails generate authentication` gives you the `Session`
|
|
44
|
+
model, the `Authentication` concern, `Current.user`, `allow_unauthenticated_access`
|
|
45
|
+
and `require_authentication`. Use these when they're present. Don't add Devise
|
|
46
|
+
alongside them.
|
|
47
|
+
- **Rate limiting:** `rate_limit to: 10, within: 3.minutes, only: :create` in the
|
|
48
|
+
controller. You don't need Rack::Attack for simple cases.
|
|
49
|
+
- **Models:** `enum :status, { draft: 0, published: 1 }` (keyword-arg form),
|
|
50
|
+
`normalizes :email, with: ->(e) { e.strip.downcase }`, `generates_token_for`,
|
|
51
|
+
`has_secure_password`. Put validations in the model *and* constraints in the DB:
|
|
52
|
+
`null: false`, unique indexes, foreign keys.
|
|
53
|
+
- **Background jobs:** Solid Queue is the default (`config/queue.yml`). Recurring
|
|
54
|
+
work goes in `config/recurring.yml`, not cron. Inherit from `ApplicationJob`, pass
|
|
55
|
+
records or ids (GlobalID), and make `perform` idempotent. Use `perform_later`.
|
|
56
|
+
Call `perform_now` only in tests or consoles.
|
|
57
|
+
- **Cache and Cable:** Solid Cache and Solid Cable are database-backed and may use
|
|
58
|
+
their own databases (`db/cache_schema.rb`, `db/queue_schema.rb`,
|
|
59
|
+
`db/cable_schema.rb`). Don't move them to Redis on your own.
|
|
60
|
+
- **Frontend:** Hotwire is the default. Use Turbo Frames/Streams for partial updates
|
|
61
|
+
and Stimulus controllers in `app/javascript/controllers`. Assets go through
|
|
62
|
+
Propshaft, and JS through importmap (`bin/importmap pin <pkg>`) unless the app uses
|
|
63
|
+
jsbundling. Don't add a SPA framework into `app/`.
|
|
64
|
+
- **Deploy:** Kamal 2 (`config/deploy.yml`) and Thruster. Secrets live in
|
|
65
|
+
`config/credentials` or `.kamal/secrets`, never in code.
|
|
66
|
+
|
|
67
|
+
## 3. Migrations
|
|
68
|
+
|
|
69
|
+
- Generate them: `bin/rails generate migration AddPublishedAtToPosts published_at:datetime:index`.
|
|
70
|
+
Never hand-pick a timestamp.
|
|
71
|
+
- Make them reversible (`change`, or `up`/`down`), and add indexes and foreign keys
|
|
72
|
+
(`t.references :user, null: false, foreign_key: true`).
|
|
73
|
+
- Never edit a migration that has already run on a shared branch. Add a new one.
|
|
74
|
+
- Run `bin/rails db:migrate` and commit the updated `db/schema.rb` (or
|
|
75
|
+
`structure.sql`) with the migration. After that, `rails_schema` is the source of
|
|
76
|
+
truth.
|
|
77
|
+
|
|
78
|
+
## 4. Controllers, routes and views
|
|
79
|
+
|
|
80
|
+
- Prefer RESTful `resources` with the seven standard actions. When you need a custom
|
|
81
|
+
action, add a nested resource or a new controller instead
|
|
82
|
+
(`resource :publication, only: :create`).
|
|
83
|
+
- Keep controllers thin: load, authorize, call the model, respond. Make
|
|
84
|
+
`before_action :set_post` match the existing style.
|
|
85
|
+
- Respond to `format.html` and `format.turbo_stream` when the app uses Turbo. On a
|
|
86
|
+
failed form submit, render with `status: :unprocessable_entity`. Use
|
|
87
|
+
`redirect_to ..., status: :see_other` after a destroy.
|
|
88
|
+
- Use the route helpers confirmed in step 1. Don't hard-code a URL string.
|
|
89
|
+
|
|
90
|
+
## 5. Tests
|
|
91
|
+
|
|
92
|
+
- Use the existing framework: `test/` (Minitest, fixtures) or `spec/` (RSpec,
|
|
93
|
+
factories). Don't add a second one.
|
|
94
|
+
- Add or update a test with every behaviour change: model tests for validations and
|
|
95
|
+
scopes, request/integration tests for endpoints, job tests for jobs.
|
|
96
|
+
- Run the narrowest test first (`bin/rails test test/models/post_test.rb`), then the
|
|
97
|
+
suite (`bin/rails test`, or `bin/rails test:all` with system tests). Run
|
|
98
|
+
`bin/rubocop` if it exists.
|
|
99
|
+
|
|
100
|
+
## 6. Paired Next.js frontend (if there is one)
|
|
101
|
+
|
|
102
|
+
If the Rails app is an API for a separate Next.js app (`config.api_only = true`, JSON
|
|
103
|
+
controllers, a sibling `web/` or `frontend/` directory):
|
|
104
|
+
|
|
105
|
+
- Treat JSON responses as a contract. Add fields rather than rename or remove them,
|
|
106
|
+
and update the Next.js types and fetchers in the same change.
|
|
107
|
+
- Check `rails_routes` for the exact path and verb before you write a fetch call
|
|
108
|
+
on the frontend.
|
|
109
|
+
- Keep CORS (`config/initializers/cors.rb`) and auth (cookie vs token) consistent
|
|
110
|
+
with what the frontend already does.
|
|
111
|
+
|
|
112
|
+
## Before you finish
|
|
113
|
+
|
|
114
|
+
- [ ] Every column, table and route helper you used is confirmed by `rails_schema` or `rails_routes` (or the files behind them).
|
|
115
|
+
- [ ] Migrations are reversible and `db/schema.rb` is updated.
|
|
116
|
+
- [ ] Tests were added or updated, and they pass.
|
|
117
|
+
- [ ] You added no new gem or framework that the app's existing stack already covers.
|
metadata
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails-agent-kit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andrii Furmanets
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: mcp
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.8'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.8'
|
|
26
|
+
description: rails-agent-kit is a small Model Context Protocol (stdio) server. It
|
|
27
|
+
exposes a Rails app's database schema (parsed from db/schema.rb without booting
|
|
28
|
+
the app) and its routes to coding agents such as Cursor and Claude Code, and ships
|
|
29
|
+
a Rails 8 conventions skill for Cursor/Claude (installed with `rails-agent-kit install-skills`).
|
|
30
|
+
executables:
|
|
31
|
+
- rails-agent-kit
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- CHANGELOG.md
|
|
36
|
+
- LICENSE
|
|
37
|
+
- README.md
|
|
38
|
+
- exe/rails-agent-kit
|
|
39
|
+
- lib/rails_agent_kit.rb
|
|
40
|
+
- lib/rails_agent_kit/routes_inspector.rb
|
|
41
|
+
- lib/rails_agent_kit/routes_parser.rb
|
|
42
|
+
- lib/rails_agent_kit/schema_inspector.rb
|
|
43
|
+
- lib/rails_agent_kit/schema_parser.rb
|
|
44
|
+
- lib/rails_agent_kit/server.rb
|
|
45
|
+
- lib/rails_agent_kit/skill_installer.rb
|
|
46
|
+
- lib/rails_agent_kit/structure_sql_parser.rb
|
|
47
|
+
- lib/rails_agent_kit/version.rb
|
|
48
|
+
- skills/rails-8-conventions/SKILL.md
|
|
49
|
+
homepage: https://github.com/afurm/rails-agent-kit
|
|
50
|
+
licenses:
|
|
51
|
+
- MIT
|
|
52
|
+
metadata:
|
|
53
|
+
homepage_uri: https://github.com/afurm/rails-agent-kit
|
|
54
|
+
source_code_uri: https://github.com/afurm/rails-agent-kit
|
|
55
|
+
changelog_uri: https://github.com/afurm/rails-agent-kit/blob/main/CHANGELOG.md
|
|
56
|
+
bug_tracker_uri: https://github.com/afurm/rails-agent-kit/issues
|
|
57
|
+
rubygems_mfa_required: 'true'
|
|
58
|
+
rdoc_options: []
|
|
59
|
+
require_paths:
|
|
60
|
+
- lib
|
|
61
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '3.2'
|
|
66
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '0'
|
|
71
|
+
requirements: []
|
|
72
|
+
rubygems_version: 3.6.7
|
|
73
|
+
specification_version: 4
|
|
74
|
+
summary: MCP server and agent skills that give coding agents ground truth about a
|
|
75
|
+
Rails app's schema and routes.
|
|
76
|
+
test_files: []
|