rubycode 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebef8daeefbc9617af948b51c30e6884d661c5522876bb59e6c49767d0339878
4
- data.tar.gz: cbb374c17da942ba865c2e352c0efee5444321f7d23a43ff99722db4065ca464
3
+ metadata.gz: 8da87e3aaf4fd3001f396d9394df720eff277f65a2074ab639f5ca881490fb5f
4
+ data.tar.gz: b5ef26c288070a2b9a2a01914765b95e9778b71473a118e6d0cc7ec9084afafd
5
5
  SHA512:
6
- metadata.gz: db0237c601c7beacd7482cb03cb2f001ed59381a19813a419d4377a0cc354f6554b05d6bc5f215cbbb2ad8337d165dccc01b4d94b2732a45d09b23bf960d5fc8
7
- data.tar.gz: 4134769796321d582bea29d5fc3cb1a3879fa8b05fc7b04b7486615a50c8c01ad1699164371e42aa942ac30f2cbe7712c5c6b88de29e090a962dd0bbb586fbb4
6
+ metadata.gz: 24f9e8016eb87418822608cfd72cdc158c45c3712767b6b18da41b4be52fa58bf49f402e4e0b9c3a5d761a0625eec0e90fb41301c340e51987201d467d4b51fb
7
+ data.tar.gz: 607b13e698d45693ef2041aa89095c91c9286e1939bbb800f823e1e1c64e9d2c8e538ddde11e9f41cbec56447ab12585ff2df5c0b0bc3df236ec0ce35654614c
data/USAGE.md ADDED
@@ -0,0 +1,93 @@
1
+ # RubyCode - Interactive AI Code Assistant
2
+
3
+ An OpenCode-inspired AI agent for Ruby/Rails development that can autonomously explore codebases and suggest changes.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ ruby rubycode_cli.rb
9
+ ```
10
+
11
+ You'll be prompted for:
12
+ 1. **Directory**: Path to your Rails project (default: current directory)
13
+ 2. **Debug mode**: Enable to see JSON requests/responses (default: no)
14
+
15
+ ## Example Usage
16
+
17
+ ```
18
+ šŸ’¬ You: Change the button color to red
19
+
20
+ šŸ¤– Agent:
21
+ šŸ’» ls -la
22
+ šŸ” button
23
+ šŸ“– app/assets/stylesheets/buttons.css
24
+
25
+ I found the button styling in `app/assets/stylesheets/buttons.css:15`
26
+
27
+ To change the button color to red, modify line 15:
28
+
29
+ ```css
30
+ /* Change this: */
31
+ background-color: blue;
32
+
33
+ /* To this: */
34
+ background-color: red;
35
+ ```
36
+ ```
37
+
38
+ ## Available Commands
39
+
40
+ - **Regular prompts**: Ask questions or request code changes
41
+ - **`clear`**: Clear conversation history
42
+ - **`exit`** or **`quit`**: Exit the CLI
43
+
44
+ ## How It Works
45
+
46
+ The agent has access to three safe tools:
47
+
48
+ 1. **bash**: Execute read-only commands (ls, find, cat, etc.)
49
+ 2. **read**: Read file contents with line numbers
50
+ 3. **search**: Search for patterns using grep
51
+
52
+ The agent autonomously decides which tools to use to:
53
+ - Understand your codebase
54
+ - Find relevant files
55
+ - Suggest specific code changes
56
+
57
+ ## Features
58
+
59
+ āœ… **Safe**: Only read-only operations, no destructive commands
60
+ āœ… **Autonomous**: Agent explores codebase on its own
61
+ āœ… **Transparent**: See what the agent is doing (enable debug mode)
62
+ āœ… **Rails-aware**: Optimized for Ruby on Rails projects
63
+ āœ… **Conversational**: Maintains history across multiple questions
64
+
65
+ ## Architecture
66
+
67
+ Built following OpenCode's design:
68
+ - **Tools**: Controlled, safe operations with validation
69
+ - **Agent Loop**: LLM calls tools, we execute, loop until done
70
+ - **History**: Maintains conversation context
71
+ - **JSON Visibility**: Full transparency in debug mode
72
+
73
+ ## Workaround for Weak Tool-Calling Models
74
+
75
+ If you're testing with models that have poor tool-calling capabilities (like qwen3-coder), enable the injection workaround:
76
+
77
+ ```ruby
78
+ Rubycode.configure do |config|
79
+ config.enable_tool_injection_workaround = true
80
+ end
81
+ ```
82
+
83
+ **What this does:**
84
+ - When the model generates text instead of calling a tool, it injects a reminder message
85
+ - Forces the model to keep calling tools until it finds the answer
86
+ - **OpenCode does NOT use this** - they rely on strong tool-calling models (Claude, GPT-4)
87
+ - This is ONLY for testing/development with weaker models
88
+
89
+ **When to disable:**
90
+ - When using Claude (Anthropic)
91
+ - When using GPT-4 (OpenAI)
92
+ - When using Gemini (Google)
93
+ - In production
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycode
4
+ module Adapters
5
+ class Base
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ def generate(prompt:)
11
+ raise NotImplementedError
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module Rubycode
7
+ module Adapters
8
+ class Ollama < Base
9
+ def generate(messages:, system: nil, tools: nil)
10
+ uri = URI("#{@config.url}/api/chat")
11
+
12
+ request = Net::HTTP::Post.new(uri)
13
+ request["Content-Type"] = "application/json"
14
+
15
+ payload = {
16
+ model: @config.model,
17
+ messages: messages,
18
+ stream: false
19
+ }
20
+ payload[:system] = system if system
21
+ payload[:tools] = tools if tools
22
+
23
+ request.body = payload.to_json
24
+
25
+ # DEBUG: Show request if debug mode enabled
26
+ if @config.debug
27
+ puts "\n" + "=" * 80
28
+ puts "šŸ“¤ REQUEST TO LLM"
29
+ puts "=" * 80
30
+ puts "URL: #{uri}"
31
+ puts "Model: #{@config.model}"
32
+ puts "\nPayload:"
33
+ puts JSON.pretty_generate(payload)
34
+ puts "=" * 80
35
+ end
36
+
37
+ response = Net::HTTP.start(uri.hostname, uri.port) do |http|
38
+ http.request(request)
39
+ end
40
+
41
+ body = JSON.parse(response.body)
42
+
43
+ # DEBUG: Show response if debug mode enabled
44
+ if @config.debug
45
+ puts "\n" + "=" * 80
46
+ puts "šŸ“„ RESPONSE FROM LLM"
47
+ puts "=" * 80
48
+ puts JSON.pretty_generate(body)
49
+ puts "=" * 80 + "\n"
50
+ end
51
+
52
+ # /api/chat always returns this structure:
53
+ # { "message": { "role": "assistant", "content": "...", "tool_calls": [...] } }
54
+ body
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycode
4
+ class Client
5
+ attr_reader :history
6
+
7
+ def initialize
8
+ @config = Rubycode.config
9
+ @adapter = build_adapter
10
+ @history = History.new
11
+ end
12
+
13
+ MAX_ITERATIONS = 25 # Maximum number of LLM calls per request
14
+ MAX_TOOL_CALLS = 50 # Maximum total tool calls
15
+
16
+ def ask(prompt:)
17
+ # Add user message to history
18
+ @history.add_message(role: "user", content: prompt)
19
+
20
+ # Build system prompt with environment context
21
+ system_prompt = build_system_prompt
22
+
23
+ iteration = 0
24
+ total_tool_calls = 0
25
+
26
+ # Agent loop: keep calling LLM until no more tool calls
27
+ loop do
28
+ iteration += 1
29
+
30
+ # Check iteration limit
31
+ if iteration > MAX_ITERATIONS
32
+ error_msg = "āš ļø Reached maximum iterations (#{MAX_ITERATIONS}). The agent may be stuck in a loop."
33
+ puts "\n#{error_msg}\n"
34
+ @history.add_message(role: "assistant", content: error_msg)
35
+ return error_msg
36
+ end
37
+
38
+ # Get messages in LLM format
39
+ messages = @history.to_llm_format
40
+
41
+ # Get response from LLM with tools
42
+ response_body = @adapter.generate(
43
+ messages: messages,
44
+ system: system_prompt,
45
+ tools: Tools.definitions
46
+ )
47
+
48
+ # Extract assistant message
49
+ assistant_message = response_body["message"]
50
+ content = assistant_message["content"] || ""
51
+ tool_calls = assistant_message["tool_calls"] || []
52
+
53
+ # Add assistant response to history
54
+ @history.add_message(role: "assistant", content: content)
55
+
56
+ # If no tool calls, we're done
57
+ if tool_calls.empty?
58
+ # ============================================================================
59
+ # WORKAROUND FOR WEAK TOOL-CALLING MODELS (e.g., qwen3-coder)
60
+ # This is ONLY for testing with models that have poor tool-calling capabilities.
61
+ # OpenCode does NOT do this - they rely on good models (Claude, GPT-4).
62
+ # Enable with: config.enable_tool_injection_workaround = true
63
+ # ============================================================================
64
+ if @config.enable_tool_injection_workaround && iteration < 10
65
+ puts " āš ļø No tool calls - injecting reminder (iteration #{iteration})" unless @config.debug
66
+
67
+ @history.add_message(
68
+ role: "user",
69
+ content: "You MUST call a tool. Do not respond with text. Call search, read, bash, or done tool now."
70
+ )
71
+ next # Continue the loop
72
+ end
73
+ # ============================================================================
74
+ # END WORKAROUND
75
+ # ============================================================================
76
+
77
+ puts "\nāœ… Agent finished (#{iteration} iterations, #{total_tool_calls} tool calls)\n" unless @config.debug
78
+ return content
79
+ end
80
+
81
+ # Check tool call limit
82
+ total_tool_calls += tool_calls.length
83
+ if total_tool_calls > MAX_TOOL_CALLS
84
+ error_msg = "āš ļø Reached maximum tool calls (#{MAX_TOOL_CALLS}). Stopping to prevent excessive operations."
85
+ puts "\n#{error_msg}\n"
86
+ @history.add_message(role: "assistant", content: error_msg)
87
+ return content.empty? ? error_msg : content
88
+ end
89
+
90
+ # Execute each tool call
91
+ unless @config.debug
92
+ puts "\nšŸ¤– Iteration #{iteration}: Calling #{tool_calls.length} tool(s)..."
93
+ end
94
+
95
+ done_result = nil
96
+ tool_calls.each do |tool_call|
97
+ result = execute_tool(tool_call)
98
+
99
+ # Check if this was the "done" tool
100
+ if tool_call.dig("function", "name") == "done"
101
+ done_result = result
102
+ break
103
+ end
104
+ end
105
+
106
+ # If done was called, return the result
107
+ if done_result
108
+ puts "\nāœ… Agent finished (#{iteration} iterations, #{total_tool_calls + 1} tool calls)\n" unless @config.debug
109
+ return done_result
110
+ end
111
+
112
+ # Loop continues - send tool results back to LLM
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ def clear_history
119
+ @history.clear
120
+ end
121
+
122
+ private
123
+
124
+ def execute_tool(tool_call)
125
+ tool_name = tool_call.dig("function", "name")
126
+ arguments = tool_call.dig("function", "arguments")
127
+
128
+ if @config.debug
129
+ puts "\nšŸ”§ Tool: #{tool_name}"
130
+ puts " Args: #{arguments.inspect}"
131
+ else
132
+ # Show minimal output
133
+ case tool_name
134
+ when "bash"
135
+ cmd = arguments.is_a?(Hash) ? arguments["command"] : JSON.parse(arguments)["command"]
136
+ puts " šŸ’» #{cmd}"
137
+ when "read"
138
+ file = arguments.is_a?(Hash) ? arguments["file_path"] : JSON.parse(arguments)["file_path"]
139
+ puts " šŸ“– #{file}"
140
+ when "search"
141
+ pattern = arguments.is_a?(Hash) ? arguments["pattern"] : JSON.parse(arguments)["pattern"]
142
+ puts " šŸ” #{pattern}"
143
+ end
144
+ end
145
+
146
+ begin
147
+ # Arguments might be Hash or JSON string
148
+ params = arguments.is_a?(Hash) ? arguments : JSON.parse(arguments)
149
+
150
+ # Execute the tool
151
+ context = { root_path: @config.root_path }
152
+ result = Tools.execute(
153
+ tool_name: tool_name,
154
+ params: params,
155
+ context: context
156
+ )
157
+
158
+ if @config.debug
159
+ puts " āœ“ Result: #{result.lines.first&.strip || '(empty)'}#{result.lines.count > 1 ? "... (#{result.lines.count} lines)" : ""}"
160
+ end
161
+
162
+ # Add tool result to history
163
+ @history.add_message(
164
+ role: "user",
165
+ content: "Tool '#{tool_name}' result:\n#{result}"
166
+ )
167
+
168
+ # Return the result so caller can check if it was "done"
169
+ result
170
+
171
+ rescue JSON::ParserError => e
172
+ error_msg = "Error parsing tool arguments: #{e.message}"
173
+ puts " āœ— #{error_msg}"
174
+ @history.add_message(role: "user", content: error_msg)
175
+ nil
176
+ rescue => e
177
+ error_msg = "Error executing tool: #{e.message}"
178
+ puts " āœ— #{error_msg}"
179
+ @history.add_message(role: "user", content: error_msg)
180
+ nil
181
+ end
182
+ end
183
+
184
+ def build_adapter
185
+ case @config.adapter
186
+ when :ollama
187
+ Adapters::Ollama.new(@config)
188
+ else
189
+ raise "Unknown Adapter"
190
+ end
191
+ end
192
+
193
+ def build_system_prompt
194
+ context = ContextBuilder.new(root_path: @config.root_path).environment_context
195
+
196
+ <<~PROMPT.strip
197
+ You are a helpful Ruby on Rails coding assistant.
198
+
199
+ #{context}
200
+
201
+ # CRITICAL RULE
202
+ You MUST call a tool in EVERY response. You MUST NEVER respond with just text.
203
+
204
+ # Available tools
205
+ - bash: explore directories (ls, find)
206
+ - search: find text inside files (supports case_insensitive parameter)
207
+ - read: view file contents with line numbers
208
+ - done: call this when you have the answer (with your final answer as the parameter)
209
+
210
+ # Required workflow
211
+ 1. Call search with the pattern
212
+ 2. If "No matches found" → call search again with case_insensitive: true
213
+ 3. If still no matches → call search with simpler pattern
214
+ 4. Once found → call read to see the file
215
+ 5. Once you have the answer → call done with your final answer
216
+
217
+ IMPORTANT: You cannot respond with plain text. You must ALWAYS call one of the tools.
218
+ When you're ready to provide your answer, call the "done" tool with your answer as the parameter.
219
+ PROMPT
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycode
4
+ class Configuration
5
+
6
+ attr_accessor :adapter, :url, :model, :root_path, :debug, :enable_tool_injection_workaround
7
+
8
+ def initialize
9
+ @adapter = :ollama
10
+ @url = "http://localhost:11434"
11
+ @model = "qwen3-coder:480b-cloud"
12
+ @root_path = Dir.pwd
13
+ @debug = false # Set to true to see JSON requests/responses
14
+
15
+ # WORKAROUND for weak tool-calling models (qwen3-coder, etc.)
16
+ # When enabled, injects reminder messages if model generates text instead of calling tools
17
+ # OpenCode does NOT use this - they rely on strong tool-calling models (Claude, GPT-4)
18
+ # Set to true ONLY for testing with weak models
19
+ @enable_tool_injection_workaround = false
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycode
4
+ class ContextBuilder
5
+ def initialize(root_path:)
6
+ @root_path = root_path
7
+ end
8
+
9
+ def environment_context
10
+ <<~CONTEXT
11
+ <env>
12
+ Working directory: #{@root_path}
13
+ Platform: #{RUBY_PLATFORM}
14
+ Ruby version: #{RUBY_VERSION}
15
+ Today's date: #{Time.now.strftime('%Y-%m-%d')}
16
+ </env>
17
+ CONTEXT
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycode
4
+ class History
5
+ def initialize
6
+ @messages = []
7
+ end
8
+
9
+ def add_message(role:, content:)
10
+ @messages << {
11
+ role: role,
12
+ content: content,
13
+ timestamp: Time.now
14
+ }
15
+ end
16
+
17
+ def to_llm_format
18
+ @messages.map { |msg| { role: msg[:role], content: msg[:content] } }
19
+ end
20
+
21
+ def clear
22
+ @messages = []
23
+ end
24
+
25
+ def messages
26
+ @messages
27
+ end
28
+
29
+ def last_user_message
30
+ @messages.reverse.find { |msg| msg[:role] == "user" }
31
+ end
32
+
33
+ def last_assistant_message
34
+ @messages.reverse.find { |msg| msg[:role] == "assistant" }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Rubycode
6
+ module Tools
7
+ class Bash
8
+ # Whitelist of safe commands
9
+ SAFE_COMMANDS = %w[
10
+ ls
11
+ pwd
12
+ find
13
+ tree
14
+ cat
15
+ head
16
+ tail
17
+ wc
18
+ file
19
+ which
20
+ echo
21
+ ].freeze
22
+
23
+ def self.definition
24
+ {
25
+ type: "function",
26
+ function: {
27
+ name: "bash",
28
+ description: "Execute safe bash commands for exploring the filesystem and terminal operations.\n\nIMPORTANT: This tool is for terminal operations and directory exploration (ls, find, tree, etc.). DO NOT use it for file operations (reading, searching file contents) - use the specialized tools instead.\n\nWhitelisted commands: #{SAFE_COMMANDS.join(', ')}",
29
+ parameters: {
30
+ type: "object",
31
+ properties: {
32
+ command: {
33
+ type: "string",
34
+ description: "The bash command to execute (e.g., 'ls -la', 'find . -name \"*.rb\"', 'tree app')"
35
+ }
36
+ },
37
+ required: ["command"]
38
+ }
39
+ }
40
+ }
41
+ end
42
+
43
+ def self.execute(params:, context:)
44
+ command = params["command"].strip
45
+
46
+ # Extract the base command (first word)
47
+ base_command = command.split.first
48
+
49
+ unless SAFE_COMMANDS.include?(base_command)
50
+ return "Error: Command '#{base_command}' is not allowed. Safe commands: #{SAFE_COMMANDS.join(', ')}"
51
+ end
52
+
53
+ # Execute in the project's root directory
54
+ Dir.chdir(context[:root_path]) do
55
+ output = `#{command} 2>&1`
56
+ exit_code = $?.exitstatus
57
+
58
+ if exit_code == 0
59
+ # Limit output length
60
+ lines = output.split("\n")
61
+ if lines.length > 200
62
+ lines[0..199].join("\n") + "\n\n... (#{lines.length - 200} more lines truncated)"
63
+ else
64
+ output
65
+ end
66
+ else
67
+ "Command failed with exit code #{exit_code}:\n#{output}"
68
+ end
69
+ end
70
+ rescue => e
71
+ "Error executing command: #{e.message}"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycode
4
+ module Tools
5
+ class Done
6
+ def self.definition
7
+ {
8
+ type: "function",
9
+ function: {
10
+ name: "done",
11
+ description: "Call this when you have found the code and are ready to provide your final answer. This signals you are finished exploring.",
12
+ parameters: {
13
+ type: "object",
14
+ properties: {
15
+ answer: {
16
+ type: "string",
17
+ description: "Your final answer showing the file, line number, current code, and suggested change"
18
+ }
19
+ },
20
+ required: ["answer"]
21
+ }
22
+ }
23
+ }
24
+ end
25
+
26
+ def self.execute(params:, context:)
27
+ # Just return the answer - this is the final response
28
+ params["answer"]
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycode
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
33
+
34
+ def self.execute(params:, context:)
35
+ file_path = params["file_path"]
36
+ offset = params["offset"] || 1
37
+ limit = params["limit"] || 2000
38
+
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
45
+
46
+ unless File.exist?(full_path)
47
+ return "Error: File '#{file_path}' does not exist"
48
+ end
49
+
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
55
+
56
+ lines = File.readlines(full_path)
57
+ start_idx = [offset - 1, 0].max
58
+ end_idx = [start_idx + limit - 1, lines.length - 1].min
59
+
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
68
+
69
+ result.join("\n")
70
+ rescue => e
71
+ "Error reading file: #{e.message}"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Rubycode
6
+ 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
39
+
40
+ def self.execute(params:, context:)
41
+ pattern = params["pattern"]
42
+ path = params["path"] || "."
43
+ include_pattern = params["include"]
44
+ case_insensitive = params["case_insensitive"] || false
45
+
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
52
+
53
+ unless File.exist?(full_path)
54
+ return "Error: Path '#{path}' does not exist"
55
+ end
56
+
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)
63
+
64
+ command = cmd_parts.join(" ")
65
+
66
+ output = `#{command} 2>&1`
67
+ exit_code = $?.exitstatus
68
+
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
79
+ "No matches found for pattern: #{pattern}"
80
+ else
81
+ # Error occurred
82
+ "Error running search: #{output}"
83
+ end
84
+ rescue => e
85
+ "Error: #{e.message}"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tools/bash"
4
+ require_relative "tools/read"
5
+ require_relative "tools/search"
6
+ require_relative "tools/done"
7
+
8
+ module Rubycode
9
+ module Tools
10
+ # Registry of all available tools
11
+ TOOLS = [
12
+ Bash,
13
+ Read,
14
+ Search,
15
+ Done
16
+ ].freeze
17
+
18
+ def self.definitions
19
+ TOOLS.map(&:definition)
20
+ end
21
+
22
+ def self.execute(tool_name:, params:, context:)
23
+ tool_class = TOOLS.find { |t| t.definition[:function][:name] == tool_name }
24
+
25
+ unless tool_class
26
+ return "Error: Unknown tool '#{tool_name}'"
27
+ end
28
+
29
+ tool_class.execute(params: params, context: context)
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubycode
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/rubycode.rb CHANGED
@@ -1,8 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rubycode/version"
4
+ require_relative "rubycode/configuration"
5
+ require_relative "rubycode/history"
6
+ require_relative "rubycode/context_builder"
7
+ require_relative "rubycode/adapters/base"
8
+ require_relative "rubycode/adapters/ollama"
9
+ require_relative "rubycode/tools"
10
+ require_relative "rubycode/client"
4
11
 
5
12
  module Rubycode
6
13
  class Error < StandardError; end
7
- # Your code goes here...
14
+
15
+ class << self
16
+ attr_accessor :configuration
17
+ end
18
+
19
+ def self.configure
20
+ self.configuration ||= Configuration.new
21
+ yield(configuration)
22
+ end
23
+
24
+ def self.config
25
+ self.configuration ||= Configuration.new
26
+ end
8
27
  end
Binary file
data/rubycode_cli.rb ADDED
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "lib/rubycode"
5
+ require "readline"
6
+
7
+ puts "\n" + "=" * 80
8
+ puts "šŸš€ RubyCode - AI Ruby/Rails Code Assistant"
9
+ puts "=" * 80
10
+
11
+ # Ask for directory
12
+ print "\nWhat directory do you want to work on? (default: current directory): "
13
+ directory = gets.chomp
14
+ directory = Dir.pwd if directory.empty?
15
+
16
+ # Resolve the full path
17
+ full_path = File.expand_path(directory)
18
+
19
+ unless Dir.exist?(full_path)
20
+ puts "\nāŒ Error: Directory '#{full_path}' does not exist!"
21
+ exit 1
22
+ end
23
+
24
+ puts "\nšŸ“ Working directory: #{full_path}"
25
+
26
+ # Ask if debug mode should be enabled
27
+ print "Enable debug mode? (shows JSON requests/responses) [y/N]: "
28
+ debug_input = gets.chomp.downcase
29
+ debug_mode = debug_input == 'y' || debug_input == 'yes'
30
+
31
+ # Configure the client
32
+ Rubycode.configure do |config|
33
+ config.adapter = :ollama
34
+ config.url = "http://localhost:11434"
35
+ config.model = "deepseek-v3.1:671b-cloud"
36
+ config.root_path = full_path
37
+ config.debug = debug_mode
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
42
+ end
43
+
44
+ puts "šŸ› Debug mode: #{debug_mode ? 'ON' : 'OFF'}" if debug_mode
45
+
46
+ # Create a client
47
+ client = Rubycode::Client.new
48
+
49
+ puts "\n" + "=" * 80
50
+ puts "✨ Agent initialized! You can now ask questions or request code changes."
51
+ puts " Type 'exit' or 'quit' to exit, 'clear' to clear history"
52
+ puts "=" * 80
53
+
54
+ # Interactive loop
55
+ loop do
56
+ print "\nšŸ’¬ You: "
57
+ prompt = Readline.readline("", true)
58
+
59
+ # Handle empty input
60
+ next if prompt.nil? || prompt.strip.empty?
61
+
62
+ # Handle commands
63
+ case prompt.strip.downcase
64
+ when 'exit', 'quit'
65
+ puts "\nšŸ‘‹ Goodbye!"
66
+ break
67
+ when 'clear'
68
+ client.clear_history
69
+ puts "\nšŸ—‘ļø History cleared!"
70
+ next
71
+ end
72
+
73
+ puts "\n" + "-" * 80
74
+
75
+ begin
76
+ # Get response from agent
77
+ response = client.ask(prompt: prompt)
78
+
79
+ puts "\nšŸ¤– Agent:"
80
+ puts "-" * 80
81
+ puts response
82
+ puts "-" * 80
83
+ rescue Interrupt
84
+ puts "\n\nāš ļø Interrupted! Type 'exit' to quit or continue chatting."
85
+ rescue => e
86
+ puts "\nāŒ Error: #{e.message}"
87
+ puts e.backtrace.first(3).join("\n")
88
+ end
89
+ 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.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonas Medeiros
@@ -21,8 +21,22 @@ files:
21
21
  - LICENSE.txt
22
22
  - README.md
23
23
  - Rakefile
24
+ - USAGE.md
24
25
  - lib/rubycode.rb
26
+ - lib/rubycode/adapters/base.rb
27
+ - lib/rubycode/adapters/ollama.rb
28
+ - lib/rubycode/client.rb
29
+ - lib/rubycode/configuration.rb
30
+ - lib/rubycode/context_builder.rb
31
+ - lib/rubycode/history.rb
32
+ - lib/rubycode/tools.rb
33
+ - lib/rubycode/tools/bash.rb
34
+ - lib/rubycode/tools/done.rb
35
+ - lib/rubycode/tools/read.rb
36
+ - lib/rubycode/tools/search.rb
25
37
  - lib/rubycode/version.rb
38
+ - rubycode-0.1.0.gem
39
+ - rubycode_cli.rb
26
40
  - sig/rubycode.rbs
27
41
  homepage: https://example.com
28
42
  licenses: