rubycode 0.1.1 → 0.1.2

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.
@@ -1,74 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Rubycode
3
+ module RubyCode
4
4
  module Tools
5
- class Read
6
- def self.definition
7
- {
8
- type: "function",
9
- function: {
10
- name: "read",
11
- description: "Read a file or directory from the filesystem.\n\n- Use this when you know the file path and want to see its contents\n- Returns file contents with line numbers (format: 'line_number: content')\n- For directories, returns a listing of entries\n- Use the search tool to find specific content in files\n- Use bash with 'ls' or 'find' to discover what files exist",
12
- parameters: {
13
- type: "object",
14
- properties: {
15
- file_path: {
16
- type: "string",
17
- description: "Absolute or relative path to the file to read"
18
- },
19
- offset: {
20
- type: "integer",
21
- description: "Line number to start reading from (1-indexed). Optional."
22
- },
23
- limit: {
24
- type: "integer",
25
- description: "Maximum number of lines to read. Default 2000. Optional."
26
- }
27
- },
28
- required: ["file_path"]
29
- }
30
- }
31
- }
32
- end
5
+ # Tool for reading files and directories
6
+ class Read < Base
7
+ private
33
8
 
34
- def self.execute(params:, context:)
9
+ def perform(params)
35
10
  file_path = params["file_path"]
36
11
  offset = params["offset"] || 1
37
12
  limit = params["limit"] || 2000
38
13
 
39
- # Resolve relative paths
40
- full_path = if File.absolute_path?(file_path)
41
- file_path
42
- else
43
- File.join(context[:root_path], file_path)
44
- end
14
+ full_path = resolve_path(file_path)
15
+ raise FileNotFoundError, "File '#{file_path}' does not exist" unless File.exist?(full_path)
16
+
17
+ return list_directory(full_path, file_path) if File.directory?(full_path)
45
18
 
46
- unless File.exist?(full_path)
47
- return "Error: File '#{file_path}' does not exist"
48
- end
19
+ format_file_lines(full_path, offset, limit)
20
+ end
21
+
22
+ def resolve_path(file_path)
23
+ File.absolute_path?(file_path) ? file_path : File.join(root_path, file_path)
24
+ end
49
25
 
50
- if File.directory?(full_path)
51
- # List directory contents instead
52
- entries = Dir.entries(full_path).reject { |e| e.start_with?(".") }.sort
53
- return "Directory listing for '#{file_path}':\n" + entries.map { |e| " #{e}" }.join("\n")
54
- end
26
+ def list_directory(full_path, file_path)
27
+ entries = Dir.entries(full_path).reject { |e| e.start_with?(".") }.sort
28
+ "Directory listing for '#{file_path}':\n" + entries.map { |e| " #{e}" }.join("\n")
29
+ end
55
30
 
31
+ def format_file_lines(full_path, offset, limit)
56
32
  lines = File.readlines(full_path)
57
33
  start_idx = [offset - 1, 0].max
58
34
  end_idx = [start_idx + limit - 1, lines.length - 1].min
59
35
 
60
- result = []
61
- (start_idx..end_idx).each do |i|
62
- line_num = i + 1
63
- content = lines[i].chomp
64
- # Truncate long lines
65
- content = content[0..2000] + "..." if content.length > 2000
66
- result << "#{line_num}: #{content}"
67
- end
36
+ formatted_lines = (start_idx..end_idx).map do |i|
37
+ format_line(lines[i], i + 1)
38
+ end.join("\n")
39
+
40
+ build_tool_result(formatted_lines, start_idx, end_idx, lines.length)
41
+ end
42
+
43
+ def build_tool_result(content, start_idx, end_idx, total_lines)
44
+ ToolResult.new(
45
+ content: content,
46
+ metadata: {
47
+ line_count: end_idx - start_idx + 1,
48
+ truncated: end_idx < total_lines - 1
49
+ }
50
+ )
51
+ end
68
52
 
69
- result.join("\n")
70
- rescue => e
71
- "Error reading file: #{e.message}"
53
+ def format_line(line, line_num)
54
+ content = line.chomp
55
+ content = "#{content[0..2000]}..." if content.length > 2000
56
+ "#{line_num}: #{content}"
72
57
  end
73
58
  end
74
59
  end
@@ -1,88 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
3
4
  require "shellwords"
4
5
 
5
- module Rubycode
6
+ module RubyCode
6
7
  module Tools
7
- class Search
8
- def self.definition
9
- {
10
- type: "function",
11
- function: {
12
- name: "search",
13
- description: "Search INSIDE file contents for patterns using grep. Returns matching lines with file paths and line numbers.\n\n- Searches file CONTENTS using regular expressions\n- Use this when you need to find WHERE specific text/code appears inside files\n- Returns file paths, line numbers, and the matching content\n- Example: search for 'button' to find files containing that text",
14
- parameters: {
15
- type: "object",
16
- properties: {
17
- pattern: {
18
- type: "string",
19
- description: "The pattern to search for (supports regex)"
20
- },
21
- path: {
22
- type: "string",
23
- description: "Directory or file to search in. Defaults to '.' (current directory). Optional."
24
- },
25
- include: {
26
- type: "string",
27
- description: "File pattern to include (e.g., '*.rb', '*.js'). Optional."
28
- },
29
- case_insensitive: {
30
- type: "boolean",
31
- description: "Perform case-insensitive search. Optional."
32
- }
33
- },
34
- required: ["pattern"]
35
- }
36
- }
37
- }
38
- end
8
+ # Tool for searching file contents with grep
9
+ class Search < Base
10
+ private
39
11
 
40
- def self.execute(params:, context:)
12
+ def perform(params)
41
13
  pattern = params["pattern"]
42
14
  path = params["path"] || "."
43
- include_pattern = params["include"]
44
- case_insensitive = params["case_insensitive"] || false
45
15
 
46
- # Resolve relative paths
47
- full_path = if File.absolute_path?(path)
48
- path
49
- else
50
- File.join(context[:root_path], path)
51
- end
16
+ full_path = resolve_path(path)
17
+ raise PathError, "Path '#{path}' does not exist" unless File.exist?(full_path)
52
18
 
53
- unless File.exist?(full_path)
54
- return "Error: Path '#{path}' does not exist"
55
- end
19
+ command = build_grep_command(pattern, full_path, params)
20
+ output, exit_code = execute_grep(command)
56
21
 
57
- # Build grep command safely using Shellwords to prevent injection
58
- cmd_parts = ["grep", "-n", "-r"]
59
- cmd_parts << "-i" if case_insensitive
60
- cmd_parts << "--include=#{Shellwords.escape(include_pattern)}" if include_pattern
61
- cmd_parts << Shellwords.escape(pattern)
62
- cmd_parts << Shellwords.escape(full_path)
22
+ format_output(output, exit_code, pattern)
23
+ end
24
+
25
+ def resolve_path(path)
26
+ File.absolute_path?(path) ? path : File.join(root_path, path)
27
+ end
63
28
 
64
- command = cmd_parts.join(" ")
29
+ def build_grep_command(pattern, full_path, params)
30
+ [
31
+ "grep", "-n", "-r",
32
+ ("-i" if params["case_insensitive"]),
33
+ ("--include=#{Shellwords.escape(params["include"])}" if params["include"]),
34
+ Shellwords.escape(pattern),
35
+ Shellwords.escape(full_path)
36
+ ].compact.join(" ")
37
+ end
65
38
 
39
+ def execute_grep(command)
66
40
  output = `#{command} 2>&1`
67
- exit_code = $?.exitstatus
41
+ [output, $CHILD_STATUS.exitstatus]
42
+ end
68
43
 
69
- if exit_code == 0
70
- # Found matches
71
- lines = output.split("\n")
72
- if lines.length > 100
73
- lines[0..99].join("\n") + "\n\n... (#{lines.length - 100} more matches truncated)"
74
- else
75
- output
76
- end
77
- elsif exit_code == 1
78
- # No matches found
44
+ def format_output(output, exit_code, pattern)
45
+ case exit_code
46
+ when 0
47
+ truncate_output(output)
48
+ when 1
79
49
  "No matches found for pattern: #{pattern}"
80
50
  else
81
- # Error occurred
82
- "Error running search: #{output}"
51
+ raise CommandExecutionError, "Error running search: #{output}"
83
52
  end
84
- rescue => e
85
- "Error: #{e.message}"
53
+ end
54
+
55
+ def truncate_output(output)
56
+ lines = output.split("\n")
57
+ return output if lines.length <= 100
58
+
59
+ truncated = lines[0..99].join("\n")
60
+ "#{truncated}\n\n... (#{lines.length - 100} more matches truncated)"
86
61
  end
87
62
  end
88
63
  end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "tools/base"
3
4
  require_relative "tools/bash"
4
5
  require_relative "tools/read"
5
6
  require_relative "tools/search"
6
7
  require_relative "tools/done"
7
8
 
8
- module Rubycode
9
+ module RubyCode
10
+ # Collection of available tools for the AI agent
9
11
  module Tools
10
12
  # Registry of all available tools
11
13
  TOOLS = [
@@ -22,11 +24,14 @@ module Rubycode
22
24
  def self.execute(tool_name:, params:, context:)
23
25
  tool_class = TOOLS.find { |t| t.definition[:function][:name] == tool_name }
24
26
 
25
- unless tool_class
26
- return "Error: Unknown tool '#{tool_name}'"
27
- end
27
+ raise ToolError, "Unknown tool '#{tool_name}'" unless tool_class
28
28
 
29
- tool_class.execute(params: params, context: context)
29
+ # Instantiate tool and call execute
30
+ tool_instance = tool_class.new(context: context)
31
+ result = tool_instance.execute(params)
32
+
33
+ # Convert result to string for history compatibility
34
+ result.respond_to?(:to_s) ? result.to_s : result
30
35
  end
31
36
  end
32
37
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ # Represents a conversation message
5
+ class Message
6
+ attr_reader :role, :content, :timestamp
7
+
8
+ def initialize(role:, content:)
9
+ @role = role
10
+ @content = content
11
+ @timestamp = Time.now
12
+ end
13
+
14
+ def to_h
15
+ { role: role, content: content }
16
+ end
17
+
18
+ def ==(other)
19
+ other.is_a?(Message) &&
20
+ role == other.role &&
21
+ content == other.content
22
+ end
23
+ end
24
+
25
+ # Represents a tool call from the LLM
26
+ class ToolCall
27
+ attr_reader :name, :arguments
28
+
29
+ def initialize(name:, arguments:)
30
+ @name = name
31
+ @arguments = arguments
32
+ end
33
+
34
+ def to_h
35
+ { name: name, arguments: arguments }
36
+ end
37
+
38
+ def ==(other)
39
+ other.is_a?(ToolCall) &&
40
+ name == other.name &&
41
+ arguments == other.arguments
42
+ end
43
+ end
44
+
45
+ # Represents the result of a tool execution
46
+ class ToolResult
47
+ attr_reader :content, :metadata
48
+
49
+ def initialize(content:, metadata: {})
50
+ @content = content
51
+ @metadata = metadata
52
+ end
53
+
54
+ def to_s
55
+ content
56
+ end
57
+
58
+ def truncated?
59
+ metadata[:truncated] == true
60
+ end
61
+
62
+ def line_count
63
+ metadata[:line_count]
64
+ end
65
+
66
+ def ==(other)
67
+ other.is_a?(ToolResult) &&
68
+ content == other.content &&
69
+ metadata == other.metadata
70
+ end
71
+ end
72
+
73
+ # Represents the result of a bash command execution
74
+ class CommandResult
75
+ attr_reader :stdout, :stderr, :exit_code
76
+
77
+ def initialize(stdout:, stderr: "", exit_code: 0)
78
+ @stdout = stdout
79
+ @stderr = stderr
80
+ @exit_code = exit_code
81
+ end
82
+
83
+ def success?
84
+ exit_code.zero?
85
+ end
86
+
87
+ def output
88
+ success? ? stdout : stderr
89
+ end
90
+
91
+ def to_s
92
+ output
93
+ end
94
+
95
+ def ==(other)
96
+ other.is_a?(CommandResult) &&
97
+ stdout == other.stdout &&
98
+ stderr == other.stderr &&
99
+ exit_code == other.exit_code
100
+ end
101
+ end
102
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Rubycode
4
- VERSION = "0.1.1"
3
+ module RubyCode
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/rubycode.rb CHANGED
@@ -1,17 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rubycode/version"
4
+ require_relative "rubycode/errors"
5
+ require_relative "rubycode/value_objects"
4
6
  require_relative "rubycode/configuration"
5
7
  require_relative "rubycode/history"
6
8
  require_relative "rubycode/context_builder"
7
9
  require_relative "rubycode/adapters/base"
8
10
  require_relative "rubycode/adapters/ollama"
9
11
  require_relative "rubycode/tools"
12
+ require_relative "rubycode/agent_loop"
10
13
  require_relative "rubycode/client"
11
14
 
12
- module Rubycode
13
- class Error < StandardError; end
14
-
15
+ # Rubycode is a Ruby-native AI coding agent with pluggable LLM adapters
16
+ module RubyCode
15
17
  class << self
16
18
  attr_accessor :configuration
17
19
  end
data/rubycode_cli.rb CHANGED
@@ -4,7 +4,7 @@
4
4
  require_relative "lib/rubycode"
5
5
  require "readline"
6
6
 
7
- puts "\n" + "=" * 80
7
+ puts "\n#{"=" * 80}"
8
8
  puts "šŸš€ RubyCode - AI Ruby/Rails Code Assistant"
9
9
  puts "=" * 80
10
10
 
@@ -26,27 +26,26 @@ puts "\nšŸ“ Working directory: #{full_path}"
26
26
  # Ask if debug mode should be enabled
27
27
  print "Enable debug mode? (shows JSON requests/responses) [y/N]: "
28
28
  debug_input = gets.chomp.downcase
29
- debug_mode = debug_input == 'y' || debug_input == 'yes'
29
+ debug_mode = %w[y yes].include?(debug_input)
30
30
 
31
31
  # Configure the client
32
- Rubycode.configure do |config|
32
+ RubyCode.configure do |config|
33
33
  config.adapter = :ollama
34
34
  config.url = "http://localhost:11434"
35
35
  config.model = "deepseek-v3.1:671b-cloud"
36
36
  config.root_path = full_path
37
37
  config.debug = debug_mode
38
38
 
39
- # Test deepseek WITHOUT workaround first - it should have better tool-calling than qwen3
40
- # If it fails, enable this: config.enable_tool_injection_workaround = true
41
- config.enable_tool_injection_workaround = false
39
+ # Enable workaround to force tool-calling for models that don't follow instructions
40
+ config.enable_tool_injection_workaround = true
42
41
  end
43
42
 
44
- puts "šŸ› Debug mode: #{debug_mode ? 'ON' : 'OFF'}" if debug_mode
43
+ puts "šŸ› Debug mode: #{debug_mode ? "ON" : "OFF"}" if debug_mode
45
44
 
46
45
  # Create a client
47
- client = Rubycode::Client.new
46
+ client = RubyCode::Client.new
48
47
 
49
- puts "\n" + "=" * 80
48
+ puts "\n#{"=" * 80}"
50
49
  puts "✨ Agent initialized! You can now ask questions or request code changes."
51
50
  puts " Type 'exit' or 'quit' to exit, 'clear' to clear history"
52
51
  puts "=" * 80
@@ -61,16 +60,16 @@ loop do
61
60
 
62
61
  # Handle commands
63
62
  case prompt.strip.downcase
64
- when 'exit', 'quit'
63
+ when "exit", "quit"
65
64
  puts "\nšŸ‘‹ Goodbye!"
66
65
  break
67
- when 'clear'
66
+ when "clear"
68
67
  client.clear_history
69
68
  puts "\nšŸ—‘ļø History cleared!"
70
69
  next
71
70
  end
72
71
 
73
- puts "\n" + "-" * 80
72
+ puts "\n#{"-" * 80}"
74
73
 
75
74
  begin
76
75
  # Get response from agent
@@ -82,7 +81,7 @@ loop do
82
81
  puts "-" * 80
83
82
  rescue Interrupt
84
83
  puts "\n\nāš ļø Interrupted! Type 'exit' to quit or continue chatting."
85
- rescue => e
84
+ rescue StandardError => e
86
85
  puts "\nāŒ Error: #{e.message}"
87
86
  puts e.backtrace.first(3).join("\n")
88
87
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubycode
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonas Medeiros
@@ -22,27 +22,39 @@ files:
22
22
  - README.md
23
23
  - Rakefile
24
24
  - USAGE.md
25
+ - config/tools/bash.json
26
+ - config/tools/done.json
27
+ - config/tools/read.json
28
+ - config/tools/search.json
25
29
  - lib/rubycode.rb
26
30
  - lib/rubycode/adapters/base.rb
27
31
  - lib/rubycode/adapters/ollama.rb
32
+ - lib/rubycode/agent_loop.rb
28
33
  - lib/rubycode/client.rb
34
+ - lib/rubycode/client/display_formatter.rb
35
+ - lib/rubycode/client/response_handler.rb
29
36
  - lib/rubycode/configuration.rb
30
37
  - lib/rubycode/context_builder.rb
38
+ - lib/rubycode/errors.rb
31
39
  - lib/rubycode/history.rb
32
40
  - lib/rubycode/tools.rb
41
+ - lib/rubycode/tools/base.rb
33
42
  - lib/rubycode/tools/bash.rb
34
43
  - lib/rubycode/tools/done.rb
35
44
  - lib/rubycode/tools/read.rb
36
45
  - lib/rubycode/tools/search.rb
46
+ - lib/rubycode/value_objects.rb
37
47
  - lib/rubycode/version.rb
38
- - rubycode-0.1.0.gem
39
48
  - rubycode_cli.rb
40
49
  - sig/rubycode.rbs
41
- homepage: https://example.com
50
+ homepage: https://github.com/jonasmedeiros/rubycode
42
51
  licenses:
43
52
  - MIT
44
53
  metadata:
45
- homepage_uri: https://example.com
54
+ homepage_uri: https://github.com/jonasmedeiros/rubycode
55
+ source_code_uri: https://github.com/jonasmedeiros/rubycode
56
+ bug_tracker_uri: https://github.com/jonasmedeiros/rubycode/issues
57
+ rubygems_mfa_required: 'true'
46
58
  rdoc_options: []
47
59
  require_paths:
48
60
  - lib
data/rubycode-0.1.0.gem DELETED
Binary file