rubyn 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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +251 -0
  4. data/Rakefile +12 -0
  5. data/exe/rubyn +5 -0
  6. data/lib/generators/rubyn/install_generator.rb +16 -0
  7. data/lib/rubyn/cli.rb +85 -0
  8. data/lib/rubyn/client/api_client.rb +172 -0
  9. data/lib/rubyn/commands/agent.rb +191 -0
  10. data/lib/rubyn/commands/base.rb +60 -0
  11. data/lib/rubyn/commands/config.rb +51 -0
  12. data/lib/rubyn/commands/dashboard.rb +85 -0
  13. data/lib/rubyn/commands/index.rb +101 -0
  14. data/lib/rubyn/commands/init.rb +166 -0
  15. data/lib/rubyn/commands/refactor.rb +175 -0
  16. data/lib/rubyn/commands/review.rb +61 -0
  17. data/lib/rubyn/commands/spec.rb +72 -0
  18. data/lib/rubyn/commands/usage.rb +56 -0
  19. data/lib/rubyn/config/credentials.rb +39 -0
  20. data/lib/rubyn/config/project_config.rb +42 -0
  21. data/lib/rubyn/config/settings.rb +53 -0
  22. data/lib/rubyn/context/codebase_indexer.rb +195 -0
  23. data/lib/rubyn/context/context_builder.rb +36 -0
  24. data/lib/rubyn/context/file_resolver.rb +235 -0
  25. data/lib/rubyn/context/project_scanner.rb +132 -0
  26. data/lib/rubyn/engine/app/assets/images/rubyn/RubynLogo.png +0 -0
  27. data/lib/rubyn/engine/app/assets/javascripts/rubyn/application.js +579 -0
  28. data/lib/rubyn/engine/app/assets/stylesheets/rubyn/application.css +1073 -0
  29. data/lib/rubyn/engine/app/controllers/rubyn/agent_controller.rb +26 -0
  30. data/lib/rubyn/engine/app/controllers/rubyn/application_controller.rb +44 -0
  31. data/lib/rubyn/engine/app/controllers/rubyn/dashboard_controller.rb +19 -0
  32. data/lib/rubyn/engine/app/controllers/rubyn/feedback_controller.rb +17 -0
  33. data/lib/rubyn/engine/app/controllers/rubyn/files_controller.rb +54 -0
  34. data/lib/rubyn/engine/app/controllers/rubyn/refactor_controller.rb +56 -0
  35. data/lib/rubyn/engine/app/controllers/rubyn/reviews_controller.rb +32 -0
  36. data/lib/rubyn/engine/app/controllers/rubyn/settings_controller.rb +17 -0
  37. data/lib/rubyn/engine/app/controllers/rubyn/specs_controller.rb +33 -0
  38. data/lib/rubyn/engine/app/views/layouts/rubyn/application.html.erb +63 -0
  39. data/lib/rubyn/engine/app/views/rubyn/agent/show.html.erb +22 -0
  40. data/lib/rubyn/engine/app/views/rubyn/dashboard/index.html.erb +120 -0
  41. data/lib/rubyn/engine/app/views/rubyn/files/index.html.erb +45 -0
  42. data/lib/rubyn/engine/app/views/rubyn/refactor/show.html.erb +28 -0
  43. data/lib/rubyn/engine/app/views/rubyn/reviews/show.html.erb +28 -0
  44. data/lib/rubyn/engine/app/views/rubyn/settings/show.html.erb +42 -0
  45. data/lib/rubyn/engine/app/views/rubyn/specs/show.html.erb +28 -0
  46. data/lib/rubyn/engine/config/routes.rb +13 -0
  47. data/lib/rubyn/engine/engine.rb +18 -0
  48. data/lib/rubyn/output/diff_renderer.rb +106 -0
  49. data/lib/rubyn/output/formatter.rb +123 -0
  50. data/lib/rubyn/output/spinner.rb +26 -0
  51. data/lib/rubyn/tools/base_tool.rb +74 -0
  52. data/lib/rubyn/tools/bundle_add.rb +77 -0
  53. data/lib/rubyn/tools/create_file.rb +32 -0
  54. data/lib/rubyn/tools/delete_file.rb +29 -0
  55. data/lib/rubyn/tools/executor.rb +68 -0
  56. data/lib/rubyn/tools/find_files.rb +33 -0
  57. data/lib/rubyn/tools/find_references.rb +72 -0
  58. data/lib/rubyn/tools/git_commit.rb +65 -0
  59. data/lib/rubyn/tools/git_create_branch.rb +58 -0
  60. data/lib/rubyn/tools/git_diff.rb +42 -0
  61. data/lib/rubyn/tools/git_log.rb +43 -0
  62. data/lib/rubyn/tools/git_status.rb +26 -0
  63. data/lib/rubyn/tools/list_directory.rb +82 -0
  64. data/lib/rubyn/tools/move_file.rb +35 -0
  65. data/lib/rubyn/tools/patch_file.rb +47 -0
  66. data/lib/rubyn/tools/rails_generate.rb +40 -0
  67. data/lib/rubyn/tools/rails_migrate.rb +55 -0
  68. data/lib/rubyn/tools/rails_routes.rb +35 -0
  69. data/lib/rubyn/tools/read_file.rb +45 -0
  70. data/lib/rubyn/tools/registry.rb +28 -0
  71. data/lib/rubyn/tools/run_command.rb +48 -0
  72. data/lib/rubyn/tools/run_tests.rb +52 -0
  73. data/lib/rubyn/tools/search_files.rb +82 -0
  74. data/lib/rubyn/tools/write_file.rb +30 -0
  75. data/lib/rubyn/version.rb +5 -0
  76. data/lib/rubyn/version_checker.rb +74 -0
  77. data/lib/rubyn.rb +95 -0
  78. data/sig/rubyn.rbs +4 -0
  79. metadata +379 -0
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ module Tools
5
+ class FindReferences < BaseTool
6
+ DESCRIPTION = "Find all references to a class, method, or constant across the project"
7
+ PARAMETERS = {
8
+ identifier: { type: "string", required: true, description: "The class, method, or constant name to find" },
9
+ type: { type: "string", required: false,
10
+ description: "Type of identifier: 'class', 'method', 'constant', or 'any' (default 'any')" }
11
+ }.freeze
12
+ REQUIRES_CONFIRMATION = false
13
+
14
+ MAX_RESULTS = 100
15
+
16
+ def execute(params)
17
+ identifier = params[:identifier]
18
+ return error("Missing required parameter: identifier") unless identifier
19
+
20
+ ref_type = params[:type] || "any"
21
+
22
+ regex = build_regex(identifier, ref_type)
23
+ return error("Invalid reference type: #{ref_type}") unless regex
24
+
25
+ references = []
26
+ rb_files = Dir.glob(File.join(project_root, "**", "*.rb"))
27
+
28
+ rb_files.each do |file|
29
+ break if references.size >= MAX_RESULTS
30
+ next if excluded?(file)
31
+
32
+ search_file_for_references(file, regex, references)
33
+ end
34
+
35
+ success(identifier: identifier, references: references)
36
+ end
37
+
38
+ private
39
+
40
+ def build_regex(identifier, type)
41
+ escaped = Regexp.escape(identifier)
42
+ case type
43
+ when "method"
44
+ Regexp.new("(?:\\.#{escaped}\\b|\\bdef\\s+#{escaped}\\b)")
45
+ when "class", "constant", "any"
46
+ Regexp.new("\\b#{escaped}\\b")
47
+ end
48
+ end
49
+
50
+ def search_file_for_references(file, regex, references)
51
+ File.open(file, "r:UTF-8") do |f|
52
+ f.each_line.with_index(1) do |line, line_number|
53
+ break if references.size >= MAX_RESULTS
54
+
55
+ if line.match?(regex)
56
+ references << {
57
+ file: relative_path(file),
58
+ line_number: line_number,
59
+ line: line.chomp.slice(0, 500)
60
+ }
61
+ end
62
+ end
63
+ end
64
+ rescue ArgumentError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
65
+ # Skip files with encoding issues
66
+ end
67
+
68
+ end
69
+ end
70
+ end
71
+
72
+ Rubyn::Tools::Registry.register("find_references", Rubyn::Tools::FindReferences)
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require_relative "base_tool"
6
+ require_relative "registry"
7
+
8
+ module Rubyn
9
+ module Tools
10
+ class GitCommit < BaseTool
11
+ DESCRIPTION = "Stage files and create a git commit"
12
+ PARAMETERS = {
13
+ message: { type: :string, description: "Commit message", required: true },
14
+ files: { type: :array, description: "Specific files to stage (defaults to all changed)", required: false }
15
+ }.freeze
16
+ REQUIRES_CONFIRMATION = true
17
+
18
+ def execute(params)
19
+ message = params[:message]
20
+ return error("message is required") unless message && !message.strip.empty?
21
+
22
+ files = params[:files]
23
+
24
+ # Stage files
25
+ add_command = if files.is_a?(Array) && !files.empty?
26
+ escaped_files = files.map { |f| Shellwords.escape(f) }.join(" ")
27
+ "git add #{escaped_files}"
28
+ else
29
+ "git add -A"
30
+ end
31
+
32
+ begin
33
+ _stdout, stderr, status = Open3.capture3(add_command, chdir: project_root)
34
+ rescue StandardError => e
35
+ return error("Failed to stage files: #{e.message}")
36
+ end
37
+
38
+ return error("git add failed: #{stderr}") unless status.exitstatus.zero?
39
+
40
+ # Create commit
41
+ begin
42
+ stdout, stderr, status = Open3.capture3("git", "commit", "-m", message, chdir: project_root)
43
+ rescue StandardError => e
44
+ return error("Failed to create commit: #{e.message}")
45
+ end
46
+
47
+ return error("git commit failed: #{stderr}") unless status.exitstatus.zero?
48
+
49
+ # Extract commit hash
50
+ commit_hash = extract_commit_hash(stdout)
51
+
52
+ success(hash: commit_hash, message: message)
53
+ end
54
+
55
+ private
56
+
57
+ def extract_commit_hash(output)
58
+ match = output.match(%r{\[[\w\-/]+\s+([a-f0-9]+)\]})
59
+ match ? match[1] : "unknown"
60
+ end
61
+ end
62
+
63
+ Registry.register("git_commit", GitCommit)
64
+ end
65
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "base_tool"
5
+ require_relative "registry"
6
+
7
+ module Rubyn
8
+ module Tools
9
+ class GitCreateBranch < BaseTool
10
+ DESCRIPTION = "Create and checkout a new git branch"
11
+ PARAMETERS = {
12
+ branch_name: { type: :string, description: "Name of the new branch", required: true },
13
+ from: { type: :string, description: "Base branch (defaults to current branch)", required: false }
14
+ }.freeze
15
+ REQUIRES_CONFIRMATION = true
16
+
17
+ VALID_BRANCH_PATTERN = %r{\A[a-zA-Z0-9\-_/]+\z}
18
+
19
+ def execute(params)
20
+ branch_name = params[:branch_name]
21
+ return error("branch_name is required") unless branch_name && !branch_name.strip.empty?
22
+
23
+ unless branch_name.match?(VALID_BRANCH_PATTERN)
24
+ return error(
25
+ "Invalid branch name: '#{branch_name}'. Use letters, numbers, hyphens, underscores, or slashes."
26
+ )
27
+ end
28
+
29
+ from = params[:from]
30
+ if from && !from.strip.empty?
31
+ unless from.match?(VALID_BRANCH_PATTERN)
32
+ return error(
33
+ "Invalid base branch name: '#{from}'. Use letters, numbers, hyphens, underscores, or slashes."
34
+ )
35
+ end
36
+ end
37
+
38
+ cmd = if from && !from.strip.empty?
39
+ ["git", "checkout", "-b", branch_name, from]
40
+ else
41
+ ["git", "checkout", "-b", branch_name]
42
+ end
43
+
44
+ begin
45
+ _, stderr, status = Open3.capture3(*cmd, chdir: project_root)
46
+ rescue StandardError => e
47
+ return error("Failed to create branch: #{e.message}")
48
+ end
49
+
50
+ return error("git checkout failed: #{stderr}") unless status.exitstatus.zero?
51
+
52
+ success(branch: branch_name)
53
+ end
54
+ end
55
+
56
+ Registry.register("git_create_branch", GitCreateBranch)
57
+ end
58
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require_relative "base_tool"
6
+ require_relative "registry"
7
+
8
+ module Rubyn
9
+ module Tools
10
+ class GitDiff < BaseTool
11
+ DESCRIPTION = "Show current git diff"
12
+ PARAMETERS = {
13
+ path: { type: :string, description: "Specific file to diff", required: false },
14
+ staged: { type: :boolean, description: "Show staged changes (default false)", required: false }
15
+ }.freeze
16
+ REQUIRES_CONFIRMATION = false
17
+
18
+ DEFAULT_MAX_OUTPUT_LENGTH = 15_000
19
+
20
+ def execute(params)
21
+ command = "git diff"
22
+ command = "#{command} --cached" if params[:staged]
23
+ command = "#{command} -- #{Shellwords.shellescape(params[:path])}" if params[:path] && !params[:path].strip.empty?
24
+
25
+ begin
26
+ stdout, stderr, status = Open3.capture3(command, chdir: project_root)
27
+ rescue StandardError => e
28
+ return error("Failed to get git diff: #{e.message}")
29
+ end
30
+
31
+ return error("git diff failed: #{stderr}") unless status.exitstatus.zero?
32
+
33
+ diff = truncate_output(stdout)
34
+ success(diff: diff)
35
+ end
36
+
37
+ private
38
+ end
39
+
40
+ Registry.register("git_diff", GitDiff)
41
+ end
42
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require_relative "base_tool"
6
+ require_relative "registry"
7
+
8
+ module Rubyn
9
+ module Tools
10
+ class GitLog < BaseTool
11
+ DESCRIPTION = "Show recent git commit history"
12
+ PARAMETERS = {
13
+ count: { type: :integer, description: "Number of commits to show (default 10)", required: false },
14
+ path: { type: :string, description: "Filter by file path", required: false }
15
+ }.freeze
16
+ REQUIRES_CONFIRMATION = false
17
+ DEFAULT_COUNT = 10
18
+
19
+ def execute(params)
20
+ count = (params[:count] || DEFAULT_COUNT).to_i
21
+ command = "git log --oneline -#{count}"
22
+ command = "#{command} -- #{Shellwords.shellescape(params[:path])}" if params[:path] && !params[:path].strip.empty?
23
+
24
+ begin
25
+ stdout, stderr, status = Open3.capture3(command, chdir: project_root)
26
+ rescue StandardError => e
27
+ return error("Failed to get git log: #{e.message}")
28
+ end
29
+
30
+ return error("git log failed: #{stderr}") unless status.exitstatus.zero?
31
+
32
+ commits = stdout.strip.split("\n").map do |line|
33
+ hash, *message_parts = line.split
34
+ { hash: hash, message: message_parts.join(" ") }
35
+ end
36
+
37
+ success(commits: commits)
38
+ end
39
+ end
40
+
41
+ Registry.register("git_log", GitLog)
42
+ end
43
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "base_tool"
5
+ require_relative "registry"
6
+
7
+ module Rubyn
8
+ module Tools
9
+ class GitStatus < BaseTool
10
+ DESCRIPTION = "Show git working tree status"
11
+ PARAMETERS = {}.freeze
12
+ REQUIRES_CONFIRMATION = false
13
+
14
+ def execute(_params)
15
+ stdout, stderr, status = Open3.capture3("git status --short", chdir: project_root)
16
+ return error("git status failed: #{stderr}") unless status.exitstatus.zero?
17
+
18
+ success(status: stdout, clean: stdout.strip.empty?)
19
+ rescue Errno::ENOENT, Errno::EACCES => e
20
+ error("Failed to get git status: #{e.message}")
21
+ end
22
+ end
23
+
24
+ Registry.register("git_status", GitStatus)
25
+ end
26
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ module Tools
5
+ class ListDirectory < BaseTool
6
+ DESCRIPTION = "List files and directories at a given path"
7
+ PARAMETERS = {
8
+ path: { type: "string", required: false, description: "Directory path (defaults to project root)" },
9
+ recursive: { type: "boolean", required: false, description: "List recursively (default false)" }
10
+ }.freeze
11
+ REQUIRES_CONFIRMATION = false
12
+
13
+ MAX_ENTRIES = 500
14
+
15
+ def execute(params)
16
+ target_path = params[:path] || "."
17
+ recursive = params[:recursive] || false
18
+
19
+ resolved = resolve_path(target_path)
20
+ return resolved if resolved.is_a?(Hash) && resolved[:error]
21
+
22
+ return error("Directory not found: #{target_path}") unless Dir.exist?(resolved)
23
+
24
+ entries = if recursive
25
+ list_recursive(resolved)
26
+ else
27
+ list_flat(resolved)
28
+ end
29
+
30
+ success(path: relative_path(resolved), entries: entries)
31
+ end
32
+
33
+ private
34
+
35
+ def list_flat(dir)
36
+ entries = []
37
+ Dir.children(dir).sort.each do |name|
38
+ break if entries.size >= MAX_ENTRIES
39
+
40
+ next if excluded?(name)
41
+
42
+ full = File.join(dir, name)
43
+ entries << entry_info(name, full)
44
+ end
45
+ entries
46
+ end
47
+
48
+ def list_recursive(dir, prefix: "")
49
+ entries = []
50
+ Dir.children(dir).sort.each do |name|
51
+ return entries if entries.size >= MAX_ENTRIES
52
+
53
+ next if excluded?(name)
54
+
55
+ full = File.join(dir, name)
56
+ rel = prefix.empty? ? name : File.join(prefix, name)
57
+
58
+ entries << entry_info(rel, full)
59
+
60
+ entries.concat(list_recursive(full, prefix: rel)) if File.directory?(full)
61
+ end
62
+ entries
63
+ end
64
+
65
+ def entry_info(name, full_path)
66
+ if File.directory?(full_path)
67
+ { name: name, type: "directory", size: 0 }
68
+ else
69
+ { name: name, type: "file", size: File.size(full_path) }
70
+ end
71
+ rescue SystemCallError
72
+ { name: name, type: "unknown", size: 0 }
73
+ end
74
+
75
+ def excluded?(name)
76
+ EXCLUDED_DIRS.any? { |ex| name == ex || name.start_with?("#{ex}/") }
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ Rubyn::Tools::Registry.register("list_directory", Rubyn::Tools::ListDirectory)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "base_tool"
5
+ require_relative "registry"
6
+
7
+ module Rubyn
8
+ module Tools
9
+ class MoveFile < BaseTool
10
+ DESCRIPTION = "Move or rename a file"
11
+ PARAMETERS = {
12
+ source: { type: :string, description: "Path to the source file", required: true },
13
+ destination: { type: :string, description: "Path to the destination", required: true }
14
+ }.freeze
15
+ REQUIRES_CONFIRMATION = true
16
+
17
+ def execute(params)
18
+ resolved_source = resolve_path(params[:source])
19
+ return resolved_source if resolved_source.is_a?(Hash) && resolved_source[:error]
20
+
21
+ resolved_destination = resolve_path(params[:destination])
22
+ return resolved_destination if resolved_destination.is_a?(Hash) && resolved_destination[:error]
23
+
24
+ return error("Source file not found: #{params[:source]}") unless File.exist?(resolved_source)
25
+
26
+ FileUtils.mkdir_p(File.dirname(resolved_destination))
27
+ FileUtils.mv(resolved_source, resolved_destination)
28
+
29
+ success(source: params[:source], destination: params[:destination])
30
+ end
31
+ end
32
+
33
+ Registry.register("move_file", MoveFile)
34
+ end
35
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_tool"
4
+ require_relative "registry"
5
+
6
+ module Rubyn
7
+ module Tools
8
+ class PatchFile < BaseTool
9
+ DESCRIPTION = "Apply a targeted find-and-replace edit to a file"
10
+ PARAMETERS = {
11
+ path: { type: :string, description: "Path to the file to patch", required: true },
12
+ find: { type: :string, description: "The string to find in the file", required: true },
13
+ replace: { type: :string, description: "The string to replace with", required: true },
14
+ all_occurrences: { type: :boolean, description: "Replace all occurrences (default: false)", required: false }
15
+ }.freeze
16
+ REQUIRES_CONFIRMATION = true
17
+
18
+ def execute(params)
19
+ resolved = resolve_path(params[:path])
20
+ return resolved if resolved.is_a?(Hash) && resolved[:error]
21
+
22
+ return error("File not found: #{params[:path]}") unless File.exist?(resolved)
23
+
24
+ content = File.read(resolved)
25
+ find = params[:find]
26
+
27
+ return error("Find string not found in file: #{params[:path]}") unless content.include?(find)
28
+
29
+ all_occurrences = params[:all_occurrences] || false
30
+
31
+ if all_occurrences
32
+ replacements = content.scan(find).length
33
+ new_content = content.gsub(find, params[:replace])
34
+ else
35
+ replacements = 1
36
+ new_content = content.sub(find, params[:replace])
37
+ end
38
+
39
+ File.write(resolved, new_content)
40
+
41
+ success(path: params[:path], replacements: replacements)
42
+ end
43
+ end
44
+
45
+ Registry.register("patch_file", PatchFile)
46
+ end
47
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "base_tool"
5
+ require_relative "registry"
6
+
7
+ module Rubyn
8
+ module Tools
9
+ class RailsGenerate < BaseTool
10
+ DESCRIPTION = "Run a Rails generator"
11
+ PARAMETERS = {
12
+ generator: { type: :string, description: "Generator name (e.g. migration, model)", required: true },
13
+ args: { type: :string, description: "Generator arguments (e.g. AddDeletedAtToOrders deleted_at:datetime)",
14
+ required: true }
15
+ }.freeze
16
+ REQUIRES_CONFIRMATION = true
17
+
18
+ def execute(params)
19
+ generator = params[:generator]
20
+ args = params[:args]
21
+ return error("generator is required") unless generator && !generator.strip.empty?
22
+ return error("args is required") unless args && !args.strip.empty?
23
+
24
+ command = "bundle exec rails generate #{generator} #{args}"
25
+
26
+ begin
27
+ stdout, stderr, status = Open3.capture3(command, chdir: project_root)
28
+ rescue StandardError => e
29
+ return error("Failed to run rails generate: #{e.message}")
30
+ end
31
+
32
+ return error("rails generate failed: #{stderr}") unless status.exitstatus.zero?
33
+
34
+ success(output: stdout)
35
+ end
36
+ end
37
+
38
+ Registry.register("rails_generate", RailsGenerate)
39
+ end
40
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "base_tool"
5
+ require_relative "registry"
6
+
7
+ module Rubyn
8
+ module Tools
9
+ class RailsMigrate < BaseTool
10
+ DESCRIPTION = "Run pending database migrations"
11
+ PARAMETERS = {
12
+ direction: { type: :string, description: "Migration direction: up or down (default up)", required: false },
13
+ version: { type: :string, description: "Specific migration version", required: false }
14
+ }.freeze
15
+ REQUIRES_CONFIRMATION = true
16
+
17
+ def execute(params)
18
+ direction = (params[:direction] || "up").strip.downcase
19
+ version = params[:version]
20
+
21
+ command = build_command(direction, version)
22
+ return command if command.is_a?(Hash) && command[:error]
23
+
24
+ begin
25
+ stdout, stderr, status = Open3.capture3(command, chdir: project_root)
26
+ rescue StandardError => e
27
+ return error("Failed to run migration: #{e.message}")
28
+ end
29
+
30
+ return error("Migration failed: #{stderr}") unless status.exitstatus.zero?
31
+
32
+ success(output: stdout)
33
+ end
34
+
35
+ private
36
+
37
+ def build_command(direction, version)
38
+ case direction
39
+ when "up"
40
+ "bundle exec rails db:migrate"
41
+ when "down"
42
+ if version && !version.strip.empty?
43
+ "bundle exec rails db:migrate:down VERSION=#{version}"
44
+ else
45
+ "bundle exec rails db:rollback"
46
+ end
47
+ else
48
+ error("Invalid direction: #{direction}. Must be 'up' or 'down'")
49
+ end
50
+ end
51
+ end
52
+
53
+ Registry.register("rails_migrate", RailsMigrate)
54
+ end
55
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require_relative "base_tool"
6
+ require_relative "registry"
7
+
8
+ module Rubyn
9
+ module Tools
10
+ class RailsRoutes < BaseTool
11
+ DESCRIPTION = "Show Rails routes, optionally filtered by a pattern"
12
+ PARAMETERS = {
13
+ pattern: { type: :string, description: "Grep filter for routes", required: false }
14
+ }.freeze
15
+ REQUIRES_CONFIRMATION = false
16
+
17
+ def execute(params)
18
+ command = "bundle exec rails routes"
19
+ command = "#{command} -g #{Shellwords.shellescape(params[:pattern])}" if params[:pattern] && !params[:pattern].strip.empty?
20
+
21
+ begin
22
+ stdout, stderr, status = Open3.capture3(command, chdir: project_root)
23
+ rescue StandardError => e
24
+ return error("Failed to run rails routes: #{e.message}")
25
+ end
26
+
27
+ return error("rails routes failed: #{stderr}") unless status.exitstatus.zero?
28
+
29
+ success(routes: stdout)
30
+ end
31
+ end
32
+
33
+ Registry.register("rails_routes", RailsRoutes)
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_tool"
4
+ require_relative "registry"
5
+
6
+ module Rubyn
7
+ module Tools
8
+ class ReadFile < BaseTool
9
+ DESCRIPTION = "Read the contents of a file"
10
+ PARAMETERS = {
11
+ path: { type: :string, description: "Path to the file to read", required: true },
12
+ line_start: { type: :integer, description: "Starting line number (1-based)", required: false },
13
+ line_end: { type: :integer, description: "Ending line number (1-based, inclusive)", required: false }
14
+ }.freeze
15
+ REQUIRES_CONFIRMATION = false
16
+
17
+ def execute(params)
18
+ resolved = resolve_path(params[:path])
19
+ return resolved if resolved.is_a?(Hash) && resolved[:error]
20
+
21
+ return error("File not found: #{params[:path]}") unless File.exist?(resolved)
22
+
23
+ return error("Not a file: #{params[:path]}") unless File.file?(resolved)
24
+
25
+ lines = File.readlines(resolved)
26
+ total_lines = lines.length
27
+
28
+ line_start = params[:line_start]&.to_i
29
+ line_end = params[:line_end]&.to_i
30
+
31
+ return success(path: params[:path], content: lines.join, line_count: total_lines) unless line_start || line_end
32
+
33
+ line_start = [line_start || 1, 1].max
34
+ line_end = [line_end || total_lines, total_lines].min
35
+
36
+ return error("line_start (#{line_start}) cannot be greater than line_end (#{line_end})") if line_start > line_end
37
+
38
+ content = lines[(line_start - 1)..(line_end - 1)].join
39
+ success(path: params[:path], content: content, line_count: total_lines)
40
+ end
41
+ end
42
+
43
+ Registry.register("read_file", ReadFile)
44
+ end
45
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ module Tools
5
+ class Registry
6
+ class << self
7
+ def register(name, klass)
8
+ @tools ||= {}
9
+ @tools[name.to_s] = klass
10
+ end
11
+
12
+ def get(name)
13
+ @tools ||= {}
14
+ @tools[name.to_s]
15
+ end
16
+
17
+ def all
18
+ @tools ||= {}
19
+ @tools.dup
20
+ end
21
+
22
+ def tool_definitions
23
+ all.map { |name, klass| { name: name, description: klass::DESCRIPTION, parameters: klass::PARAMETERS } }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end