tidewave 0.1.3 → 0.2.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/README.md +25 -26
- data/lib/tidewave/configuration.rb +6 -5
- data/lib/tidewave/database_adapter.rb +33 -0
- data/lib/tidewave/database_adapters/active_record.rb +39 -0
- data/lib/tidewave/database_adapters/sequel.rb +37 -0
- data/lib/tidewave/exceptions_middleware.rb +88 -0
- data/lib/tidewave/file_tracker.rb +11 -10
- data/lib/tidewave/middleware.rb +182 -0
- data/lib/tidewave/railtie.rb +32 -32
- data/lib/tidewave/tools/edit_project_file.rb +1 -1
- data/lib/tidewave/tools/execute_sql_query.rb +2 -20
- data/lib/tidewave/tools/get_docs.rb +64 -0
- data/lib/tidewave/tools/get_models.rb +21 -10
- data/lib/tidewave/tools/get_source_location.rb +9 -8
- data/lib/tidewave/tools/list_project_files.rb +5 -5
- data/lib/tidewave/tools/project_eval.rb +49 -9
- data/lib/tidewave/version.rb +1 -1
- data/lib/tidewave.rb +6 -3
- metadata +8 -4
- data/lib/tidewave/tools/grep_project_files.rb +0 -110
- data/lib/tidewave/tools/package_search.rb +0 -43
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d708afbfcf6d89cc1456bd7bcefdc660952571cda0c6635b8c6b14b8fa31d91e
|
4
|
+
data.tar.gz: da923b2f69608fe95eb11f76795cef1598f9cc24941afe50081afc4b76c66cfa
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9b0eaa2a77690e4dda1720e5c2c84b02ea93d9b69955e2e430745da483d30014ba3fc5d59dc40c92b0ccdedd4413f4198012813a33f2332fd7ca08a9fac79fc1
|
7
|
+
data.tar.gz: 501103bf39a69932a408ebf9e455640fc8fb97a507163d6b4f42faf27aead8811927ecc249858978c8c5a46213dbe5b1d8635ade60817f546645ec99fa4c203a
|
data/README.md
CHANGED
@@ -6,17 +6,6 @@ assistant to your web framework runtime via [MCP](https://modelcontextprotocol.i
|
|
6
6
|
|
7
7
|
[See our website](https://tidewave.ai) for more information.
|
8
8
|
|
9
|
-
## Key Features
|
10
|
-
|
11
|
-
Tidewave provides tools that allow your LLM of choice to:
|
12
|
-
|
13
|
-
- inspect your application logs to help debugging errors
|
14
|
-
- execute SQL queries and inspect your database
|
15
|
-
- evaluate custom Ruby code in the context of your project
|
16
|
-
- find Rubygems packages and source code locations
|
17
|
-
|
18
|
-
and more.
|
19
|
-
|
20
9
|
## Installation
|
21
10
|
|
22
11
|
You can install Tidewave by adding the `tidewave` gem to the development group in your Gemfile:
|
@@ -29,37 +18,47 @@ Tidewave will now run on the same port as your regular Rails application.
|
|
29
18
|
In particular, the MCP is located by default at http://localhost:3000/tidewave/mcp.
|
30
19
|
[You must configure your editor and AI assistants accordingly](https://hexdocs.pm/tidewave/mcp.html).
|
31
20
|
|
32
|
-
##
|
21
|
+
## Troubleshooting
|
33
22
|
|
34
|
-
|
23
|
+
### Localhost requirement
|
24
|
+
|
25
|
+
Tidewave expects your web application to be running on `localhost`. If you are not running on localhost, you may need to set some additional configuration. In particular, you must configure Tidewave to allow `allow_remote_access` and [optionally configure your Rails hosts](https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization). For example, in your `config/environments/development.rb`:
|
35
26
|
|
36
27
|
```ruby
|
37
|
-
|
28
|
+
config.hosts << "company.local"
|
29
|
+
config.tidewave.allow_remote_access = true
|
38
30
|
```
|
39
31
|
|
40
|
-
|
41
|
-
|
42
|
-
* `:allowed_origins`
|
43
|
-
|
44
|
-
* `:localhost_only`
|
45
|
-
|
46
|
-
* `:allowed_ips`
|
32
|
+
If you want to use Docker for development, you either need to enable the configuration above or automatically redirect the relevant ports, as done by [devcontainers](https://code.visualstudio.com/docs/devcontainers/containers). See our [containars](https://hexdocs.pm/tidewave/containers.html) guide for more information.
|
47
33
|
|
48
|
-
|
34
|
+
### Content security policy
|
49
35
|
|
50
|
-
|
36
|
+
If you have enabled Content-Security-Policy, Tidewave will automatically enable "unsafe-eval" under `script-src` in order for contextual browser testing to work correctly.
|
51
37
|
|
52
38
|
### Production Environment
|
53
39
|
|
54
|
-
Tidewave is a powerful tool that can help you develop your web application faster and more efficiently.
|
55
|
-
However, it is important to note that Tidewave is not meant to be used in a production environment.
|
40
|
+
Tidewave is a powerful tool that can help you develop your web application faster and more efficiently. However, it is important to note that Tidewave is not meant to be used in a production environment.
|
56
41
|
|
57
|
-
Tidewave will raise an error if it is used in
|
42
|
+
Tidewave will raise an error if it is used in any environment where code reloading is disabled (which typically includes production).
|
58
43
|
|
59
44
|
### Web server requirements
|
60
45
|
|
61
46
|
Tidewave currently requires a threaded web server like Puma.
|
62
47
|
|
48
|
+
## Configuration
|
49
|
+
|
50
|
+
You may configure `tidewave` using the following syntax:
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
config.tidewave.allow_remote_access = true
|
54
|
+
```
|
55
|
+
|
56
|
+
The following options are available:
|
57
|
+
|
58
|
+
* `:allow_remote_access` - Tidewave only allows requests from localhost by default, even if your server listens on other interfaces as well. If you trust your network and need to access Tidewave from a different machine, this configuration can be set to `true`
|
59
|
+
|
60
|
+
* `:preferred_orm` - which ORM to use, either `:active_record` (default) or `:sequel`
|
61
|
+
|
63
62
|
## Acknowledgements
|
64
63
|
|
65
64
|
A thank you to Yorick Jacquin, for creating [FastMCP](https://github.com/yjacquin/fast_mcp) and implementing the initial version of this project.
|
@@ -2,13 +2,14 @@
|
|
2
2
|
|
3
3
|
module Tidewave
|
4
4
|
class Configuration
|
5
|
-
attr_accessor :logger, :
|
5
|
+
attr_accessor :logger, :allow_remote_access, :preferred_orm, :credentials, :client_url
|
6
6
|
|
7
7
|
def initialize
|
8
|
-
@logger =
|
9
|
-
@
|
10
|
-
@
|
11
|
-
@
|
8
|
+
@logger = nil
|
9
|
+
@allow_remote_access = true
|
10
|
+
@preferred_orm = :active_record
|
11
|
+
@credentials = {}
|
12
|
+
@client_url = "https://tidewave.ai"
|
12
13
|
end
|
13
14
|
end
|
14
15
|
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tidewave
|
4
|
+
class DatabaseAdapter
|
5
|
+
class << self
|
6
|
+
def current
|
7
|
+
@current ||= create_adapter
|
8
|
+
end
|
9
|
+
|
10
|
+
def create_adapter
|
11
|
+
orm_type = Rails.application.config.tidewave.preferred_orm
|
12
|
+
case orm_type
|
13
|
+
when :active_record
|
14
|
+
require_relative "database_adapters/active_record"
|
15
|
+
DatabaseAdapters::ActiveRecord.new
|
16
|
+
when :sequel
|
17
|
+
require_relative "database_adapters/sequel"
|
18
|
+
DatabaseAdapters::Sequel.new
|
19
|
+
else
|
20
|
+
raise "Unknown preferred ORM: #{orm_type}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def execute_query(query, arguments = [])
|
26
|
+
raise NotImplementedError, "Subclasses must implement execute_query"
|
27
|
+
end
|
28
|
+
|
29
|
+
def get_base_class
|
30
|
+
raise NotImplementedError, "Subclasses must implement get_base_class"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tidewave
|
4
|
+
module DatabaseAdapters
|
5
|
+
class ActiveRecord < DatabaseAdapter
|
6
|
+
RESULT_LIMIT = 50
|
7
|
+
|
8
|
+
def execute_query(query, arguments = [])
|
9
|
+
conn = ::ActiveRecord::Base.connection
|
10
|
+
|
11
|
+
# Execute the query with prepared statement and arguments
|
12
|
+
if arguments.any?
|
13
|
+
result = conn.exec_query(query, "SQL", arguments)
|
14
|
+
else
|
15
|
+
result = conn.exec_query(query)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Format the result
|
19
|
+
{
|
20
|
+
columns: result.columns,
|
21
|
+
rows: result.rows.first(RESULT_LIMIT),
|
22
|
+
row_count: result.rows.length,
|
23
|
+
adapter: conn.adapter_name,
|
24
|
+
database: database_name
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
def get_base_class
|
29
|
+
::ActiveRecord::Base
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def database_name
|
35
|
+
Rails.configuration.database_configuration.dig(Rails.env, "database")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tidewave
|
4
|
+
module DatabaseAdapters
|
5
|
+
class Sequel < DatabaseAdapter
|
6
|
+
RESULT_LIMIT = 50
|
7
|
+
|
8
|
+
def execute_query(query, arguments = [])
|
9
|
+
db = ::Sequel::Model.db
|
10
|
+
|
11
|
+
# Execute the query with arguments
|
12
|
+
result = if arguments.any?
|
13
|
+
db.fetch(query, *arguments)
|
14
|
+
else
|
15
|
+
db.fetch(query)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Convert to array of hashes and extract metadata
|
19
|
+
rows = result.all
|
20
|
+
columns = rows.first&.keys || []
|
21
|
+
|
22
|
+
# Format the result similar to ActiveRecord
|
23
|
+
{
|
24
|
+
columns: columns.map(&:to_s),
|
25
|
+
rows: rows.first(RESULT_LIMIT).map(&:values),
|
26
|
+
row_count: rows.length,
|
27
|
+
adapter: db.adapter_scheme.to_s.upcase,
|
28
|
+
database: db.opts[:database]
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
def get_base_class
|
33
|
+
::Sequel::Model
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Tidewave::ExceptionsMiddleware
|
4
|
+
def initialize(app)
|
5
|
+
@app = app
|
6
|
+
end
|
7
|
+
|
8
|
+
def call(env)
|
9
|
+
request = ActionDispatch::Request.new(env)
|
10
|
+
status, headers, body = @app.call(env)
|
11
|
+
|
12
|
+
exception = request.get_header("tidewave.exception")
|
13
|
+
|
14
|
+
if exception
|
15
|
+
formatted = format_exception_html(exception, request)
|
16
|
+
body, headers = append_body(body, headers, formatted)
|
17
|
+
end
|
18
|
+
|
19
|
+
[ status, headers, body ]
|
20
|
+
rescue => error
|
21
|
+
Rails.logger.error("Failure in Tidewave::ExceptionsMiddleware, message: #{error.message}")
|
22
|
+
raise error
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def format_exception_html(exception, request)
|
28
|
+
backtrace = Rails.backtrace_cleaner.clean(exception.backtrace)
|
29
|
+
|
30
|
+
parameters = request_parameters(request)
|
31
|
+
|
32
|
+
text = exception.class.name.dup
|
33
|
+
|
34
|
+
if parameters["controller"]
|
35
|
+
text << " in #{parameters["controller"].camelize}Controller"
|
36
|
+
|
37
|
+
if parameters["action"]
|
38
|
+
text << "##{parameters["action"]}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
text << "\n\n## Message\n\n#{exception.message}"
|
43
|
+
|
44
|
+
if backtrace.any?
|
45
|
+
text << "\n\n## Backtrace\n\n#{backtrace.join("\n")}"
|
46
|
+
end
|
47
|
+
|
48
|
+
text << "\n\n"
|
49
|
+
|
50
|
+
text << <<~TEXT.chomp
|
51
|
+
## Request info
|
52
|
+
|
53
|
+
* URI: #{request.base_url + request.path}
|
54
|
+
* Query string: #{request.query_string}
|
55
|
+
|
56
|
+
## Session
|
57
|
+
|
58
|
+
#{request.session.to_hash.inspect}
|
59
|
+
TEXT
|
60
|
+
|
61
|
+
%Q(<textarea style="display: none;" data-tidewave-exception-info>#{ERB::Util.html_escape(text)}</textarea>)
|
62
|
+
end
|
63
|
+
|
64
|
+
def request_parameters(request)
|
65
|
+
request.parameters
|
66
|
+
rescue ActionController::BadRequest
|
67
|
+
{}
|
68
|
+
end
|
69
|
+
|
70
|
+
def append_body(body, headers, content)
|
71
|
+
new_body = "".dup
|
72
|
+
|
73
|
+
body.each { |part| new_body << part }
|
74
|
+
body.close if body.respond_to?(:close)
|
75
|
+
|
76
|
+
if position = new_body.rindex("</body>")
|
77
|
+
new_body.insert(position, content)
|
78
|
+
else
|
79
|
+
new_body << content
|
80
|
+
end
|
81
|
+
|
82
|
+
if headers[Rack::CONTENT_LENGTH]
|
83
|
+
headers[Rack::CONTENT_LENGTH] = new_body.bytesize.to_s
|
84
|
+
end
|
85
|
+
|
86
|
+
[ [ new_body ], headers ]
|
87
|
+
end
|
88
|
+
end
|
@@ -4,9 +4,10 @@ module Tidewave
|
|
4
4
|
module FileTracker
|
5
5
|
extend self
|
6
6
|
|
7
|
-
def project_files(glob_pattern: nil)
|
8
|
-
args =
|
9
|
-
args
|
7
|
+
def project_files(glob_pattern: nil, include_ignored: false)
|
8
|
+
args = [ "ls-files", "--cached", "--others" ]
|
9
|
+
args << "--exclude-standard" unless include_ignored
|
10
|
+
args << glob_pattern if glob_pattern
|
10
11
|
`git #{args.join(" ")}`.split("\n")
|
11
12
|
end
|
12
13
|
|
@@ -41,11 +42,7 @@ module Tidewave
|
|
41
42
|
end
|
42
43
|
|
43
44
|
def file_full_path(path)
|
44
|
-
File.
|
45
|
-
end
|
46
|
-
|
47
|
-
def git_root
|
48
|
-
@git_root ||= `git rev-parse --show-toplevel`.strip
|
45
|
+
File.expand_path(path, Rails.root)
|
49
46
|
end
|
50
47
|
|
51
48
|
def validate_path_access!(path, validate_existence: true)
|
@@ -55,10 +52,14 @@ module Tidewave
|
|
55
52
|
full_path = file_full_path(path)
|
56
53
|
|
57
54
|
# Verify the file is within the project directory
|
58
|
-
|
55
|
+
unless full_path.start_with?(Rails.root.to_s + File::SEPARATOR)
|
56
|
+
raise ArgumentError, "File path must be within the project directory"
|
57
|
+
end
|
59
58
|
|
60
59
|
# Verify the file exists
|
61
|
-
|
60
|
+
if validate_existence && !File.exist?(full_path)
|
61
|
+
raise ArgumentError, "File not found: #{path}"
|
62
|
+
end
|
62
63
|
|
63
64
|
true
|
64
65
|
end
|
@@ -0,0 +1,182 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
require "ipaddr"
|
5
|
+
require "fast_mcp"
|
6
|
+
require "rack/request"
|
7
|
+
require "active_support/core_ext/class"
|
8
|
+
require "active_support/core_ext/object/blank"
|
9
|
+
require "json"
|
10
|
+
require "erb"
|
11
|
+
|
12
|
+
class Tidewave::Middleware
|
13
|
+
TIDEWAVE_ROUTE = "tidewave".freeze
|
14
|
+
EMPTY_ROUTE = "empty".freeze
|
15
|
+
SSE_ROUTE = "mcp".freeze
|
16
|
+
MESSAGES_ROUTE = "mcp/message".freeze
|
17
|
+
SHELL_ROUTE = "shell".freeze
|
18
|
+
|
19
|
+
INVALID_IP = <<~TEXT.freeze
|
20
|
+
For security reasons, Tidewave does not accept remote connections by default.
|
21
|
+
|
22
|
+
If you really want to allow remote connections, set `config.tidewave.allow_remote_access = true`.
|
23
|
+
TEXT
|
24
|
+
|
25
|
+
def initialize(app, config)
|
26
|
+
@allow_remote_access = config.allow_remote_access
|
27
|
+
@client_url = config.client_url
|
28
|
+
@project_name = Rails.application.class.module_parent.name
|
29
|
+
|
30
|
+
@app = FastMcp.rack_middleware(app,
|
31
|
+
name: "tidewave",
|
32
|
+
version: Tidewave::VERSION,
|
33
|
+
path_prefix: "/" + TIDEWAVE_ROUTE,
|
34
|
+
messages_route: MESSAGES_ROUTE,
|
35
|
+
sse_route: SSE_ROUTE,
|
36
|
+
logger: config.logger || Logger.new(Rails.root.join("log", "tidewave.log")),
|
37
|
+
# Rails runs the HostAuthorization in dev, so we skip this
|
38
|
+
allowed_origins: [],
|
39
|
+
# We validate this one in Tidewave::Middleware
|
40
|
+
localhost_only: false
|
41
|
+
) do |server|
|
42
|
+
server.filter_tools do |request, tools|
|
43
|
+
if request.params["include_fs_tools"] != "true"
|
44
|
+
tools.reject { |tool| tool.tags.include?(:file_system_tool) }
|
45
|
+
else
|
46
|
+
tools
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
server.register_tools(*Tidewave::Tools::Base.descendants)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def call(env)
|
55
|
+
request = Rack::Request.new(env)
|
56
|
+
path = request.path.split("/").reject(&:empty?)
|
57
|
+
|
58
|
+
if path[0] == TIDEWAVE_ROUTE
|
59
|
+
return forbidden(INVALID_IP) unless valid_client_ip?(request)
|
60
|
+
|
61
|
+
# The MCP routes are handled downstream by FastMCP
|
62
|
+
case [ request.request_method, path ]
|
63
|
+
when [ "GET", [ TIDEWAVE_ROUTE ] ]
|
64
|
+
return home(request)
|
65
|
+
when [ "GET", [ TIDEWAVE_ROUTE, EMPTY_ROUTE ] ]
|
66
|
+
return empty(request)
|
67
|
+
when [ "POST", [ TIDEWAVE_ROUTE, SHELL_ROUTE ] ]
|
68
|
+
return shell(request)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
@app.call(env)
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def home(request)
|
78
|
+
config = {
|
79
|
+
"project_name" => @project_name,
|
80
|
+
"framework_type" => "rails",
|
81
|
+
"tidewave_version" => Tidewave::VERSION
|
82
|
+
}
|
83
|
+
|
84
|
+
html = <<~HTML
|
85
|
+
<html>
|
86
|
+
<head>
|
87
|
+
<meta charset="UTF-8" />
|
88
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
89
|
+
<meta name="tidewave:config" content="#{ERB::Util.html_escape(JSON.generate(config))}" />
|
90
|
+
<script type="module" src="#{@client_url}/tc/tc.js"></script>
|
91
|
+
</head>
|
92
|
+
<body></body>
|
93
|
+
</html>
|
94
|
+
HTML
|
95
|
+
|
96
|
+
[ 200, { "Content-Type" => "text/html" }, [ html ] ]
|
97
|
+
end
|
98
|
+
|
99
|
+
def empty(request)
|
100
|
+
html = ""
|
101
|
+
[ 200, { "Content-Type" => "text/html" }, [ html ] ]
|
102
|
+
end
|
103
|
+
|
104
|
+
def forbidden(message)
|
105
|
+
Rails.logger.warn(message)
|
106
|
+
[ 403, { "Content-Type" => "text/plain" }, [ message ] ]
|
107
|
+
end
|
108
|
+
|
109
|
+
def shell(request)
|
110
|
+
body = request.body.read
|
111
|
+
return [ 400, { "Content-Type" => "text/plain" }, [ "Command body is required" ] ] if body.blank?
|
112
|
+
|
113
|
+
begin
|
114
|
+
parsed_body = JSON.parse(body)
|
115
|
+
cmd = parsed_body["command"]
|
116
|
+
return [ 400, { "Content-Type" => "text/plain" }, [ "Command field is required" ] ] if cmd.blank?
|
117
|
+
rescue JSON::ParserError
|
118
|
+
return [ 400, { "Content-Type" => "text/plain" }, [ "Invalid JSON in request body" ] ]
|
119
|
+
end
|
120
|
+
|
121
|
+
response = Rack::Response.new
|
122
|
+
response.status = 200
|
123
|
+
response.headers["Content-Type"] = "text/plain"
|
124
|
+
|
125
|
+
response.finish do |res|
|
126
|
+
begin
|
127
|
+
Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
|
128
|
+
stdin.close
|
129
|
+
|
130
|
+
# Merge stdout and stderr streams
|
131
|
+
ios = [ stdout, stderr ]
|
132
|
+
|
133
|
+
until ios.empty?
|
134
|
+
ready = IO.select(ios, nil, nil, 0.1)
|
135
|
+
next unless ready
|
136
|
+
|
137
|
+
ready[0].each do |io|
|
138
|
+
begin
|
139
|
+
data = io.read_nonblock(4096)
|
140
|
+
if data
|
141
|
+
# Write binary chunk: type (0 for data) + 4-byte length + data
|
142
|
+
chunk = [ 0, data.bytesize ].pack("CN") + data
|
143
|
+
res.write(chunk)
|
144
|
+
end
|
145
|
+
rescue IO::WaitReadable
|
146
|
+
# No data available right now
|
147
|
+
rescue EOFError
|
148
|
+
# Stream ended
|
149
|
+
ios.delete(io)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# Wait for process to complete and get exit status
|
155
|
+
exit_status = wait_thr.value.exitstatus
|
156
|
+
status_json = JSON.generate({ status: exit_status })
|
157
|
+
# Write binary chunk: type (1 for status) + 4-byte length + JSON data
|
158
|
+
chunk = [ 1, status_json.bytesize ].pack("CN") + status_json
|
159
|
+
res.write(chunk)
|
160
|
+
end
|
161
|
+
rescue => e
|
162
|
+
error_json = JSON.generate({ status: 213 })
|
163
|
+
chunk = [ 1, error_json.bytesize ].pack("CN") + error_json
|
164
|
+
res.write(chunk)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def valid_client_ip?(request)
|
170
|
+
return true if @allow_remote_access
|
171
|
+
|
172
|
+
ip = request.ip
|
173
|
+
return false unless ip
|
174
|
+
|
175
|
+
addr = IPAddr.new(ip)
|
176
|
+
|
177
|
+
addr.loopback? ||
|
178
|
+
addr == IPAddr.new("127.0.0.1") ||
|
179
|
+
addr == IPAddr.new("::1") ||
|
180
|
+
addr == IPAddr.new("::ffff:127.0.0.1") # IPv4-mapped IPv6
|
181
|
+
end
|
182
|
+
end
|
data/lib/tidewave/railtie.rb
CHANGED
@@ -1,49 +1,49 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "fast_mcp"
|
4
3
|
require "logger"
|
5
4
|
require "fileutils"
|
6
5
|
require "tidewave/configuration"
|
7
|
-
require "
|
6
|
+
require "tidewave/middleware"
|
7
|
+
require "tidewave/exceptions_middleware"
|
8
8
|
|
9
9
|
gem_tools_path = File.expand_path("tools/**/*.rb", __dir__)
|
10
10
|
Dir[gem_tools_path].each { |f| require f }
|
11
11
|
|
12
12
|
module Tidewave
|
13
13
|
class Railtie < Rails::Railtie
|
14
|
-
config.tidewave = Tidewave::Configuration.new
|
15
|
-
|
16
|
-
initializer "tidewave.
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
config = app.config.tidewave
|
21
|
-
|
22
|
-
# Set up MCP server with the host application
|
23
|
-
FastMcp.mount_in_rails(
|
24
|
-
app,
|
25
|
-
name: "tidewave",
|
26
|
-
version: Tidewave::VERSION,
|
27
|
-
path_prefix: Tidewave::PATH_PREFIX,
|
28
|
-
messages_route: Tidewave::MESSAGES_ROUTE,
|
29
|
-
sse_route: Tidewave::SSE_ROUTE,
|
30
|
-
logger: config.logger,
|
31
|
-
allowed_origins: config.allowed_origins,
|
32
|
-
localhost_only: config.localhost_only,
|
33
|
-
allowed_ips: config.allowed_ips
|
34
|
-
) do |server|
|
35
|
-
app.config.before_initialize do
|
36
|
-
server.filter_tools do |request, tools|
|
37
|
-
if request.params["include_fs_tools"] != "true"
|
38
|
-
tools.reject { |tool| tool.tags.include?(:file_system_tool) }
|
39
|
-
else
|
40
|
-
tools
|
41
|
-
end
|
42
|
-
end
|
14
|
+
config.tidewave = Tidewave::Configuration.new()
|
15
|
+
|
16
|
+
initializer "tidewave.setup" do |app|
|
17
|
+
unless app.config.enable_reloading
|
18
|
+
raise "For security reasons, Tidewave is only supported in environments where config.enable_reloading is true (typically development)"
|
19
|
+
end
|
43
20
|
|
44
|
-
|
21
|
+
app.config.middleware.insert_after(
|
22
|
+
ActionDispatch::Callbacks,
|
23
|
+
Tidewave::Middleware,
|
24
|
+
app.config.tidewave
|
25
|
+
)
|
26
|
+
|
27
|
+
app.config.after_initialize do
|
28
|
+
# If the user configured CSP, we need to alter it in dev
|
29
|
+
# to allow TC to run browser_eval.
|
30
|
+
app.config.content_security_policy.try do |content_security_policy|
|
31
|
+
content_security_policy.directives["script-src"].try do |script_src|
|
32
|
+
script_src << "'unsafe-eval'" unless script_src.include?("'unsafe-eval'")
|
33
|
+
end
|
45
34
|
end
|
46
35
|
end
|
47
36
|
end
|
37
|
+
|
38
|
+
initializer "tidewave.intercept_exceptions" do |app|
|
39
|
+
# We intercept exceptions from DebugExceptions, format the
|
40
|
+
# information as text and inject into the exception page html.
|
41
|
+
|
42
|
+
ActionDispatch::DebugExceptions.register_interceptor do |request, exception|
|
43
|
+
request.set_header("tidewave.exception", exception)
|
44
|
+
end
|
45
|
+
|
46
|
+
app.middleware.insert_before(ActionDispatch::DebugExceptions, Tidewave::ExceptionsMiddleware)
|
47
|
+
end
|
48
48
|
end
|
49
49
|
end
|
@@ -33,7 +33,7 @@ class Tidewave::Tools::EditProjectFile < Tidewave::Tools::Base
|
|
33
33
|
# Check if the file exists within the project root and has been read
|
34
34
|
Tidewave::FileTracker.validate_path_is_editable!(path, atime)
|
35
35
|
|
36
|
-
old_content = Tidewave::FileTracker.read_file(path)
|
36
|
+
_mtime, old_content = Tidewave::FileTracker.read_file(path)
|
37
37
|
|
38
38
|
# Ensure old_string is unique within the file
|
39
39
|
scan_result = old_content.scan(old_string)
|
@@ -3,7 +3,7 @@
|
|
3
3
|
class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tools::Base
|
4
4
|
tool_name "execute_sql_query"
|
5
5
|
description <<~DESCRIPTION
|
6
|
-
Executes the given SQL query against the
|
6
|
+
Executes the given SQL query against the database connection.
|
7
7
|
Returns the result as a Ruby data structure.
|
8
8
|
|
9
9
|
Note that the output is limited to 50 rows at a time. If you need to see more, perform additional calls
|
@@ -25,24 +25,6 @@ class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tools::Base
|
|
25
25
|
RESULT_LIMIT = 50
|
26
26
|
|
27
27
|
def call(query:, arguments: [])
|
28
|
-
|
29
|
-
conn = ActiveRecord::Base.connection
|
30
|
-
|
31
|
-
# Execute the query with prepared statement and arguments
|
32
|
-
if arguments.any?
|
33
|
-
result = conn.exec_query(query, "SQL", arguments)
|
34
|
-
else
|
35
|
-
result = conn.exec_query(query)
|
36
|
-
end
|
37
|
-
|
38
|
-
|
39
|
-
# Format the result
|
40
|
-
{
|
41
|
-
columns: result.columns,
|
42
|
-
rows: result.rows.first(RESULT_LIMIT),
|
43
|
-
row_count: result.rows.length,
|
44
|
-
adapter: conn.adapter_name,
|
45
|
-
database: Rails.configuration.database_configuration.dig(Rails.env, "database")
|
46
|
-
}
|
28
|
+
Tidewave::DatabaseAdapter.current.execute_query(query, arguments)
|
47
29
|
end
|
48
30
|
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Tidewave::Tools::GetDocs < Tidewave::Tools::Base
|
4
|
+
tool_name "get_docs"
|
5
|
+
|
6
|
+
description <<~DESCRIPTION
|
7
|
+
Returns the documentation for the given reference.
|
8
|
+
|
9
|
+
The reference may be a constant, most commonly classes and modules
|
10
|
+
such as `String`, an instance method, such as `String#gsub`, or class
|
11
|
+
method, such as `File.executable?`
|
12
|
+
|
13
|
+
This works for methods in the current project, as well as dependencies.
|
14
|
+
|
15
|
+
This tool only works if you know the specific constant/method being targeted.
|
16
|
+
If that is the case, prefer this tool over grepping the file system.
|
17
|
+
DESCRIPTION
|
18
|
+
|
19
|
+
arguments do
|
20
|
+
required(:reference).filled(:string).description("The constant/method to lookup, such String, String#gsub or File.executable?")
|
21
|
+
end
|
22
|
+
|
23
|
+
def call(reference:)
|
24
|
+
file_path, line_number = Tidewave::Tools::GetSourceLocation.get_source_location(reference)
|
25
|
+
|
26
|
+
if file_path
|
27
|
+
extract_documentation(file_path, line_number)
|
28
|
+
else
|
29
|
+
raise NameError, "could not find docs for #{reference}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def extract_documentation(file_path, line_number)
|
36
|
+
return nil unless File.exist?(file_path)
|
37
|
+
|
38
|
+
lines = File.readlines(file_path)
|
39
|
+
return nil if line_number <= 0 || line_number > lines.length
|
40
|
+
|
41
|
+
# Start from the line before the method definition
|
42
|
+
current_line = line_number - 2 # Convert to 0-based index and go one line up
|
43
|
+
comment_lines = []
|
44
|
+
|
45
|
+
# Collect comment lines going backwards
|
46
|
+
while current_line >= 0
|
47
|
+
line = lines[current_line].chomp.strip
|
48
|
+
|
49
|
+
if line.start_with?("#")
|
50
|
+
comment_lines.unshift(line.sub(/^#\s|^#/, ""))
|
51
|
+
elsif line.empty?
|
52
|
+
# Skip empty lines but continue looking for comments
|
53
|
+
else
|
54
|
+
# Hit a non-comment, non-empty line, stop collecting
|
55
|
+
break
|
56
|
+
end
|
57
|
+
|
58
|
+
current_line -= 1
|
59
|
+
end
|
60
|
+
|
61
|
+
return nil if comment_lines.empty?
|
62
|
+
comment_lines.join("\n")
|
63
|
+
end
|
64
|
+
end
|
@@ -3,25 +3,36 @@
|
|
3
3
|
class Tidewave::Tools::GetModels < Tidewave::Tools::Base
|
4
4
|
tool_name "get_models"
|
5
5
|
description <<~DESCRIPTION
|
6
|
-
Returns a list of all models in the application
|
6
|
+
Returns a list of all models in the application.
|
7
7
|
DESCRIPTION
|
8
8
|
|
9
9
|
def call
|
10
10
|
# Ensure all models are loaded
|
11
11
|
Rails.application.eager_load!
|
12
12
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
13
|
+
base_class = Tidewave::DatabaseAdapter.current.get_base_class
|
14
|
+
base_class.descendants.map do |model|
|
15
|
+
if location = get_relative_source_location(model.name)
|
16
|
+
"* #{model.name} at #{location}"
|
17
|
+
else
|
18
|
+
"* #{model.name}"
|
19
|
+
end
|
20
|
+
end.join("\n")
|
18
21
|
end
|
19
22
|
|
20
23
|
private
|
21
24
|
|
22
|
-
def
|
23
|
-
|
24
|
-
|
25
|
-
|
25
|
+
def get_relative_source_location(model_name)
|
26
|
+
source_location = Object.const_source_location(model_name)
|
27
|
+
return nil if source_location.blank?
|
28
|
+
|
29
|
+
file_path, line_number = source_location
|
30
|
+
begin
|
31
|
+
relative_path = Pathname.new(file_path).relative_path_from(Rails.root)
|
32
|
+
"#{relative_path}:#{line_number}"
|
33
|
+
rescue ArgumentError
|
34
|
+
# If the path cannot be made relative, return the absolute path
|
35
|
+
"#{file_path}:#{line_number}"
|
36
|
+
end
|
26
37
|
end
|
27
38
|
end
|
@@ -21,21 +21,22 @@ class Tidewave::Tools::GetSourceLocation < Tidewave::Tools::Base
|
|
21
21
|
end
|
22
22
|
|
23
23
|
def call(reference:)
|
24
|
-
file_path, line_number = get_source_location(reference)
|
24
|
+
file_path, line_number = self.class.get_source_location(reference)
|
25
25
|
|
26
26
|
if file_path
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
27
|
+
begin
|
28
|
+
relative_path = Pathname.new(file_path).relative_path_from(Rails.root)
|
29
|
+
"#{relative_path}:#{line_number}"
|
30
|
+
rescue ArgumentError
|
31
|
+
# If the path cannot be made relative, return the absolute path
|
32
|
+
"#{file_path}:#{line_number}"
|
33
|
+
end
|
31
34
|
else
|
32
35
|
raise NameError, "could not find source location for #{reference}"
|
33
36
|
end
|
34
37
|
end
|
35
38
|
|
36
|
-
|
37
|
-
|
38
|
-
def get_source_location(reference)
|
39
|
+
def self.get_source_location(reference)
|
39
40
|
constant_path, selector, method_name = reference.rpartition(/\.|#/)
|
40
41
|
|
41
42
|
# There are no selectors, so the method_name is a constant path
|
@@ -12,15 +12,15 @@ class Tidewave::Tools::ListProjectFiles < Tidewave::Tools::Base
|
|
12
12
|
By default, when no arguments are passed, it returns all files in the project that
|
13
13
|
are not ignored by .gitignore.
|
14
14
|
|
15
|
-
Optionally, a glob_pattern can be passed to filter this list.
|
16
|
-
the gitignore check will be skipped.
|
15
|
+
Optionally, a glob_pattern can be passed to filter this list.
|
17
16
|
DESC
|
18
17
|
|
19
18
|
arguments do
|
20
|
-
optional(:glob_pattern).maybe(:string).description("Optional: a glob pattern to filter the listed files
|
19
|
+
optional(:glob_pattern).maybe(:string).description("Optional: a glob pattern to filter the listed files")
|
20
|
+
optional(:include_ignored).maybe(:bool).description("Optional: whether to include files that are ignored by .gitignore. Defaults to false. WARNING: Use with targeted glob patterns to avoid listing excessive files from dependencies or build directories.")
|
21
21
|
end
|
22
22
|
|
23
|
-
def call(glob_pattern: nil)
|
24
|
-
Tidewave::FileTracker.project_files(glob_pattern: glob_pattern)
|
23
|
+
def call(glob_pattern: nil, include_ignored: false)
|
24
|
+
Tidewave::FileTracker.project_files(glob_pattern: glob_pattern, include_ignored: include_ignored)
|
25
25
|
end
|
26
26
|
end
|
@@ -1,5 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "timeout"
|
4
|
+
require "json"
|
5
|
+
|
3
6
|
class Tidewave::Tools::ProjectEval < Tidewave::Tools::Base
|
4
7
|
tool_name "project_eval"
|
5
8
|
description <<~DESCRIPTION
|
@@ -15,9 +18,12 @@ class Tidewave::Tools::ProjectEval < Tidewave::Tools::Base
|
|
15
18
|
|
16
19
|
arguments do
|
17
20
|
required(:code).filled(:string).description("The Ruby code to evaluate")
|
21
|
+
optional(:arguments).value(:array).description("The arguments to pass to evaluation. They are available inside the evaluated code as `arguments`.")
|
22
|
+
optional(:timeout).filled(:integer).description("The timeout in milliseconds. If the evaluation takes longer than this, it will be terminated. Defaults to 30000 (30 seconds).")
|
23
|
+
optional(:json).filled(:bool).description("Whether to return the result as JSON with structured output containing result, success, stdout, and stderr fields. Defaults to false.")
|
18
24
|
end
|
19
25
|
|
20
|
-
def call(code:)
|
26
|
+
def call(code:, arguments: [], timeout: 30_000, json: false)
|
21
27
|
original_stdout = $stdout
|
22
28
|
original_stderr = $stderr
|
23
29
|
|
@@ -27,22 +33,56 @@ class Tidewave::Tools::ProjectEval < Tidewave::Tools::Base
|
|
27
33
|
$stderr = stderr_capture
|
28
34
|
|
29
35
|
begin
|
30
|
-
|
36
|
+
timeout_seconds = timeout / 1000.0
|
37
|
+
|
38
|
+
success, result = begin
|
39
|
+
Timeout.timeout(timeout_seconds) do
|
40
|
+
[ true, eval(code, eval_binding(arguments)) ]
|
41
|
+
end
|
42
|
+
rescue Timeout::Error
|
43
|
+
[ false, "Timeout::Error: Evaluation timed out after #{timeout} milliseconds." ]
|
44
|
+
rescue => e
|
45
|
+
[ false, e.full_message ]
|
46
|
+
end
|
47
|
+
|
31
48
|
stdout = stdout_capture.string
|
32
49
|
stderr = stderr_capture.string
|
33
50
|
|
34
|
-
if
|
35
|
-
|
36
|
-
|
37
|
-
|
51
|
+
if json
|
52
|
+
JSON.generate({
|
53
|
+
result: result,
|
54
|
+
success: success,
|
38
55
|
stdout: stdout,
|
39
|
-
stderr: stderr
|
40
|
-
|
41
|
-
|
56
|
+
stderr: stderr
|
57
|
+
})
|
58
|
+
elsif stdout.empty? && stderr.empty?
|
59
|
+
# We explicitly call to_s so the result is not accidentally
|
60
|
+
# parsed as a JSON response by FastMCP.
|
61
|
+
result.to_s
|
62
|
+
else
|
63
|
+
<<~OUTPUT
|
64
|
+
STDOUT:
|
65
|
+
|
66
|
+
#{stdout}
|
67
|
+
|
68
|
+
STDERR:
|
69
|
+
|
70
|
+
#{stderr}
|
71
|
+
|
72
|
+
Result:
|
73
|
+
|
74
|
+
#{result}
|
75
|
+
OUTPUT
|
42
76
|
end
|
43
77
|
ensure
|
44
78
|
$stdout = original_stdout
|
45
79
|
$stderr = original_stderr
|
46
80
|
end
|
47
81
|
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def eval_binding(arguments)
|
86
|
+
binding
|
87
|
+
end
|
48
88
|
end
|
data/lib/tidewave/version.rb
CHANGED
data/lib/tidewave.rb
CHANGED
@@ -2,9 +2,12 @@
|
|
2
2
|
|
3
3
|
require "tidewave/version"
|
4
4
|
require "tidewave/railtie"
|
5
|
+
require "tidewave/database_adapter"
|
5
6
|
|
7
|
+
# Ensure DatabaseAdapters module is available
|
6
8
|
module Tidewave
|
7
|
-
|
8
|
-
|
9
|
-
|
9
|
+
module DatabaseAdapters
|
10
|
+
# This module is defined here to ensure it's available for autoloading
|
11
|
+
# Individual adapters are loaded on-demand in database_adapter.rb
|
12
|
+
end
|
10
13
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: tidewave
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Yorick Jacquin
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-08-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -64,18 +64,22 @@ files:
|
|
64
64
|
- config/database.yml
|
65
65
|
- lib/tidewave.rb
|
66
66
|
- lib/tidewave/configuration.rb
|
67
|
+
- lib/tidewave/database_adapter.rb
|
68
|
+
- lib/tidewave/database_adapters/active_record.rb
|
69
|
+
- lib/tidewave/database_adapters/sequel.rb
|
70
|
+
- lib/tidewave/exceptions_middleware.rb
|
67
71
|
- lib/tidewave/file_tracker.rb
|
72
|
+
- lib/tidewave/middleware.rb
|
68
73
|
- lib/tidewave/railtie.rb
|
69
74
|
- lib/tidewave/tools/base.rb
|
70
75
|
- lib/tidewave/tools/edit_project_file.rb
|
71
76
|
- lib/tidewave/tools/execute_sql_query.rb
|
77
|
+
- lib/tidewave/tools/get_docs.rb
|
72
78
|
- lib/tidewave/tools/get_logs.rb
|
73
79
|
- lib/tidewave/tools/get_models.rb
|
74
80
|
- lib/tidewave/tools/get_package_location.rb
|
75
81
|
- lib/tidewave/tools/get_source_location.rb
|
76
|
-
- lib/tidewave/tools/grep_project_files.rb
|
77
82
|
- lib/tidewave/tools/list_project_files.rb
|
78
|
-
- lib/tidewave/tools/package_search.rb
|
79
83
|
- lib/tidewave/tools/project_eval.rb
|
80
84
|
- lib/tidewave/tools/read_project_file.rb
|
81
85
|
- lib/tidewave/tools/shell_eval.rb
|
@@ -1,110 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
class Tidewave::Tools::GrepProjectFiles < Tidewave::Tools::Base
|
4
|
-
tags :file_system_tool
|
5
|
-
|
6
|
-
def self.ripgrep_executable
|
7
|
-
@ripgrep_executable ||= `which rg`.strip
|
8
|
-
end
|
9
|
-
|
10
|
-
def self.ripgrep_available?
|
11
|
-
ripgrep_executable.present?
|
12
|
-
end
|
13
|
-
|
14
|
-
def self.description
|
15
|
-
"Searches for text patterns in files using #{ripgrep_available? ? 'ripgrep' : 'a grep variant'}."
|
16
|
-
end
|
17
|
-
tool_name "grep_project_files"
|
18
|
-
|
19
|
-
arguments do
|
20
|
-
required(:pattern).filled(:string).description("The pattern to search for")
|
21
|
-
optional(:glob).filled(:string).description(
|
22
|
-
'Optional glob pattern to filter which files to search in, e.g., \"**/*.ex\". Note that if a glob pattern is used, the .gitignore file will be ignored.'
|
23
|
-
)
|
24
|
-
optional(:case_sensitive).filled(:bool).description("Whether the search should be case-sensitive. Defaults to false.")
|
25
|
-
optional(:max_results).filled(:integer).description("Maximum number of results to return. Defaults to 100.")
|
26
|
-
end
|
27
|
-
|
28
|
-
def call(pattern:, glob: "**/*", case_sensitive: false, max_results: 100)
|
29
|
-
if self.class.ripgrep_available?
|
30
|
-
execute_ripgrep(pattern, glob, case_sensitive, max_results)
|
31
|
-
else
|
32
|
-
execute_grep(pattern, glob, case_sensitive, max_results)
|
33
|
-
end
|
34
|
-
end
|
35
|
-
|
36
|
-
private
|
37
|
-
|
38
|
-
def execute_ripgrep(pattern, glob, case_sensitive, max_results)
|
39
|
-
command = [ self.class.ripgrep_executable ]
|
40
|
-
command << "--no-require-git" # ignore gitignored files
|
41
|
-
command << "--json" # formatted as json
|
42
|
-
command << "--max-count=#{max_results}"
|
43
|
-
command << "--ignore-case" unless case_sensitive
|
44
|
-
command << "--glob=#{glob}" if glob.present?
|
45
|
-
command << pattern
|
46
|
-
command << "." # Search in current directory
|
47
|
-
|
48
|
-
results = `#{command.join(" ")} 2>&1`
|
49
|
-
|
50
|
-
# Process the results as needed
|
51
|
-
format_ripgrep_results(results)
|
52
|
-
end
|
53
|
-
|
54
|
-
def execute_grep(pattern, glob, case_sensitive, max_results)
|
55
|
-
glob = "**/*" if glob.blank?
|
56
|
-
files = Dir.glob(glob, base: Tidewave::FileTracker.git_root)
|
57
|
-
results = []
|
58
|
-
files.each do |file|
|
59
|
-
full_path = File.join(Tidewave::FileTracker.git_root, file)
|
60
|
-
next unless File.file?(full_path)
|
61
|
-
|
62
|
-
begin
|
63
|
-
file_matches = 0
|
64
|
-
line_number = 0
|
65
|
-
|
66
|
-
File.foreach(full_path) do |line|
|
67
|
-
line_number += 1
|
68
|
-
|
69
|
-
# Check if line matches pattern with proper case sensitivity
|
70
|
-
if case_sensitive
|
71
|
-
next unless line.include?(pattern)
|
72
|
-
else
|
73
|
-
next unless line.downcase.include?(pattern.downcase)
|
74
|
-
end
|
75
|
-
|
76
|
-
results << {
|
77
|
-
"path" => file,
|
78
|
-
"line_number" => line_number,
|
79
|
-
"content" => line.strip
|
80
|
-
}
|
81
|
-
|
82
|
-
file_matches += 1
|
83
|
-
# Stop processing this file if we've reached max results for it
|
84
|
-
break if file_matches >= max_results
|
85
|
-
end
|
86
|
-
rescue => e
|
87
|
-
# Skip files that can't be read (e.g., binary files)
|
88
|
-
next
|
89
|
-
end
|
90
|
-
end
|
91
|
-
|
92
|
-
results.to_json
|
93
|
-
end
|
94
|
-
|
95
|
-
def format_ripgrep_results(results)
|
96
|
-
parsed_results = results.split("\n").map(&:strip).reject(&:empty?).map do |line|
|
97
|
-
JSON.parse(line)
|
98
|
-
end
|
99
|
-
|
100
|
-
parsed_results.map do |result|
|
101
|
-
next if result["type"] != "match"
|
102
|
-
|
103
|
-
{
|
104
|
-
"path" => result.dig("data", "path", "text"),
|
105
|
-
"line_number" => result.dig("data", "line_number"),
|
106
|
-
"content" => result.dig("data", "lines", "text").strip
|
107
|
-
}
|
108
|
-
end.compact.to_json
|
109
|
-
end
|
110
|
-
end
|
@@ -1,43 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "net/http"
|
4
|
-
require "uri"
|
5
|
-
require "json"
|
6
|
-
|
7
|
-
class Tidewave::Tools::PackageSearch < Tidewave::Tools::Base
|
8
|
-
tool_name "package_search"
|
9
|
-
description <<~DESCRIPTION
|
10
|
-
Searches for packages on RubyGems.
|
11
|
-
|
12
|
-
Use this tool if you need to find new packages to add to the project. Before using this tool,
|
13
|
-
get an overview of the existing dependencies by using the `project_eval` tool and executing
|
14
|
-
`Gem::Specification.map { |gem| [gem.name, gem.version] }`.
|
15
|
-
|
16
|
-
The results are paginated, with 30 packages per page. Use the `page` parameter to fetch a specific page.
|
17
|
-
DESCRIPTION
|
18
|
-
|
19
|
-
arguments do
|
20
|
-
required(:search).filled(:string).description("The search term")
|
21
|
-
optional(:page).filled(:integer, gt?: 0).description("The page number to fetch. Must be greater than 0. Defaults to 1.")
|
22
|
-
end
|
23
|
-
|
24
|
-
def call(search:, page: 1)
|
25
|
-
uri = URI("https://rubygems.org/api/v1/search.json")
|
26
|
-
uri.query = URI.encode_www_form(query: search, page: page)
|
27
|
-
|
28
|
-
response = Net::HTTP.get_response(uri)
|
29
|
-
|
30
|
-
if response.is_a?(Net::HTTPSuccess)
|
31
|
-
JSON.parse(response.body).map do |package|
|
32
|
-
{
|
33
|
-
name: package["name"],
|
34
|
-
version: package["version"],
|
35
|
-
downloads: package["downloads"],
|
36
|
-
documentation_uri: package["documentation_uri"]
|
37
|
-
}
|
38
|
-
end
|
39
|
-
else
|
40
|
-
raise "RubyGems API request failed with status code: #{response.code}"
|
41
|
-
end
|
42
|
-
end
|
43
|
-
end
|