ruboto-ai 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: aab699c78829f06e755d96f99c0dfcb39053d209e4ee99d61f198a6c33b650c5
4
+ data.tar.gz: be0851dcdfb649041a4c75bea567f3369253687e5a4e1fe562f1461c69343ee0
5
+ SHA512:
6
+ metadata.gz: 6fb78c938b1bbc219b5debd6fcd1e0038a6bfbe176d0abada304aceed5379888274a2106f10e922f8edd63e3c4149d12ad0abad7dc0c0bea78882dc95f6db8f0
7
+ data.tar.gz: 7846fad4648aca6329c9db9845624b8c4e2224e5f578c620fea1bae19528be5e2bffb3c07f1faedcbe04dc79f4af5f1c5513755d058ea62ed0955c811ed1f0dc
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Akhil Gautam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # Ruboto
2
+
3
+ A minimal agentic coding assistant for the terminal. Built in Ruby, powered by multiple LLM providers via OpenRouter.
4
+
5
+ ## Features
6
+
7
+ - **Multi-model support**: GPT-4o, Claude Sonnet, Gemini, Llama, DeepSeek
8
+ - **Agentic tools**: Read, write, edit files, run shell commands, search codebases
9
+ - **Meta-tools**: High-level tools for exploration, verification, and patching
10
+ - **Conversation history**: Persisted in SQLite with session tracking
11
+ - **Autonomous operation**: Acts first, asks questions only when needed
12
+ - **Zero dependencies**: Pure Ruby stdlib, no external gems required
13
+
14
+ ## Installation
15
+
16
+ ### From RubyGems
17
+
18
+ ```bash
19
+ gem install ruboto-ai
20
+ ```
21
+
22
+ ### From Source
23
+
24
+ ```bash
25
+ git clone https://github.com/akhilgautam/ruboto-ai.git
26
+ cd ruboto-ai
27
+ gem build ruboto.gemspec
28
+ gem install ruboto-ai-0.1.0.gem
29
+ ```
30
+
31
+ ### Configuration
32
+
33
+ Set your OpenRouter API key:
34
+
35
+ ```bash
36
+ export OPENROUTER_API_KEY="your-api-key-here"
37
+ ```
38
+
39
+ Add this to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.) to persist it.
40
+
41
+ ## Usage
42
+
43
+ ```bash
44
+ ruboto-ai
45
+ ```
46
+
47
+ ### Starting a Session
48
+
49
+ ```
50
+ $ ruboto-ai
51
+
52
+ ██████╗ ██╗ ██╗██████╗ ██████╗ ████████╗ ██████╗
53
+ ██╔══██╗██║ ██║██╔══██╗██╔═══██╗╚══██╔══╝██╔═══██╗
54
+ ██████╔╝██║ ██║██████╔╝██║ ██║ ██║ ██║ ██║
55
+ ██╔══██╗██║ ██║██╔══██╗██║ ██║ ██║ ██║ ██║
56
+ ██║ ██║╚██████╔╝██████╔╝╚██████╔╝ ██║ ╚██████╔╝
57
+ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝
58
+
59
+ Professional YAML indenter
60
+
61
+ myproject
62
+
63
+ Select a model:
64
+
65
+ 1. GPT-4o OpenAI flagship
66
+ 2. GPT-4o Mini Fast & cheap
67
+ 3. Claude Sonnet 4 Anthropic's latest
68
+ 4. Claude 3.5 Sonnet Great for code
69
+ 5. Gemini 2.0 Flash Google's fast model
70
+ 6. Llama 3.3 70B Open source
71
+ 7. DeepSeek Chat Strong reasoning
72
+
73
+ Enter number (1-7):
74
+ ```
75
+
76
+ Select a model by number, then start chatting.
77
+
78
+ ### Example Interactions
79
+
80
+ **Exploring a codebase:**
81
+ ```
82
+ > where is the authentication logic?
83
+
84
+ ⏺ Exploring: where is the authentication logic?
85
+ ⎿ Question: where is the authentication logic? (+15 more lines)
86
+
87
+ Authentication is handled in two places:
88
+ - src/auth/login.js - main login logic with JWT token generation
89
+ - src/middleware/auth.js - route protection middleware
90
+ ```
91
+
92
+ **Debugging a bug:**
93
+ ```
94
+ > the todo app is not working, clicking the add button does nothing
95
+
96
+ ⏺ Listing directory
97
+ ⎿ ├── app.js (+5 more)
98
+
99
+ ⏺ Reading app.js
100
+ ⎿ 1| import { loadTodos, saveTodos } from "./storage"; ...
101
+
102
+ ⏺ Patching app.js
103
+ ⎿ ok: applied 1 hunk(s)
104
+
105
+ ⏺ Verifying: npm test
106
+ ⎿ {"status":"success","attempts":1,"output":"PASS"}
107
+
108
+ Fixed! The addBtn click handler was missing. Added it and verified tests pass.
109
+ ```
110
+
111
+ **Creating a new file:**
112
+ ```
113
+ > create a utility function to format dates in utils.js
114
+
115
+ ⏺ Writing to utils.js
116
+ ⎿ ok
117
+
118
+ Created utils.js with a formatDate function that handles ISO strings and Date objects.
119
+ ```
120
+
121
+ **Running commands with verification:**
122
+ ```
123
+ > run the tests and make sure they all pass
124
+
125
+ ⏺ Verifying: npm test
126
+ ⎿ {"status":"success","attempts":1,"output":"PASS src/todo.test.js..."}
127
+
128
+ All 5 tests passed.
129
+ ```
130
+
131
+ **Applying multi-line changes:**
132
+ ```
133
+ > add error handling to the fetchUser function
134
+
135
+ ⏺ Reading api.js
136
+ ⎿ 1| export async function fetchUser(id) { ...
137
+
138
+ ⏺ Patching api.js
139
+ ⎿ ok: applied 1 hunk(s)
140
+
141
+ ⏺ Verifying: npm test
142
+ ⎿ {"status":"success","attempts":1,"output":"PASS"}
143
+
144
+ Added try/catch with proper error handling. Tests still pass.
145
+ ```
146
+
147
+ ### Commands
148
+
149
+ | Command | Description |
150
+ |---------|-------------|
151
+ | `/h` | Show help |
152
+ | `/c` | Clear conversation context |
153
+ | `/q` | Quit |
154
+ | `/history` | Show recent commands |
155
+ | `Ctrl+C` | Exit |
156
+
157
+ ## Available Tools
158
+
159
+ ### Meta-Tools (Preferred)
160
+
161
+ | Tool | Description |
162
+ |------|-------------|
163
+ | `explore` | Answer "where is X?" / "how does Y work?" questions automatically |
164
+ | `patch` | Apply unified diffs for multi-line edits (more reliable than string replace) |
165
+ | `verify` | Run commands and check success/failure with optional retries |
166
+
167
+ ### Primitive Tools
168
+
169
+ | Tool | Description |
170
+ |------|-------------|
171
+ | `read` | Read file contents with line numbers |
172
+ | `write` | Create or overwrite a file |
173
+ | `edit` | Modify a file (find & replace, must be unique match) |
174
+ | `glob` | Find files by pattern (`*.js`, `**/*.test.rb`) |
175
+ | `grep` | Search file contents with regex |
176
+ | `find` | Locate files by name substring |
177
+ | `tree` | Show directory structure |
178
+ | `bash` | Run shell commands (git, npm, python, etc.) |
179
+
180
+ ## Supported Models
181
+
182
+ | Model | Provider | Best For |
183
+ |-------|----------|----------|
184
+ | GPT-4o | OpenAI | General coding tasks |
185
+ | GPT-4o Mini | OpenAI | Fast, cheap tasks |
186
+ | Claude Sonnet 4 | Anthropic | Complex reasoning |
187
+ | Claude 3.5 Sonnet | Anthropic | Code generation |
188
+ | Gemini 2.0 Flash | Google | Fast responses |
189
+ | Llama 3.3 70B | Meta | Open source option |
190
+ | DeepSeek Chat | DeepSeek | Strong reasoning |
191
+
192
+ ## Data Storage
193
+
194
+ Ruboto stores data in `~/.ruboto/`:
195
+
196
+ | File | Purpose |
197
+ |------|---------|
198
+ | `history.db` | Conversation history (SQLite) |
199
+
200
+ ## Requirements
201
+
202
+ - Ruby 3.0+
203
+ - SQLite3 (usually pre-installed on macOS/Linux)
204
+ - OpenRouter API key ([get one here](https://openrouter.ai/keys))
205
+
206
+ ## Development
207
+
208
+ ```bash
209
+ # Clone the repo
210
+ git clone https://github.com/akhilgautam/ruboto-ai.git
211
+ cd ruboto-ai
212
+
213
+ # Run directly without installing
214
+ ruby -Ilib bin/ruboto-ai
215
+
216
+ # Build the gem
217
+ gem build ruboto.gemspec
218
+
219
+ # Install locally
220
+ gem install ruboto-ai-0.1.0.gem
221
+
222
+ # Uninstall
223
+ gem uninstall ruboto-ai
224
+ ```
225
+
226
+ ## License
227
+
228
+ MIT - See [LICENSE.txt](LICENSE.txt)
data/bin/ruboto-ai ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "ruboto"
5
+
6
+ Ruboto.run
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboto
4
+ VERSION = "0.1.0"
5
+ end
data/lib/ruboto.rb ADDED
@@ -0,0 +1,1056 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "openssl"
7
+ require "readline"
8
+ require "open3"
9
+
10
+ require_relative "ruboto/version"
11
+
12
+ module Ruboto
13
+ API_URL = "https://openrouter.ai/api/v1/chat/completions"
14
+
15
+ MODELS = [
16
+ { id: "openai/gpt-4o", name: "GPT-4o", desc: "OpenAI flagship" },
17
+ { id: "openai/gpt-4o-mini", name: "GPT-4o Mini", desc: "Fast & cheap" },
18
+ { id: "anthropic/claude-sonnet-4", name: "Claude Sonnet 4", desc: "Anthropic's latest" },
19
+ { id: "anthropic/claude-3.5-sonnet", name: "Claude 3.5 Sonnet", desc: "Great for code" },
20
+ { id: "google/gemini-2.0-flash-001", name: "Gemini 2.0 Flash", desc: "Google's fast model" },
21
+ { id: "meta-llama/llama-3.3-70b-instruct", name: "Llama 3.3 70B", desc: "Open source" },
22
+ { id: "deepseek/deepseek-chat", name: "DeepSeek Chat", desc: "Strong reasoning" }
23
+ ].freeze
24
+
25
+ # ANSI colors
26
+ RESET = "\033[0m"
27
+ BOLD = "\033[1m"
28
+ DIM = "\033[2m"
29
+ BLUE = "\033[34m"
30
+ CYAN = "\033[36m"
31
+ GREEN = "\033[32m"
32
+ YELLOW = "\033[33m"
33
+ RED = "\033[31m"
34
+
35
+ MAX_OUTPUT = 4000
36
+ IGNORE_DIRS = [".git", "node_modules", "__pycache__", ".venv", "venv", ".bundle", "vendor", "tmp", "log", "coverage"].freeze
37
+
38
+ # History configuration
39
+ RUBOTO_DIR = File.expand_path("~/.ruboto")
40
+ DB_PATH = File.join(RUBOTO_DIR, "history.db")
41
+ MAX_HISTORY_LOAD = 100
42
+
43
+ # Spinner frames (braille dots for smooth animation)
44
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
45
+
46
+ LOGO = <<~'ASCII'
47
+ ██████╗ ██╗ ██╗██████╗ ██████╗ ████████╗ ██████╗
48
+ ██╔══██╗██║ ██║██╔══██╗██╔═══██╗╚══██╔══╝██╔═══██╗
49
+ ██████╔╝██║ ██║██████╔╝██║ ██║ ██║ ██║ ██║
50
+ ██╔══██╗██║ ██║██╔══██╗██║ ██║ ██║ ██║ ██║
51
+ ██║ ██║╚██████╔╝██████╔╝╚██████╔╝ ██║ ╚██████╔╝
52
+ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝
53
+ ASCII
54
+
55
+ TAGLINES = [
56
+ "Your mass-produced artisanal code monkey",
57
+ "Writes code. Breaks things. Blames the compiler.",
58
+ "50% AI, 50% chaos, 100% overconfident",
59
+ "I've read Stack Overflow so you don't have to",
60
+ "Will code for electricity",
61
+ "Professional YAML indenter",
62
+ "I put the 'pro' in 'probably works'",
63
+ "Powered by mass compute and strong opinions"
64
+ ].freeze
65
+
66
+ class << self
67
+ # Human-readable tool action messages
68
+ def tool_message(name, args)
69
+ case name
70
+ when "read"
71
+ path = args["path"] || "file"
72
+ "Reading #{File.basename(path)}"
73
+ when "write"
74
+ path = args["path"] || "file"
75
+ "Writing to #{File.basename(path)}"
76
+ when "edit"
77
+ path = args["path"] || "file"
78
+ "Editing #{File.basename(path)}"
79
+ when "glob"
80
+ pattern = args["pattern"] || "*"
81
+ "Searching for #{pattern}"
82
+ when "grep"
83
+ pattern = args["pattern"] || "pattern"
84
+ "Searching for '#{pattern[0, 30]}'"
85
+ when "bash"
86
+ cmd = args["cmd"] || ""
87
+ cmd_preview = cmd.split.first(2).join(" ")
88
+ "Running #{cmd_preview}"
89
+ when "tree"
90
+ path = args["path"] || "."
91
+ "Listing #{path == "." ? "directory" : path}"
92
+ when "find"
93
+ name_arg = args["name"] || ""
94
+ "Finding files matching '#{name_arg}'"
95
+ when "explore"
96
+ question = args["question"] || "codebase"
97
+ "Exploring: #{question[0, 40]}#{question.length > 40 ? '...' : ''}"
98
+ when "verify"
99
+ cmd = args["command"] || ""
100
+ cmd_preview = cmd.split.first(2).join(" ")
101
+ "Verifying: #{cmd_preview}"
102
+ when "patch"
103
+ path = args["path"] || "file"
104
+ "Patching #{File.basename(path)}"
105
+ else
106
+ name.capitalize.to_s
107
+ end
108
+ end
109
+
110
+ # Run a block with a spinner, returns the block's result
111
+ def with_spinner(message)
112
+ result = nil
113
+ done = false
114
+ spinner_thread = Thread.new do
115
+ i = 0
116
+ while !done
117
+ print "\r#{YELLOW}#{SPINNER_FRAMES[i % SPINNER_FRAMES.length]}#{RESET} #{message}"
118
+ $stdout.flush
119
+ sleep 0.08
120
+ i += 1
121
+ end
122
+ end
123
+
124
+ begin
125
+ result = yield
126
+ ensure
127
+ done = true
128
+ spinner_thread.join
129
+ print "\r#{GREEN}⏺#{RESET} #{message}\n"
130
+ end
131
+
132
+ result
133
+ end
134
+
135
+ # --- Tool implementations ---
136
+
137
+ def tool_read(args)
138
+ path = args["path"]
139
+ offset = args["offset"] || 0
140
+ limit = args["limit"]
141
+
142
+ lines = File.readlines(path)
143
+ limit ||= lines.length
144
+ selected = lines[offset, limit] || []
145
+
146
+ selected.each_with_index.map { |line, idx| format("%4d| %s", offset + idx + 1, line) }.join
147
+ end
148
+
149
+ def tool_write(args)
150
+ File.write(args["path"], args["content"])
151
+ "ok"
152
+ end
153
+
154
+ def tool_edit(args)
155
+ path, old, new_str = args["path"], args["old"], args["new"]
156
+ replace_all = args["all"] || false
157
+
158
+ text = File.read(path)
159
+
160
+ return "error: old_string not found" unless text.include?(old)
161
+
162
+ count = text.scan(old).length
163
+ if !replace_all && count > 1
164
+ return "error: old_string appears #{count} times, must be unique (use all=true)"
165
+ end
166
+
167
+ replacement = replace_all ? text.gsub(old, new_str) : text.sub(old, new_str)
168
+ File.write(path, replacement)
169
+ "ok"
170
+ end
171
+
172
+ def tool_glob(args)
173
+ base_path = args["path"] || "."
174
+ pattern = File.join(base_path, args["pattern"]).gsub("//", "/")
175
+
176
+ files = Dir.glob(pattern, File::FNM_DOTMATCH)
177
+ files = files.select { |f| File.file?(f) }
178
+ .sort_by { |f| -File.mtime(f).to_i }
179
+
180
+ files.empty? ? "none" : files.join("\n")
181
+ end
182
+
183
+ def tool_grep(args)
184
+ pattern = Regexp.new(args["pattern"])
185
+ path = args["path"] || "."
186
+ type = args["type"]
187
+ limit = args["limit"] || 30
188
+
189
+ glob_pattern = type ? File.join(path, "**", "*.#{type}") : File.join(path, "**", "*")
190
+ hits = []
191
+
192
+ Dir.glob(glob_pattern).each do |filepath|
193
+ next unless File.file?(filepath)
194
+ begin
195
+ File.readlines(filepath).each_with_index do |line, idx|
196
+ if pattern.match?(line)
197
+ hits << "#{filepath}:#{idx + 1}:#{line.rstrip}"
198
+ break if hits.length >= limit
199
+ end
200
+ end
201
+ rescue
202
+ # Skip unreadable files
203
+ end
204
+ break if hits.length >= limit
205
+ end
206
+
207
+ hits.empty? ? "none" : hits.join("\n")
208
+ end
209
+
210
+ def tool_bash(args)
211
+ cmd = args["cmd"]
212
+
213
+ # Allowlist of valid command prefixes
214
+ valid_commands = %w[
215
+ git npm npx node ruby python python3 pip pip3 cargo rustc go
216
+ ls cat head tail less more file wc grep awk sed find xargs
217
+ cd pwd mkdir rmdir rm cp mv ln chmod chown touch
218
+ curl wget ssh scp rsync tar zip unzip gzip gunzip
219
+ docker docker-compose kubectl helm
220
+ make cmake gcc g++ clang javac java
221
+ bundle gem rake rails yarn pnpm bun deno
222
+ brew apt yum dnf pacman
223
+ echo printf test expr date cal
224
+ ps top kill pkill htop df du free
225
+ open code vim nano
226
+ ]
227
+
228
+ first_word = cmd.strip.split(/\s+/).first&.downcase || ""
229
+
230
+ unless valid_commands.include?(first_word)
231
+ return "error: '#{first_word}' is not a recognized command. Use bash only for shell commands like: git, npm, node, python, ls, etc."
232
+ end
233
+
234
+ # Reject backticks as a safety measure
235
+ if cmd.include?("`")
236
+ return "error: backticks not allowed in commands (causes shell command substitution)"
237
+ end
238
+
239
+ output = `#{cmd} 2>&1`
240
+ output.strip.empty? ? "(empty)" : output.strip
241
+ rescue => e
242
+ "error: #{e.message}"
243
+ end
244
+
245
+ def tool_tree(args)
246
+ path = args["path"] || "."
247
+ depth = args["depth"] || 3
248
+ result = get_file_tree(path, depth)
249
+ result.empty? ? "(empty)" : result
250
+ end
251
+
252
+ def tool_find(args)
253
+ name = args["name"]
254
+ path = args["path"] || "."
255
+
256
+ matches = Dir.glob(File.join(path, "**", "*"))
257
+ .reject { |f| IGNORE_DIRS.any? { |i| f.split("/").include?(i) } }
258
+ .select { |f| File.file?(f) && File.basename(f).downcase.include?(name.downcase) }
259
+ .first(20)
260
+
261
+ matches.empty? ? "none" : matches.join("\n")
262
+ end
263
+
264
+ def extract_keywords(question)
265
+ stop_words = %w[the a an is are was were what where how does do did can could would should this that these those]
266
+ words = question.downcase.gsub(/[^\w\s]/, '').split
267
+ words.reject { |w| stop_words.include?(w) || w.length < 3 }
268
+ end
269
+
270
+ def tool_explore(args)
271
+ question = args["question"]
272
+ scope = args["scope"] || "."
273
+
274
+ keywords = extract_keywords(question)
275
+ return "error: couldn't extract keywords from question" if keywords.empty?
276
+
277
+ # Phase 1: Get structure overview
278
+ structure = get_file_tree(scope, 2)
279
+
280
+ # Phase 2: Search for keywords
281
+ pattern = keywords.first(3).join("|")
282
+ hits = tool_grep("pattern" => pattern, "path" => scope, "limit" => 15)
283
+
284
+ if hits == "none"
285
+ # Fallback: try glob for filenames
286
+ keywords.each do |kw|
287
+ file_hits = tool_find("name" => kw, "path" => scope)
288
+ next if file_hits == "none"
289
+ hits = file_hits
290
+ break
291
+ end
292
+ end
293
+
294
+ return "No matches found for: #{question}\n\nDirectory structure:\n#{structure}" if hits == "none"
295
+
296
+ # Phase 3: Extract unique files and read top 3
297
+ files = hits.lines.map { |l| l.split(":").first }.uniq.first(3)
298
+
299
+ context = files.map do |f|
300
+ content = tool_read("path" => f, "limit" => 50)
301
+ "=== #{f} ===\n#{content}"
302
+ end.join("\n\n")
303
+
304
+ "Question: #{question}\n\nFiles found: #{files.join(', ')}\n\n#{context}"
305
+ rescue => e
306
+ "error: #{e.message}"
307
+ end
308
+
309
+ def tool_verify(args)
310
+ cmd = args["command"]
311
+ expect_pattern = args["expect_pattern"]
312
+ fail_pattern = args["fail_pattern"]
313
+ retries = args["retries"] || 0
314
+ retries = [retries, 10].min
315
+
316
+ # Validate command against allowlist
317
+ first_word = cmd.strip.split(/\s+/).first&.downcase || ""
318
+ valid_commands = %w[
319
+ git npm npx node ruby python python3 pip pip3 cargo rustc go
320
+ ls cat head tail less more file wc grep awk sed find xargs
321
+ bundle gem rake rails yarn pnpm bun deno
322
+ make cmake gcc g++ clang javac java
323
+ pytest rspec jest mocha
324
+ echo test expr
325
+ ]
326
+
327
+ unless valid_commands.include?(first_word)
328
+ return { status: "error", message: "command '#{first_word}' not in allowlist" }.to_json
329
+ end
330
+
331
+ if cmd.include?("`")
332
+ return { status: "error", message: "backticks not allowed in commands" }.to_json
333
+ end
334
+
335
+ attempts = 0
336
+ output = ""
337
+ exit_code = 0
338
+
339
+ loop do
340
+ attempts += 1
341
+ output = `#{cmd} 2>&1`
342
+ exit_code = $?.exitstatus
343
+
344
+ passed = exit_code == 0
345
+ passed &&= output.match?(Regexp.new(expect_pattern)) if expect_pattern
346
+ passed = false if fail_pattern && output.match?(Regexp.new(fail_pattern))
347
+
348
+ if passed
349
+ return {
350
+ status: "success",
351
+ attempts: attempts,
352
+ output: truncate_output(output, 1000)
353
+ }.to_json
354
+ end
355
+
356
+ break if attempts > retries
357
+ sleep 0.5
358
+ end
359
+
360
+ {
361
+ status: "failed",
362
+ attempts: attempts,
363
+ exit_code: exit_code,
364
+ output: truncate_output(output, 2000)
365
+ }.to_json
366
+ rescue => e
367
+ { status: "error", message: e.message }.to_json
368
+ end
369
+
370
+ def parse_unified_diff(diff)
371
+ hunks = []
372
+ current_hunk = nil
373
+
374
+ diff.lines.each do |line|
375
+ if line.start_with?("@@")
376
+ match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/)
377
+ if match
378
+ current_hunk = {
379
+ old_start: match[1].to_i,
380
+ old_count: match[2].empty? ? 1 : match[2].to_i,
381
+ new_start: match[3].to_i,
382
+ new_count: match[4].empty? ? 1 : match[4].to_i,
383
+ old_lines: [],
384
+ new_lines: []
385
+ }
386
+ hunks << current_hunk
387
+ end
388
+ elsif current_hunk
389
+ case line[0]
390
+ when "-"
391
+ current_hunk[:old_lines] << line[1..].chomp
392
+ when "+"
393
+ current_hunk[:new_lines] << line[1..].chomp
394
+ when " "
395
+ current_hunk[:old_lines] << line[1..].chomp
396
+ current_hunk[:new_lines] << line[1..].chomp
397
+ end
398
+ end
399
+ end
400
+
401
+ hunks
402
+ end
403
+
404
+ def fuzzy_find_hunk(lines, old_lines, expected_start, tolerance: 20)
405
+ return expected_start - 1 if old_lines.empty?
406
+
407
+ search_start = [expected_start - tolerance - 1, 0].max
408
+ search_end = [expected_start + tolerance - 1, lines.length - 1].min
409
+
410
+ (search_start..search_end).each do |idx|
411
+ match = old_lines.each_with_index.all? do |old_line, offset|
412
+ lines[idx + offset]&.chomp == old_line
413
+ end
414
+ return idx if match
415
+ end
416
+
417
+ # Fallback: search entire file
418
+ lines.each_with_index do |_, idx|
419
+ match = old_lines.each_with_index.all? do |old_line, offset|
420
+ lines[idx + offset]&.chomp == old_line
421
+ end
422
+ return idx if match
423
+ end
424
+
425
+ nil
426
+ end
427
+
428
+ def tool_patch(args)
429
+ path = args["path"]
430
+ diff = args["diff"]
431
+
432
+ return "error: file not found: #{path}" unless File.exist?(path)
433
+
434
+ lines = File.readlines(path)
435
+ hunks = parse_unified_diff(diff)
436
+
437
+ return "error: no valid hunks found in diff" if hunks.empty?
438
+
439
+ # Apply hunks in reverse order to preserve line numbers
440
+ hunks.reverse.each_with_index do |hunk, idx|
441
+ actual_start = fuzzy_find_hunk(lines, hunk[:old_lines], hunk[:old_start])
442
+
443
+ unless actual_start
444
+ return "error: couldn't locate hunk #{hunks.length - idx} near line #{hunk[:old_start]}"
445
+ end
446
+
447
+ lines.slice!(actual_start, hunk[:old_lines].length)
448
+ hunk[:new_lines].reverse.each do |new_line|
449
+ lines.insert(actual_start, new_line + "\n")
450
+ end
451
+ end
452
+
453
+ File.write(path, lines.join)
454
+ "ok: applied #{hunks.length} hunk(s)"
455
+ rescue => e
456
+ "error: #{e.message}"
457
+ end
458
+
459
+ # --- Tool definitions ---
460
+
461
+ def tools
462
+ @tools ||= {
463
+ "read" => {
464
+ impl: method(:tool_read),
465
+ schema: {
466
+ type: "function",
467
+ name: "read",
468
+ description: "Read file with line numbers (file path, not directory)",
469
+ parameters: {
470
+ type: "object",
471
+ properties: {
472
+ path: { type: "string", description: "Path to the file" },
473
+ offset: { type: "integer", description: "Line offset to start from" },
474
+ limit: { type: "integer", description: "Number of lines to read" }
475
+ },
476
+ required: ["path"]
477
+ }
478
+ }
479
+ },
480
+ "write" => {
481
+ impl: method(:tool_write),
482
+ schema: {
483
+ type: "function",
484
+ name: "write",
485
+ description: "Write content to file",
486
+ parameters: {
487
+ type: "object",
488
+ properties: {
489
+ path: { type: "string", description: "Path to the file" },
490
+ content: { type: "string", description: "Content to write" }
491
+ },
492
+ required: ["path", "content"]
493
+ }
494
+ }
495
+ },
496
+ "edit" => {
497
+ impl: method(:tool_edit),
498
+ schema: {
499
+ type: "function",
500
+ name: "edit",
501
+ description: "Replace old with new in file (old must be unique unless all=true)",
502
+ parameters: {
503
+ type: "object",
504
+ properties: {
505
+ path: { type: "string", description: "Path to the file" },
506
+ old: { type: "string", description: "String to find and replace" },
507
+ new: { type: "string", description: "Replacement string" },
508
+ all: { type: "boolean", description: "Replace all occurrences" }
509
+ },
510
+ required: ["path", "old", "new"]
511
+ }
512
+ }
513
+ },
514
+ "glob" => {
515
+ impl: method(:tool_glob),
516
+ schema: {
517
+ type: "function",
518
+ name: "glob",
519
+ description: "Find files by pattern, sorted by mtime",
520
+ parameters: {
521
+ type: "object",
522
+ properties: {
523
+ pattern: { type: "string", description: "Glob pattern (e.g., **/*.rb)" },
524
+ path: { type: "string", description: "Base path to search from" }
525
+ },
526
+ required: ["pattern"]
527
+ }
528
+ }
529
+ },
530
+ "grep" => {
531
+ impl: method(:tool_grep),
532
+ schema: {
533
+ type: "function",
534
+ name: "grep",
535
+ description: "Search file contents for regex pattern",
536
+ parameters: {
537
+ type: "object",
538
+ properties: {
539
+ pattern: { type: "string", description: "Regex pattern to search for" },
540
+ path: { type: "string", description: "Directory to search (default: current)" },
541
+ type: { type: "string", description: "File extension filter (e.g., 'rb', 'js')" },
542
+ limit: { type: "integer", description: "Max results (default: 30)" }
543
+ },
544
+ required: ["pattern"]
545
+ }
546
+ }
547
+ },
548
+ "bash" => {
549
+ impl: method(:tool_bash),
550
+ schema: {
551
+ type: "function",
552
+ name: "bash",
553
+ description: "Run shell command",
554
+ parameters: {
555
+ type: "object",
556
+ properties: {
557
+ cmd: { type: "string", description: "Command to execute" }
558
+ },
559
+ required: ["cmd"]
560
+ }
561
+ }
562
+ },
563
+ "tree" => {
564
+ impl: method(:tool_tree),
565
+ schema: {
566
+ type: "function",
567
+ name: "tree",
568
+ description: "Show directory structure (use to orient yourself)",
569
+ parameters: {
570
+ type: "object",
571
+ properties: {
572
+ path: { type: "string", description: "Directory to show (default: current)" },
573
+ depth: { type: "integer", description: "Max depth (default: 3)" }
574
+ },
575
+ required: []
576
+ }
577
+ }
578
+ },
579
+ "find" => {
580
+ impl: method(:tool_find),
581
+ schema: {
582
+ type: "function",
583
+ name: "find",
584
+ description: "Find files by name (fast, no content reading)",
585
+ parameters: {
586
+ type: "object",
587
+ properties: {
588
+ name: { type: "string", description: "Filename substring to search for" },
589
+ path: { type: "string", description: "Directory to search (default: current)" }
590
+ },
591
+ required: ["name"]
592
+ }
593
+ }
594
+ },
595
+ "explore" => {
596
+ impl: method(:tool_explore),
597
+ schema: {
598
+ type: "function",
599
+ name: "explore",
600
+ description: "Answer questions about the codebase (where is X, how does Y work). Searches and reads relevant files automatically.",
601
+ parameters: {
602
+ type: "object",
603
+ properties: {
604
+ question: { type: "string", description: "What you want to know about the codebase" },
605
+ scope: { type: "string", description: "Directory to focus on (optional)" }
606
+ },
607
+ required: ["question"]
608
+ }
609
+ }
610
+ },
611
+ "verify" => {
612
+ impl: method(:tool_verify),
613
+ schema: {
614
+ type: "function",
615
+ name: "verify",
616
+ description: "Run a command and check if it succeeds. Use after code changes to verify they work.",
617
+ parameters: {
618
+ type: "object",
619
+ properties: {
620
+ command: { type: "string", description: "Command to run" },
621
+ expect_pattern: { type: "string", description: "Regex that should match output on success" },
622
+ fail_pattern: { type: "string", description: "Regex indicating failure" },
623
+ retries: { type: "integer", description: "Number of retries (default: 0)" }
624
+ },
625
+ required: ["command"]
626
+ }
627
+ }
628
+ },
629
+ "patch" => {
630
+ impl: method(:tool_patch),
631
+ schema: {
632
+ type: "function",
633
+ name: "patch",
634
+ description: "Apply a unified diff to a file. More reliable than string replacement for multi-line changes.",
635
+ parameters: {
636
+ type: "object",
637
+ properties: {
638
+ path: { type: "string", description: "File to patch" },
639
+ diff: { type: "string", description: "Unified diff format (like git diff output)" }
640
+ },
641
+ required: ["path", "diff"]
642
+ }
643
+ }
644
+ }
645
+ }
646
+ end
647
+
648
+ def truncate_output(result, max = MAX_OUTPUT)
649
+ return result if result.length <= max
650
+ result[0, max] + "\n... (truncated, #{result.length - max} chars omitted)"
651
+ end
652
+
653
+ def run_tool(name, args)
654
+ tool = tools[name]
655
+ return "error: unknown tool '#{name}'" unless tool
656
+ result = tool[:impl].call(args)
657
+ truncate_output(result)
658
+ rescue => e
659
+ "error: #{e.message}"
660
+ end
661
+
662
+ def tool_schemas
663
+ tools.values.map do |t|
664
+ schema = t[:schema]
665
+ {
666
+ type: "function",
667
+ function: {
668
+ name: schema[:name],
669
+ description: schema[:description],
670
+ parameters: schema[:parameters]
671
+ }
672
+ }
673
+ end
674
+ end
675
+
676
+ def call_api(messages, model)
677
+ uri = URI(API_URL)
678
+ http = Net::HTTP.new(uri.host, uri.port)
679
+ http.use_ssl = true
680
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
681
+ http.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
682
+ http.read_timeout = 120
683
+
684
+ request = Net::HTTP::Post.new(uri)
685
+ request["Content-Type"] = "application/json"
686
+ api_key = ENV['OPENROUTER_API_KEY']
687
+ raise "OPENROUTER_API_KEY environment variable is required" unless api_key
688
+ request["Authorization"] = "Bearer #{api_key}"
689
+ request["HTTP-Referer"] = "https://github.com/ruboto"
690
+ request["X-Title"] = "Ruboto"
691
+
692
+ body = {
693
+ model: model,
694
+ messages: messages,
695
+ tools: tool_schemas
696
+ }
697
+
698
+ request.body = body.to_json
699
+
700
+ response = http.request(request)
701
+ unless response.is_a?(Net::HTTPSuccess)
702
+ puts "#{RED}Debug - Response body: #{response.body}#{RESET}"
703
+ return { "error" => { "message" => "HTTP #{response.code}: #{response.message}" } }
704
+ end
705
+ JSON.parse(response.body)
706
+ end
707
+
708
+ def get_file_tree(path = ".", depth = 3, prefix = "")
709
+ return "" if depth <= 0
710
+
711
+ entries = Dir.entries(path) - [".", ".."]
712
+ entries = entries.reject { |e| e.start_with?(".") || IGNORE_DIRS.include?(e) }
713
+ entries = entries.sort_by { |e| [File.directory?(File.join(path, e)) ? 0 : 1, e.downcase] }
714
+
715
+ lines = []
716
+ entries.each_with_index do |entry, idx|
717
+ full_path = File.join(path, entry)
718
+ is_last = idx == entries.length - 1
719
+ connector = is_last ? "└── " : "├── "
720
+
721
+ if File.directory?(full_path)
722
+ lines << "#{prefix}#{connector}#{entry}/"
723
+ extension = is_last ? " " : "│ "
724
+ lines << get_file_tree(full_path, depth - 1, prefix + extension)
725
+ else
726
+ lines << "#{prefix}#{connector}#{entry}"
727
+ end
728
+ end
729
+
730
+ lines.reject(&:empty?).join("\n")
731
+ end
732
+
733
+ def separator
734
+ width = [`tput cols`.to_i, 80].min
735
+ width = 80 if width <= 0
736
+ "#{DIM}#{'─' * width}#{RESET}"
737
+ end
738
+
739
+ def render_markdown(text)
740
+ text.gsub(/\*\*(.+?)\*\*/m, "#{BOLD}\\1#{RESET}")
741
+ end
742
+
743
+ # --- History persistence ---
744
+
745
+ def ensure_db_exists
746
+ Dir.mkdir(RUBOTO_DIR) unless Dir.exist?(RUBOTO_DIR)
747
+
748
+ schema = <<~SQL
749
+ CREATE TABLE IF NOT EXISTS messages (
750
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
751
+ role TEXT NOT NULL,
752
+ content TEXT NOT NULL,
753
+ session_id TEXT,
754
+ working_dir TEXT,
755
+ created_at TEXT DEFAULT (datetime('now'))
756
+ );
757
+ SQL
758
+
759
+ run_sql(schema)
760
+ end
761
+
762
+ def run_sql(sql)
763
+ output, _status = Open3.capture2('sqlite3', DB_PATH, sql)
764
+ output.strip
765
+ rescue => e
766
+ ""
767
+ end
768
+
769
+ def save_message(role, content, session_id = nil)
770
+ escaped_content = content.gsub('"', '""').gsub('$', '\$')
771
+ escaped_dir = Dir.pwd.gsub('"', '""')
772
+ session_part = session_id ? "'#{session_id}'" : "NULL"
773
+
774
+ sql = "INSERT INTO messages (role, content, session_id, working_dir) " \
775
+ "VALUES ('#{role}', \"#{escaped_content}\", #{session_part}, \"#{escaped_dir}\");"
776
+ run_sql(sql)
777
+ end
778
+
779
+ def load_readline_history
780
+ sql = "SELECT content FROM messages WHERE role='user' ORDER BY id DESC LIMIT #{MAX_HISTORY_LOAD};"
781
+ entries = run_sql(sql).split("\n").reverse
782
+ entries.each { |cmd| Readline::HISTORY << cmd }
783
+ rescue
784
+ # Ignore history load errors
785
+ end
786
+
787
+ def terminal_width
788
+ width = `tput cols 2>/dev/null`.to_i
789
+ width > 0 ? width : 80
790
+ end
791
+
792
+ def center_text(text, width)
793
+ padding = [(width - text.gsub(/\e\[[0-9;]*m/, '').length) / 2, 0].max
794
+ " " * padding + text
795
+ end
796
+
797
+ def print_startup
798
+ width = terminal_width
799
+ colors = [RED, YELLOW, GREEN, CYAN, BLUE, "\033[35m"]
800
+
801
+ # Clear screen and hide cursor
802
+ print "\033[2J\033[H\033[?25l"
803
+
804
+ # Animate logo reveal line by line with color wave
805
+ logo_lines = LOGO.lines.map(&:chomp)
806
+
807
+ logo_lines.each_with_index do |line, idx|
808
+ color = colors[idx % colors.length]
809
+ centered = center_text(line, width)
810
+ print "\r#{color}#{centered}#{RESET}"
811
+ puts
812
+ sleep 0.06
813
+ end
814
+
815
+ # Pause then show tagline with typewriter effect
816
+ sleep 0.3
817
+ tagline = TAGLINES.sample
818
+ centered_tag = center_text(tagline, width)
819
+
820
+ puts
821
+ print " " * [(width - tagline.length) / 2, 0].max
822
+ tagline.each_char do |c|
823
+ print "#{DIM}#{c}#{RESET}"
824
+ $stdout.flush
825
+ sleep 0.02
826
+ end
827
+ puts
828
+
829
+ # Show directory
830
+ sleep 0.2
831
+ info = File.basename(Dir.pwd).to_s
832
+ puts center_text("#{DIM}#{info}#{RESET}", width)
833
+
834
+ # Show cursor again
835
+ print "\033[?25h"
836
+
837
+ # Brief pause before prompt
838
+ sleep 0.3
839
+ puts
840
+ end
841
+
842
+ def select_model
843
+ width = terminal_width
844
+
845
+ puts center_text("#{CYAN}Select a model:#{RESET}", width)
846
+ puts
847
+
848
+ MODELS.each_with_index do |model, idx|
849
+ num = "#{BOLD}#{idx + 1}#{RESET}"
850
+ name = "#{CYAN}#{model[:name]}#{RESET}"
851
+ desc = "#{DIM}#{model[:desc]}#{RESET}"
852
+ puts " #{num}. #{name} #{desc}"
853
+ end
854
+
855
+ puts
856
+ print " #{DIM}Enter number (1-#{MODELS.length}):#{RESET} "
857
+
858
+ loop do
859
+ input = gets&.strip
860
+ return MODELS[0][:id] if input.nil? || input.empty?
861
+
862
+ num = input.to_i
863
+ if num >= 1 && num <= MODELS.length
864
+ selected = MODELS[num - 1]
865
+ puts "\n #{GREEN}✓#{RESET} Using #{BOLD}#{selected[:name]}#{RESET}"
866
+ puts
867
+ return selected[:id]
868
+ else
869
+ print " #{RED}Invalid choice.#{RESET} Enter 1-#{MODELS.length}: "
870
+ end
871
+ end
872
+ end
873
+
874
+ def print_help
875
+ puts <<~HELP
876
+ #{CYAN}Examples:#{RESET}
877
+ #{DIM}•#{RESET} "Find all TODO comments in this project"
878
+ #{DIM}•#{RESET} "Explain what the main function does"
879
+ #{DIM}•#{RESET} "Add error handling to tool_read"
880
+ #{DIM}•#{RESET} "Run the tests and fix any failures"
881
+
882
+ #{CYAN}Commands:#{RESET}
883
+ #{BOLD}/q#{RESET} #{DIM}quit#{RESET}
884
+ #{BOLD}/c#{RESET} #{DIM}clear conversation context#{RESET}
885
+ #{BOLD}/h#{RESET} #{DIM}show this help#{RESET}
886
+ #{BOLD}/history#{RESET} #{DIM}show recent commands#{RESET}
887
+ HELP
888
+ end
889
+
890
+ def run
891
+ ensure_db_exists
892
+ load_readline_history
893
+ print_startup
894
+
895
+ # Model selection
896
+ model = select_model
897
+
898
+ session_id = Time.now.strftime("%Y%m%d_%H%M%S")
899
+
900
+ system_prompt = <<~PROMPT
901
+ You are a fast, autonomous coding assistant. Working directory: #{Dir.pwd}
902
+
903
+ TOOL HIERARCHY - Use highest-level tool that fits:
904
+
905
+ 1. META-TOOLS (prefer these):
906
+ - explore: Answer "where is X?" / "how does Y work?" questions
907
+ - patch: Multi-line edits using unified diff format
908
+ - verify: Check if command succeeds (use after code changes)
909
+
910
+ 2. PRIMITIVES (when meta-tools don't fit):
911
+ - read/write/edit: Single, targeted file operations
912
+ - grep/glob/find: When you know exactly what to search for
913
+ - tree: See directory structure
914
+ - bash: Run shell commands (only real commands, not prose)
915
+
916
+ AUTONOMY RULES:
917
+ - ACT FIRST. Never ask "should I...?" or "would you like me to...?" - just do it
918
+ - After ANY code change → immediately use verify to check it works
919
+ - If verify fails → read the error, fix it, verify again
920
+ - Keep using tools until you have a complete answer
921
+ - Only ask questions when genuinely choosing between approaches
922
+
923
+ CRITICAL - BASH TOOL RULES:
924
+ - ONLY use bash for executable commands: git, npm, python, node, ls, etc.
925
+ - NEVER put prose, explanations, or markdown in bash
926
+ - To communicate with user, just respond with text - no tool needed
927
+ - NEVER put backticks in bash commands
928
+
929
+ EFFICIENCY:
930
+ - Use explore instead of multiple grep/read cycles
931
+ - Use patch for multi-line changes (more reliable than edit)
932
+ - Don't re-read files you just read
933
+
934
+ Be concise. Act, don't narrate.
935
+ PROMPT
936
+
937
+ # Initialize conversation with system message
938
+ messages = [{ role: "system", content: system_prompt }]
939
+
940
+ puts "#{DIM}Type your request, or /h for help#{RESET}"
941
+
942
+ loop do
943
+ begin
944
+ puts separator
945
+ user_input = Readline.readline("#{BOLD}#{BLUE}> #{RESET}", false)&.strip
946
+ puts separator
947
+
948
+ break if user_input.nil?
949
+ next if user_input.empty?
950
+ break if ["/q", "exit"].include?(user_input)
951
+
952
+ if user_input == "/c"
953
+ messages = [{ role: "system", content: system_prompt }]
954
+ puts "#{GREEN}⏺ Cleared conversation#{RESET}"
955
+ next
956
+ end
957
+
958
+ if user_input == "/h" || user_input == "/help"
959
+ print_help
960
+ next
961
+ end
962
+
963
+ if user_input == "/history"
964
+ sql = "SELECT content FROM messages WHERE role='user' ORDER BY id DESC LIMIT 10;"
965
+ entries = run_sql(sql).split("\n")
966
+ if entries.empty?
967
+ puts "#{DIM}No history yet.#{RESET}"
968
+ else
969
+ puts "#{DIM}Recent commands:#{RESET}"
970
+ entries.each_with_index { |cmd, i| puts " #{DIM}#{i + 1}.#{RESET} #{cmd[0, 60]}" }
971
+ end
972
+ next
973
+ end
974
+
975
+ # Save to history
976
+ Readline::HISTORY << user_input
977
+ save_message("user", user_input, session_id)
978
+
979
+ # Add user message to conversation
980
+ messages << { role: "user", content: user_input }
981
+
982
+ # Agentic loop
983
+ loop do
984
+ response = with_spinner("Thinking...") do
985
+ call_api(messages, model)
986
+ end
987
+
988
+ if response["error"]
989
+ puts "#{RED}⏺ API Error: #{response["error"]["message"]}#{RESET}"
990
+ break
991
+ end
992
+
993
+ # Parse Chat Completions response
994
+ choice = response.dig("choices", 0)
995
+ unless choice
996
+ puts "#{RED}⏺ Error: No response from model#{RESET}"
997
+ break
998
+ end
999
+
1000
+ message = choice["message"]
1001
+ text_content = message["content"]
1002
+ tool_calls = message["tool_calls"] || []
1003
+
1004
+ # Add assistant message to conversation
1005
+ messages << message
1006
+
1007
+ if text_content && !text_content.empty?
1008
+ puts "\n#{CYAN}⏺#{RESET} #{render_markdown(text_content)}"
1009
+ save_message("assistant", text_content, session_id)
1010
+ end
1011
+
1012
+ break if tool_calls.empty?
1013
+
1014
+ # Execute tool calls and add results to messages
1015
+ tool_calls.each do |tc|
1016
+ tool_name = tc.dig("function", "name")
1017
+ tool_args = JSON.parse(tc.dig("function", "arguments") || "{}")
1018
+ call_id = tc["id"]
1019
+
1020
+ label = tool_message(tool_name, tool_args)
1021
+
1022
+ print "\n"
1023
+ result = with_spinner(label) do
1024
+ run_tool(tool_name, tool_args)
1025
+ end
1026
+
1027
+ result_lines = result.split("\n")
1028
+ preview = result_lines.first.to_s[0, 60]
1029
+ if result_lines.length > 1
1030
+ preview += " #{DIM}(+#{result_lines.length - 1} more lines)#{RESET}"
1031
+ elsif result_lines.first.to_s.length > 60
1032
+ preview += "..."
1033
+ end
1034
+ puts " #{DIM}⎿ #{preview}#{RESET}"
1035
+
1036
+ # Add tool result to conversation
1037
+ messages << {
1038
+ role: "tool",
1039
+ tool_call_id: call_id,
1040
+ content: result
1041
+ }
1042
+ end
1043
+ end
1044
+
1045
+ puts
1046
+
1047
+ rescue Interrupt
1048
+ break
1049
+ rescue => e
1050
+ puts "#{RED}⏺ Error: #{e.message}#{RESET}"
1051
+ puts e.backtrace.first(3).join("\n") if ENV["DEBUG"]
1052
+ end
1053
+ end
1054
+ end
1055
+ end
1056
+ end
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruboto-ai
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Akhil Gautam
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A fast, autonomous coding assistant built in Ruby, powered by multiple
13
+ LLM providers via OpenRouter API. Features agentic tools for file manipulation,
14
+ command execution, and codebase exploration.
15
+ email: []
16
+ executables:
17
+ - ruboto-ai
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE.txt
22
+ - README.md
23
+ - bin/ruboto-ai
24
+ - lib/ruboto.rb
25
+ - lib/ruboto/version.rb
26
+ homepage: https://github.com/akhilgautam/ruboto-ai
27
+ licenses:
28
+ - MIT
29
+ metadata:
30
+ homepage_uri: https://github.com/akhilgautam/ruboto-ai
31
+ source_code_uri: https://github.com/akhilgautam/ruboto-ai
32
+ changelog_uri: https://github.com/akhilgautam/ruboto-ai/blob/main/CHANGELOG.md
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 3.0.0
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 3.6.9
48
+ specification_version: 4
49
+ summary: Minimal agentic coding assistant for the terminal
50
+ test_files: []