tidewave 0.1.3 → 0.3.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.
@@ -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
@@ -10,13 +10,20 @@ class Tidewave::Tools::GetLogs < Tidewave::Tools::Base
10
10
 
11
11
  arguments do
12
12
  required(:tail).filled(:integer).description("The number of log entries to return from the end of the log")
13
+ optional(:grep).filled(:string).description("Filter logs with the given regular expression (case insensitive). E.g. \"error\" when you want to capture errors in particular")
13
14
  end
14
15
 
15
- def call(tail:)
16
+ def call(tail:, grep: nil)
16
17
  log_file = Rails.root.join("log", "#{Rails.env}.log")
17
18
  return "Log file not found" unless File.exist?(log_file)
18
19
 
19
- logs = File.readlines(log_file).last(tail)
20
- logs.join
20
+ logs = File.readlines(log_file)
21
+
22
+ if grep
23
+ regex = Regexp.new(grep, Regexp::IGNORECASE)
24
+ logs = logs.select { |line| line.match?(regex) }
25
+ end
26
+
27
+ logs.last(tail).join
21
28
  end
22
29
  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 and their relationships.
6
+ Returns a list of all database-backed 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
- models = ActiveRecord::Base.descendants.map do |model|
14
- { name: model.name, relationships: get_relationships(model) }
15
- end
16
-
17
- models.to_json
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 get_relationships(model)
23
- model.reflect_on_all_associations.map do |association|
24
- { name: association.name, type: association.macro }
25
- end.compact_blank
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
@@ -10,10 +10,11 @@ class Tidewave::Tools::GetSourceLocation < Tidewave::Tools::Base
10
10
  such as `String`, an instance method, such as `String#gsub`, or class
11
11
  method, such as `File.executable?`
12
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.
13
+ This tool only works if you know the specific constant/method being targeted,
14
+ and it works across the current project and all dependencies.
16
15
  If that is the case, prefer this tool over grepping the file system.
16
+
17
+ You may also get the root location of a gem, you can use "dep:PACKAGE_NAME".
17
18
  DESCRIPTION
18
19
 
19
20
  arguments do
@@ -21,21 +22,40 @@ class Tidewave::Tools::GetSourceLocation < Tidewave::Tools::Base
21
22
  end
22
23
 
23
24
  def call(reference:)
24
- file_path, line_number = get_source_location(reference)
25
+ # Check if this is a package location request
26
+ if reference.start_with?("dep:")
27
+ package_name = reference.gsub("dep:", "")
28
+ return get_package_location(package_name)
29
+ end
30
+
31
+ file_path, line_number = self.class.get_source_location(reference)
25
32
 
26
33
  if file_path
27
- {
28
- file_path: file_path,
29
- line_number: line_number
30
- }.to_json
34
+ begin
35
+ relative_path = Pathname.new(file_path).relative_path_from(Rails.root)
36
+ "#{relative_path}:#{line_number}"
37
+ rescue ArgumentError
38
+ # If the path cannot be made relative, return the absolute path
39
+ "#{file_path}:#{line_number}"
40
+ end
31
41
  else
32
42
  raise NameError, "could not find source location for #{reference}"
33
43
  end
34
44
  end
35
45
 
36
- private
46
+ def get_package_location(package)
47
+ raise "dep: prefix only works with projects using Bundler" unless defined?(Bundler)
48
+ specs = Bundler.load.specs
49
+
50
+ spec = specs.find { |s| s.name == package }
51
+ if spec
52
+ spec.full_gem_path
53
+ else
54
+ raise "Package #{package} not found. Check your Gemfile for available packages."
55
+ end
56
+ end
37
57
 
38
- def get_source_location(reference)
58
+ def self.get_source_location(reference)
39
59
  constant_path, selector, method_name = reference.rpartition(/\.|#/)
40
60
 
41
61
  # There are no selectors, so the method_name is a constant path
@@ -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).hidden().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
- result = eval(code)
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 stdout.empty? && stderr.empty?
35
- result
36
- else
37
- {
51
+ if json
52
+ JSON.generate({
53
+ result: result,
54
+ success: success,
38
55
  stdout: stdout,
39
- stderr: stderr,
40
- result: result
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tidewave
4
- VERSION = "0.1.3"
4
+ VERSION = "0.3.0"
5
5
  end
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
- PATH_PREFIX = "/tidewave"
8
- MESSAGES_ROUTE = "messages"
9
- SSE_ROUTE = "mcp"
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,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tidewave
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yorick Jacquin
8
+ - José Valim
8
9
  autorequire:
9
10
  bindir: bin
10
11
  cert_chain: []
11
- date: 2025-06-04 00:00:00.000000000 Z
12
+ date: 2025-09-08 00:00:00.000000000 Z
12
13
  dependencies:
13
14
  - !ruby/object:Gem::Dependency
14
15
  name: rails
@@ -64,22 +65,20 @@ files:
64
65
  - config/database.yml
65
66
  - lib/tidewave.rb
66
67
  - lib/tidewave/configuration.rb
67
- - lib/tidewave/file_tracker.rb
68
+ - lib/tidewave/database_adapter.rb
69
+ - lib/tidewave/database_adapters/active_record.rb
70
+ - lib/tidewave/database_adapters/sequel.rb
71
+ - lib/tidewave/exceptions_middleware.rb
72
+ - lib/tidewave/middleware.rb
73
+ - lib/tidewave/quiet_requests_middleware.rb
68
74
  - lib/tidewave/railtie.rb
69
75
  - lib/tidewave/tools/base.rb
70
- - 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
- - lib/tidewave/tools/get_package_location.rb
75
80
  - lib/tidewave/tools/get_source_location.rb
76
- - lib/tidewave/tools/grep_project_files.rb
77
- - lib/tidewave/tools/list_project_files.rb
78
- - lib/tidewave/tools/package_search.rb
79
81
  - lib/tidewave/tools/project_eval.rb
80
- - lib/tidewave/tools/read_project_file.rb
81
- - lib/tidewave/tools/shell_eval.rb
82
- - lib/tidewave/tools/write_project_file.rb
83
82
  - lib/tidewave/version.rb
84
83
  homepage: https://tidewave.ai/
85
84
  licenses:
@@ -1,101 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Tidewave
4
- module FileTracker
5
- extend self
6
-
7
- def project_files(glob_pattern: nil)
8
- args = %w[--git-dir] + [ "#{git_root}/.git", "ls-files", "--cached", "--others" ]
9
- args += glob_pattern ? [ glob_pattern ] : [ "--exclude-standard" ]
10
- `git #{args.join(" ")}`.split("\n")
11
- end
12
-
13
- def read_file(path, line_offset: 0, count: nil)
14
- full_path = file_full_path(path)
15
- # Explicitly read the mtime first to avoid race conditions
16
- mtime = File.mtime(full_path).to_i
17
- content = File.read(full_path)
18
-
19
- if line_offset > 0 || count
20
- lines = content.lines
21
- start_idx = [ line_offset, 0 ].max
22
- count = (count || lines.length)
23
- selected_lines = lines[start_idx, count]
24
- content = selected_lines ? selected_lines.join : ""
25
- end
26
-
27
- [ mtime, content ]
28
- end
29
-
30
- def write_file(path, content)
31
- validate_ruby_syntax!(content) if ruby_file?(path)
32
- full_path = file_full_path(path)
33
-
34
- # Create the directory if it doesn't exist
35
- dirname = File.dirname(full_path)
36
- FileUtils.mkdir_p(dirname)
37
-
38
- # Write and return the file contents
39
- File.write(full_path, content)
40
- content
41
- end
42
-
43
- def file_full_path(path)
44
- File.join(git_root, path)
45
- end
46
-
47
- def git_root
48
- @git_root ||= `git rev-parse --show-toplevel`.strip
49
- end
50
-
51
- def validate_path_access!(path, validate_existence: true)
52
- raise ArgumentError, "File path must not contain '..'" if path.include?("..")
53
-
54
- # Ensure the path is within the project
55
- full_path = file_full_path(path)
56
-
57
- # Verify the file is within the project directory
58
- raise ArgumentError, "File path must be within the project directory" unless full_path.start_with?(git_root)
59
-
60
- # Verify the file exists
61
- raise ArgumentError, "File not found: #{path}" if validate_existence && !File.exist?(full_path)
62
-
63
- true
64
- end
65
-
66
- def validate_path_is_editable!(path, atime)
67
- validate_path_access!(path)
68
- validate_path_has_been_read_since_last_write!(path, atime)
69
-
70
- true
71
- end
72
-
73
- def validate_path_is_writable!(path, atime)
74
- validate_path_access!(path, validate_existence: false)
75
- validate_path_has_been_read_since_last_write!(path, atime)
76
-
77
- true
78
- end
79
-
80
- private
81
-
82
- def ruby_file?(path)
83
- [ ".rb", ".rake", ".gemspec" ].include?(File.extname(path)) ||
84
- [ "Gemfile" ].include?(File.basename(path))
85
- end
86
-
87
- def validate_ruby_syntax!(content)
88
- RubyVM::AbstractSyntaxTree.parse(content)
89
- rescue SyntaxError => e
90
- raise "Invalid Ruby syntax: #{e.message}"
91
- end
92
-
93
- def validate_path_has_been_read_since_last_write!(path, atime)
94
- if atime && File.mtime(file_full_path(path)).to_i > atime
95
- raise ArgumentError, "File has been modified since last read, please read the file again"
96
- end
97
-
98
- true
99
- end
100
- end
101
- end
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "tidewave/file_tracker"
4
-
5
- class Tidewave::Tools::EditProjectFile < Tidewave::Tools::Base
6
- tags :file_system_tool
7
-
8
- tool_name "edit_project_file"
9
- description <<~DESCRIPTION
10
- A tool for editing parts of a file. It can find and replace text inside a file.
11
- For moving or deleting files, use the shell_eval tool with 'mv' or 'rm' instead.
12
-
13
- For large edits, use the write_project_file tool instead and overwrite the entire file.
14
-
15
- Before editing, ensure to read the source file using the read_project_file tool.
16
-
17
- To use this tool, provide the path to the file, the old_string to search for, and the new_string to replace it with.
18
- If the old_string is found multiple times, an error will be returned. To ensure uniqueness, include a couple of lines
19
- before and after the edit. All whitespace must be preserved as in the original file.
20
-
21
- This tool can only do a single edit at a time. If you need to make multiple edits, you can create a message with
22
- multiple tool calls to this tool, ensuring that each one contains enough context to uniquely identify the edit.
23
- DESCRIPTION
24
-
25
- arguments do
26
- required(:path).filled(:string).description("The path to the file to edit. It is relative to the project root.")
27
- required(:old_string).filled(:string).description("The string to search for")
28
- required(:new_string).filled(:string).description("The string to replace the old_string with")
29
- optional(:atime).filled(:integer).hidden.description("The Unix timestamp this file was last accessed. Not to be used.")
30
- end
31
-
32
- def call(path:, old_string:, new_string:, atime: nil)
33
- # Check if the file exists within the project root and has been read
34
- Tidewave::FileTracker.validate_path_is_editable!(path, atime)
35
-
36
- old_content = Tidewave::FileTracker.read_file(path)
37
-
38
- # Ensure old_string is unique within the file
39
- scan_result = old_content.scan(old_string)
40
- raise ArgumentError, "old_string is not found" if scan_result.empty?
41
- raise ArgumentError, "old_string is not unique" if scan_result.size > 1
42
-
43
- new_content = old_content.sub(old_string, new_string)
44
- Tidewave::FileTracker.write_file(path, new_content)
45
- "OK"
46
- end
47
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "pathname"
4
-
5
- class Tidewave::Tools::GetPackageLocation < Tidewave::Tools::Base
6
- tool_name "get_package_location"
7
-
8
- description <<~DESCRIPTION
9
- Returns the location of dependency packages.
10
- You can use this tool to get the location of any project dependency. Optionally,
11
- a specific dependency name can be provided to only return the location of that dependency.
12
- Use the result in combination with shell tools like grep to look for specific
13
- code inside dependencies.
14
- DESCRIPTION
15
-
16
- arguments do
17
- optional(:package).maybe(:string).description(
18
- "The name of the package to get the location of. If not provided, the location of all packages will be returned."
19
- )
20
- end
21
-
22
- def call(package: nil)
23
- raise "get_package_location only works with projects using Bundler" unless defined?(Bundler)
24
- specs = Bundler.load.specs
25
-
26
- if package
27
- spec = specs.find { |s| s.name == package }
28
- if spec
29
- spec.full_gem_path
30
- else
31
- raise "Package #{package} not found. Check your Gemfile for available packages."
32
- end
33
- else
34
- # For all packages, return a formatted string with package names and locations
35
- specs.map do |spec|
36
- relative_path = Pathname.new(spec.full_gem_path).relative_path_from(Pathname.new(Dir.pwd))
37
- "#{spec.name}: #{relative_path}"
38
- end.join("\n")
39
- end
40
- end
41
- end
@@ -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