genie_cli 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 +7 -0
- data/LICENSE +21 -0
- data/README.md +107 -0
- data/Rakefile +8 -0
- data/bin/genie +8 -0
- data/lib/genie/cli.rb +20 -0
- data/lib/genie/sandboxed_file_tool.rb +22 -0
- data/lib/genie/session.rb +83 -0
- data/lib/genie/session_config.rb +110 -0
- data/lib/genie/version.rb +3 -0
- data/lib/genie.rb +69 -0
- data/lib/tools/append_to_file.rb +33 -0
- data/lib/tools/ask_for_help.rb +14 -0
- data/lib/tools/insert_into_file.rb +58 -0
- data/lib/tools/list_files.rb +61 -0
- data/lib/tools/read_file.rb +34 -0
- data/lib/tools/rename_file.rb +45 -0
- data/lib/tools/replace_lines_in_file.rb +58 -0
- data/lib/tools/run_tests.rb +38 -0
- data/lib/tools/take_a_note.rb +14 -0
- data/lib/tools/write_file.rb +38 -0
- data/test/data/sample_config.yml +5 -0
- data/test/data/sample_files/a_dir/two.txt +0 -0
- data/test/data/sample_files/one.txt +0 -0
- data/test/data/sample_files/read_file_test.txt +3 -0
- data/test/test_cli.rb +11 -0
- data/test/test_helper.rb +5 -0
- data/test/test_session.rb +13 -0
- data/test/test_session_config.rb +55 -0
- data/test/tools/test_append_to_file.rb +29 -0
- data/test/tools/test_insert_into_file.rb +97 -0
- data/test/tools/test_list_files.rb +31 -0
- data/test/tools/test_read_file.rb +26 -0
- data/test/tools/test_rename_file.rb +76 -0
- data/test/tools/test_replace_lines_in_file.rb +141 -0
- data/test/tools/test_run_tests.rb +9 -0
- data/test/tools/test_take_a_note.rb +11 -0
- data/test/tools/test_write_file.rb +17 -0
- metadata +179 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 4582658de3ff1e9145b81a2975b973f7aebbc1762f8d7a21bb6237c211225558
|
4
|
+
data.tar.gz: 79b2870a69918c5c7eba6a72ca1a8d5e5284392304c39833cf61f74e19686eae
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: eaf263a55c387b7e021afbc3dd161d47e38307ccf09a6ec5bfd952237f739bb74720fe23efcfd5873030d7bcc1af11540198c760f99438254562aabb39002f3f
|
7
|
+
data.tar.gz: 5e1cac00335cc946e3bbcbe04f2b6017861033186810523ac33e40ab56f763d28ba73d6b1cedbdaca755a3737aeb0048140d789ab8e43fac34ec243cab8093dc
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Jeff McFadden
|
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 all
|
13
|
+
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 THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
# Genie CLI
|
2
|
+
|
3
|
+
Genie CLI is a command-line tool that brings Test Driven Development (TDD)
|
4
|
+
principles to life by integrating with a large language model (LLM) through
|
5
|
+
the `ruby_llm` library. It provides an interactive session where you can ask
|
6
|
+
the AI assistant to write tests, implement code, and manage your codebase—all
|
7
|
+
while enforcing a strict TDD workflow.
|
8
|
+
|
9
|
+
## Features
|
10
|
+
|
11
|
+
- Interactive REPL-style session powered by OpenAI (or any provider supported by `ruby_llm`).
|
12
|
+
- Built-in tools for common file operations:
|
13
|
+
- `ListFiles`: List and filter files in your project directory.
|
14
|
+
- `ReadFile`: Read the contents of files.
|
15
|
+
- `WriteFile`: Create or overwrite files.
|
16
|
+
- `InsertIntoFile`: Insert content at a specific marker in a file.
|
17
|
+
- `AppendToFile`: Append content to existing files.
|
18
|
+
- `RunTests`: Run your test suite and capture results.
|
19
|
+
- `TakeANote`: Write notes without affecting your source files.
|
20
|
+
- `AskForHelp`: Request guidance or explanations from the AI.
|
21
|
+
- Enforces Genie workflow: tests first, implementation second.
|
22
|
+
- Restricts file operations to your project directory for safety.
|
23
|
+
|
24
|
+
## Installation
|
25
|
+
|
26
|
+
1. Clone this repository:
|
27
|
+
```bash
|
28
|
+
git clone https://github.com/jeffmcfadden/genie_cli.git
|
29
|
+
cd genie_cli
|
30
|
+
```
|
31
|
+
|
32
|
+
2. Install dependencies using Bundler:
|
33
|
+
```bash
|
34
|
+
bundle install
|
35
|
+
```
|
36
|
+
|
37
|
+
3. Set your OpenAI API key (or other LLM provider keys) in your environment:
|
38
|
+
```bash
|
39
|
+
export OPENAI_API_KEY="your_api_key_here"
|
40
|
+
```
|
41
|
+
|
42
|
+
4. Make the CLI executable:
|
43
|
+
```bash
|
44
|
+
chmod +x bin/genie
|
45
|
+
```
|
46
|
+
|
47
|
+
## Usage
|
48
|
+
|
49
|
+
Start a Genie session by running the `genie` command from the root of your project:
|
50
|
+
|
51
|
+
```bash
|
52
|
+
./bin/genie ["initial prompt or command"]
|
53
|
+
```
|
54
|
+
|
55
|
+
- If you provide an initial prompt, the assistant will immediately respond. Otherwise, you'll enter an interactive prompt where you can type your questions or commands.
|
56
|
+
- To quit the session, type `q`, `quit`, `done`, or `exit`.
|
57
|
+
|
58
|
+
Example session:
|
59
|
+
|
60
|
+
```bash
|
61
|
+
$ ./bin/genie
|
62
|
+
Starting a new session with:
|
63
|
+
base_path: /Users/you/projects/genie_cli
|
64
|
+
|
65
|
+
> "Create a failing test for a Calculator#add method"
|
66
|
+
|
67
|
+
# (AI writes a test file)
|
68
|
+
|
69
|
+
> "Implement Calculator#add to pass the test"
|
70
|
+
|
71
|
+
# (AI writes the implementation)
|
72
|
+
|
73
|
+
> "Run the test suite"
|
74
|
+
|
75
|
+
# (AI invokes `rake test` and reports results)
|
76
|
+
|
77
|
+
> "exit"
|
78
|
+
Exiting...
|
79
|
+
Total Conversation Tokens: 1234
|
80
|
+
```
|
81
|
+
|
82
|
+
## Logging
|
83
|
+
|
84
|
+
The output of `genie` to the terminal includes "essential" output, but not
|
85
|
+
_all_ output. To aid in debugging, the full RubyLLM debug log is saved to
|
86
|
+
`ruby_llm.log`. This can be useful for auditing what's happened during a
|
87
|
+
session in great detail.
|
88
|
+
|
89
|
+
## Configuration
|
90
|
+
|
91
|
+
Configuration is available via a `genie.yml` file in the project root.
|
92
|
+
|
93
|
+
## Testing
|
94
|
+
|
95
|
+
This project includes a comprehensive test suite. Run all tests with:
|
96
|
+
|
97
|
+
```bash
|
98
|
+
bundle exec rake test
|
99
|
+
```
|
100
|
+
|
101
|
+
## Contributing
|
102
|
+
|
103
|
+
Contributions are welcome! Please fork the repository and open pull requests for new features or bug fixes. Make sure to follow the Genie workflow and include tests for new functionality.
|
104
|
+
|
105
|
+
## License
|
106
|
+
|
107
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
data/Rakefile
ADDED
data/bin/genie
ADDED
data/lib/genie/cli.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
module Genie
|
4
|
+
|
5
|
+
class Cli
|
6
|
+
def initialize(config:)
|
7
|
+
@config = config
|
8
|
+
end
|
9
|
+
|
10
|
+
def run
|
11
|
+
session = Session.new(config: @config)
|
12
|
+
|
13
|
+
# Trap CTRL+C to exit gracefully
|
14
|
+
Signal.trap("INT") { session.complete }
|
15
|
+
|
16
|
+
# Begin the session with the first CLI argument (or nil)
|
17
|
+
session.begin(@args&.last)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Genie
|
2
|
+
module SandboxedFileTool
|
3
|
+
|
4
|
+
|
5
|
+
def initialize(base_path:)
|
6
|
+
@base_path = base_path
|
7
|
+
@base_path.freeze
|
8
|
+
end
|
9
|
+
|
10
|
+
def within_sandbox?(filepath)
|
11
|
+
filepath = File.expand_path(filepath)
|
12
|
+
filepath.start_with?(@base_path)
|
13
|
+
end
|
14
|
+
|
15
|
+
def enforce_sandbox!(filepath)
|
16
|
+
unless within_sandbox?(filepath)
|
17
|
+
raise ArgumentError, "File not allowed: #{filepath}. Must be within base path: #{@base_path}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module Genie
|
2
|
+
class Session
|
3
|
+
extend Forwardable
|
4
|
+
|
5
|
+
attr_reader :config
|
6
|
+
|
7
|
+
# Supported commands to exit the session
|
8
|
+
QUIT_COMMANDS = ["q", "quit", "done", "exit", "bye"].freeze
|
9
|
+
|
10
|
+
# Config holds these for us
|
11
|
+
def_delegators :@config, :model, :base_path, :run_tests_cmd, :instructions
|
12
|
+
|
13
|
+
# Initializes the session with a pre-loaded configuration
|
14
|
+
# config: instance of Genie::SessionConfig
|
15
|
+
def initialize(config:)
|
16
|
+
@config = config
|
17
|
+
|
18
|
+
Genie.output "Starting a new session with:\n base_path: #{base_path}\n model: #{model}\n", color: :green
|
19
|
+
|
20
|
+
# Initialize the LLM chat with the specified model
|
21
|
+
@chat = RubyLLM.chat(model: model)
|
22
|
+
|
23
|
+
# Use Genie-specific instructions from config
|
24
|
+
@chat.with_instructions instructions
|
25
|
+
|
26
|
+
# Provide file tools for the assistant, scoped to base_path
|
27
|
+
@chat.with_tools(
|
28
|
+
Genie::AppendToFile.new(base_path: base_path),
|
29
|
+
Genie::AskForHelp.new,
|
30
|
+
# Genie::InsertIntoFile.new(base_path: base_path),
|
31
|
+
Genie::ListFiles.new(base_path: base_path),
|
32
|
+
Genie::ReadFile.new(base_path: base_path),
|
33
|
+
Genie::RenameFile.new(base_path: base_path),
|
34
|
+
# Genie::ReplaceLinesInFile.new(base_path: base_path),
|
35
|
+
Genie::RunTests.new(base_path: base_path, cmd: run_tests_cmd),
|
36
|
+
Genie::TakeANote.new,
|
37
|
+
Genie::WriteFile.new(base_path: base_path),
|
38
|
+
)
|
39
|
+
end
|
40
|
+
|
41
|
+
def begin(q)
|
42
|
+
q = q.to_s
|
43
|
+
|
44
|
+
loop do
|
45
|
+
complete if QUIT_COMMANDS.include? q.downcase
|
46
|
+
|
47
|
+
begin
|
48
|
+
ask q unless q.strip == ""
|
49
|
+
rescue RubyLLM::RateLimitError => e
|
50
|
+
Genie.output "Rate limit exceeded: #{e.message}", color: :red
|
51
|
+
end
|
52
|
+
|
53
|
+
q = prompt
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Send a question to the LLM and output both prompt and response
|
58
|
+
def ask(question)
|
59
|
+
Genie.output "#{question}\n", color: :white
|
60
|
+
|
61
|
+
response = @chat.ask(question)
|
62
|
+
|
63
|
+
Genie.output "\n#{response.content}", color: :white
|
64
|
+
|
65
|
+
response
|
66
|
+
end
|
67
|
+
|
68
|
+
def prompt
|
69
|
+
print "\n > "
|
70
|
+
STDIN.gets.chomp
|
71
|
+
end
|
72
|
+
|
73
|
+
def complete
|
74
|
+
Genie.output "\nExiting...", color: :white
|
75
|
+
|
76
|
+
total_conversation_tokens = @chat.messages.sum { |msg| (msg.input_tokens || 0) + (msg.output_tokens || 0) }
|
77
|
+
Genie.output "Total Conversation Tokens: #{total_conversation_tokens}", color: :white
|
78
|
+
|
79
|
+
exit
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Genie
|
4
|
+
# Handles loading of session configuration such as run_tests_cmd
|
5
|
+
class SessionConfig
|
6
|
+
# Read-only attributes
|
7
|
+
attr_reader :base_path, :run_tests_cmd, :model, :first_question, :instructions
|
8
|
+
|
9
|
+
DEFAULT_INSTRUCTIONS = <<~INSTRUCTIONS
|
10
|
+
# Genie Instructions
|
11
|
+
You are a Genie coding assistant. You help me write code using Test Driven Development
|
12
|
+
(Genie) principles. You have some tools available to you, such as listing files, reading files, and writing files,
|
13
|
+
and you can write code in Ruby. You will always write tests first, and then implement
|
14
|
+
the code to pass those tests. You will not write any code that does not have a test.
|
15
|
+
|
16
|
+
# Rules
|
17
|
+
1. We do not have access to any files outside of the base_path.
|
18
|
+
2. We do not have access to the internet.
|
19
|
+
3. You will always write tests first, and then implement the code to pass those tests.
|
20
|
+
INSTRUCTIONS
|
21
|
+
|
22
|
+
DEFAULTS = {
|
23
|
+
base_path: ".",
|
24
|
+
run_tests_cmd: 'rake test',
|
25
|
+
model: 'gpt-4o-mini',
|
26
|
+
first_question: nil,
|
27
|
+
instructions: DEFAULT_INSTRUCTIONS
|
28
|
+
}
|
29
|
+
|
30
|
+
def self.from_argv(argv)
|
31
|
+
cli_options = {}
|
32
|
+
config_file = File.expand_path "./genie.yml"
|
33
|
+
|
34
|
+
OptionParser.new do |opts|
|
35
|
+
opts.banner = "Usage: genie [options]"
|
36
|
+
|
37
|
+
opts.on("-c", "--config FILE", "Path to config file") do |file|
|
38
|
+
config_file = File.expand_path(file)
|
39
|
+
end
|
40
|
+
|
41
|
+
opts.on("-b", "--base-path PATH", "Base path for the session") do |path|
|
42
|
+
cli_options[:base_path] = path
|
43
|
+
end
|
44
|
+
|
45
|
+
opts.on("-r", "--run-tests CMD", "Command to run tests") do |cmd|
|
46
|
+
cli_options[:run_tests_cmd] = cmd
|
47
|
+
end
|
48
|
+
|
49
|
+
opts.on("-m", "--model NAME", "Name of model to use") do |name|
|
50
|
+
cli_options[:model] = name
|
51
|
+
end
|
52
|
+
|
53
|
+
opts.on("-i", "--instructions TEXT", "Instructions for the session") do |text|
|
54
|
+
cli_options[:instructions] = text
|
55
|
+
end
|
56
|
+
|
57
|
+
opts.on("-v", "--[no-]verbose", "Enable verbose mode") do |v|
|
58
|
+
cli_options['verbose'] = v
|
59
|
+
end
|
60
|
+
end.parse!(argv)
|
61
|
+
|
62
|
+
# Last remaining argument is the first question
|
63
|
+
cli_options[:first_question] = argv.last if argv.any?
|
64
|
+
|
65
|
+
file_config = {}
|
66
|
+
if config_file && File.exist?(config_file)
|
67
|
+
file_config = YAML.load_file(config_file, symbolize_names: true)
|
68
|
+
end
|
69
|
+
|
70
|
+
# 3️⃣ Merge: DEFAULT < FILE < CLI
|
71
|
+
final_config = DEFAULTS.merge(file_config).merge(cli_options)
|
72
|
+
|
73
|
+
# We always preface the instructions with context
|
74
|
+
final_config[:instructions] = <<~PREFACE
|
75
|
+
# Context
|
76
|
+
Current Date and Time: #{Time.now.iso8601}
|
77
|
+
We are working in a codebase located at '#{final_config[:base_path]}'.
|
78
|
+
|
79
|
+
#{final_config[:instructions]}
|
80
|
+
PREFACE
|
81
|
+
|
82
|
+
new(
|
83
|
+
base_path: final_config[:base_path],
|
84
|
+
run_tests_cmd: final_config[:run_tests_cmd],
|
85
|
+
model: final_config[:model],
|
86
|
+
first_question: final_config[:first_question],
|
87
|
+
instructions: final_config[:instructions]
|
88
|
+
)
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.default
|
92
|
+
new(
|
93
|
+
base_path: DEFAULTS[:base_path],
|
94
|
+
run_tests_cmd: DEFAULTS[:run_tests_cmd],
|
95
|
+
model: DEFAULTS[:model],
|
96
|
+
first_question: DEFAULTS[:first_question],
|
97
|
+
instructions: DEFAULTS[:instructions]
|
98
|
+
)
|
99
|
+
end
|
100
|
+
|
101
|
+
def initialize(base_path:, run_tests_cmd:, model:, first_question:, instructions:) # Requires both base_path and run_tests_cmd
|
102
|
+
@base_path = File.expand_path(base_path)
|
103
|
+
@run_tests_cmd = run_tests_cmd
|
104
|
+
@model = model
|
105
|
+
@first_question = first_question
|
106
|
+
@instructions = instructions
|
107
|
+
end
|
108
|
+
|
109
|
+
end
|
110
|
+
end
|
data/lib/genie.rb
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
require "ruby_llm"
|
2
|
+
require "dotenv/load"
|
3
|
+
require "tty-command"
|
4
|
+
|
5
|
+
$LOAD_PATH.unshift (File.expand_path('../lib', __dir__))
|
6
|
+
|
7
|
+
require "genie/sandboxed_file_tool"
|
8
|
+
require "genie/version"
|
9
|
+
require "genie/session_config"
|
10
|
+
require "genie/session"
|
11
|
+
require "tools/append_to_file"
|
12
|
+
require "tools/list_files"
|
13
|
+
require "tools/read_file"
|
14
|
+
require "tools/write_file"
|
15
|
+
require "tools/replace_lines_in_file"
|
16
|
+
require "tools/rename_file"
|
17
|
+
require "tools/run_tests"
|
18
|
+
require "tools/take_a_note"
|
19
|
+
require "tools/insert_into_file"
|
20
|
+
require "tools/ask_for_help"
|
21
|
+
require "genie/cli"
|
22
|
+
|
23
|
+
RubyLLM.configure do |config|
|
24
|
+
# Set keys for the providers you need. Using environment variables is best practice.
|
25
|
+
config.openai_api_key = ENV.fetch('OPENAI_API_KEY', nil)
|
26
|
+
# Add other keys like config.anthropic_api_key if needed
|
27
|
+
|
28
|
+
config.gemini_api_key = ENV.fetch('GEMINI_API_KEY', nil) # Set your Gemini API key
|
29
|
+
|
30
|
+
config.log_file = 'ruby_llm.log' # Log file path
|
31
|
+
config.log_level = :debug # Log level (:debug, :info, :warn)
|
32
|
+
end
|
33
|
+
|
34
|
+
module Genie
|
35
|
+
def self.output(s, color: :white)
|
36
|
+
return if quiet?
|
37
|
+
|
38
|
+
# This method is used to output messages in a consistent format.
|
39
|
+
# You can customize the color or format as needed.
|
40
|
+
puts "\e[32m#{s}\e[0m" if color == :green
|
41
|
+
puts "\e[31m#{s}\e[0m" if color == :red
|
42
|
+
puts "\e[33m#{s}\e[0m" if color == :yellow
|
43
|
+
puts "\e[34m#{s}\e[0m" if color == :blue
|
44
|
+
puts "\e[35m#{s}\e[0m" if color == :magenta
|
45
|
+
puts "\e[36m#{s}\e[0m" if color == :cyan
|
46
|
+
|
47
|
+
puts "\e[37m#{s}\e[0m" if color == :white
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.quiet=(value)
|
51
|
+
# This method can be used to set a quiet mode for the output.
|
52
|
+
# If true, it suppresses the output.
|
53
|
+
@quiet = value
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.quiet?
|
57
|
+
@quiet || false
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.reset_quiet!
|
61
|
+
@quiet = false
|
62
|
+
end
|
63
|
+
|
64
|
+
def self.quiet!
|
65
|
+
# This method can be used to enable quiet mode.
|
66
|
+
@quiet = true
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require "fileutils"
|
2
|
+
|
3
|
+
module Genie
|
4
|
+
class AppendToFile < RubyLLM::Tool
|
5
|
+
include SandboxedFileTool
|
6
|
+
|
7
|
+
description "Append a string to an existing file."
|
8
|
+
param :filepath, desc: "The path to the file to append to (e.g., '/home/user/documents/file.txt'). File must already exist."
|
9
|
+
param :content, desc: "The content to append to the file"
|
10
|
+
|
11
|
+
def execute(filepath:, content:)
|
12
|
+
filepath = File.expand_path(filepath)
|
13
|
+
|
14
|
+
Genie.output "Appending to file: #{filepath}", color: :blue
|
15
|
+
|
16
|
+
enforce_sandbox!(filepath)
|
17
|
+
|
18
|
+
indented = content.each_line.map { |line| " #{line}" }.join
|
19
|
+
Genie.output indented, color: :green
|
20
|
+
|
21
|
+
raise "File not found. Cannot append to a non-existent file." unless File.exist?(filepath)
|
22
|
+
|
23
|
+
File.open(filepath, "a") do |file|
|
24
|
+
file.write(content)
|
25
|
+
end
|
26
|
+
|
27
|
+
{ success: true }
|
28
|
+
rescue => e
|
29
|
+
Genie.output "Error: #{e.message}", color: :red
|
30
|
+
{ error: e.message }
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Genie
|
2
|
+
class AskForHelp < RubyLLM::Tool
|
3
|
+
description "Ask for help. If you can't seem to get something to work, or you get repeated errors, use this tool to ask the user to intervene. Use only when absolutely necessary."
|
4
|
+
param :message, desc: "The message you want the user to see. Include any helpful details!"
|
5
|
+
|
6
|
+
def execute(message:)
|
7
|
+
Genie.output "Help! \n#{message}\n", color: :red
|
8
|
+
|
9
|
+
{
|
10
|
+
status: "User has been asked for help. We should wait until we get input from them.",
|
11
|
+
}
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require "fileutils"
|
2
|
+
|
3
|
+
module Genie
|
4
|
+
class InsertIntoFile < RubyLLM::Tool
|
5
|
+
include SandboxedFileTool
|
6
|
+
|
7
|
+
description "Insert a string into an existing file at a specified line number."
|
8
|
+
param :filepath, desc: "The path to the file to insert into (e.g., '/home/user/documents/file.txt'). File must already exist."
|
9
|
+
param :content, desc: "The content to insert into the file"
|
10
|
+
param :line_number, desc: "The 1-based line number at which to insert the content"
|
11
|
+
|
12
|
+
def execute(filepath:, content:, line_number:)
|
13
|
+
line_number = line_number.to_i
|
14
|
+
|
15
|
+
# Expand the filepath to an absolute path
|
16
|
+
filepath = File.expand_path(filepath)
|
17
|
+
|
18
|
+
Genie.output "Inserting into file: #{filepath}", color: :blue
|
19
|
+
|
20
|
+
enforce_sandbox!(filepath)
|
21
|
+
|
22
|
+
# Check file exists
|
23
|
+
unless File.exist?(filepath)
|
24
|
+
raise "File not found. Cannot insert into a non-existent file."
|
25
|
+
end
|
26
|
+
|
27
|
+
# Read existing lines
|
28
|
+
lines = File.readlines(filepath)
|
29
|
+
|
30
|
+
# Validate line_number
|
31
|
+
total_lines = lines.size
|
32
|
+
if !line_number.is_a?(Integer) || line_number < 1 || line_number > total_lines + 1
|
33
|
+
raise ArgumentError, "Invalid line number: #{line_number}. Must be between 1 and #{total_lines + 1}."
|
34
|
+
end
|
35
|
+
|
36
|
+
# Prepare content lines
|
37
|
+
content_lines = content.each_line.to_a
|
38
|
+
|
39
|
+
# Show the content to be inserted
|
40
|
+
indented = content.each_line.map { |line| " #{line}" }.join
|
41
|
+
Genie.output indented, color: :green
|
42
|
+
|
43
|
+
# Perform insertion
|
44
|
+
index = line_number - 1
|
45
|
+
new_lines = lines[0...index] + content_lines + lines[index..-1]
|
46
|
+
|
47
|
+
# Write back
|
48
|
+
File.open(filepath, "w") do |file|
|
49
|
+
file.write(new_lines.join)
|
50
|
+
end
|
51
|
+
|
52
|
+
{ success: true }
|
53
|
+
rescue => e
|
54
|
+
Genie.output "Error: #{e.message}", color: :red
|
55
|
+
{ error: e.message }
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Genie
|
2
|
+
|
3
|
+
class ListFiles < RubyLLM::Tool
|
4
|
+
include SandboxedFileTool
|
5
|
+
|
6
|
+
description "Lists the files in the given directory"
|
7
|
+
param :directory, desc: "Directory path to list files from (e.g., '/home/user/documents')"
|
8
|
+
param :recursive, desc: "Whether to list files recursively (default: false)"
|
9
|
+
param :filter, desc: "Filter string to include only paths that include this substring (Optional)"
|
10
|
+
|
11
|
+
def execute(directory:, recursive: false, filter: nil)
|
12
|
+
directory = File.expand_path(directory, @base_path)
|
13
|
+
|
14
|
+
Genie.output "Listing files in directory: #{directory} (recursive: #{recursive})", color: :blue
|
15
|
+
|
16
|
+
raise ArgumentError, "Directory not allowed: #{directory}. Must be within base path: #{@base_path}" unless directory.start_with?(@base_path)
|
17
|
+
|
18
|
+
listing = recursive ? list_recursive(directory) : list_non_recursive(directory)
|
19
|
+
|
20
|
+
# Apply filter if provided
|
21
|
+
if filter && !filter.empty?
|
22
|
+
listing = listing.select { |entry| entry[:name].include?(filter) }
|
23
|
+
end
|
24
|
+
|
25
|
+
Genie.output listing.map { |e| e[:name] }.join("\n") + "\n", color: :green
|
26
|
+
|
27
|
+
listing
|
28
|
+
rescue => e
|
29
|
+
Genie.output "Error: #{e.message}", color: :red
|
30
|
+
{ error: e.message }
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def list_recursive(directory)
|
36
|
+
Dir.glob(File.join(directory, '**', '*')).map do |file_path|
|
37
|
+
# next if File.basename(file_path).start_with?('.') # Skip hidden files
|
38
|
+
|
39
|
+
{
|
40
|
+
name: file_path,
|
41
|
+
type: File.file?(file_path) ? 'file' : 'directory',
|
42
|
+
}
|
43
|
+
end.compact
|
44
|
+
end
|
45
|
+
|
46
|
+
def list_non_recursive(directory)
|
47
|
+
Dir.each_child(directory).map do |filename|
|
48
|
+
next if filename.start_with?('.') # Skip hidden files
|
49
|
+
|
50
|
+
file_path = File.join(directory, filename)
|
51
|
+
|
52
|
+
{
|
53
|
+
name: filename,
|
54
|
+
type: File.file?(file_path) ? 'file' : 'directory',
|
55
|
+
}
|
56
|
+
end.compact # Remove any nil entries (e.g., hidden files or directories)
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Genie
|
2
|
+
class ReadFile < RubyLLM::Tool
|
3
|
+
include SandboxedFileTool
|
4
|
+
|
5
|
+
description "Reads the contents of a file and returns its content"
|
6
|
+
param :filepath, desc: "The path to the file to read (e.g., '/home/user/documents/file.txt')"
|
7
|
+
param :include_line_numbers, desc: "Whether to include line numbers (default: false)"
|
8
|
+
|
9
|
+
def execute(filepath:, include_line_numbers: false)
|
10
|
+
filepath = File.expand_path(filepath)
|
11
|
+
|
12
|
+
Genie.output "Reading file: #{filepath}", color: :blue
|
13
|
+
|
14
|
+
enforce_sandbox!(filepath)
|
15
|
+
|
16
|
+
lines = File.readlines(filepath)
|
17
|
+
contents = if include_line_numbers
|
18
|
+
width = lines.size.to_s.length
|
19
|
+
lines.each_with_index.map do |line, index|
|
20
|
+
number = (index + 1).to_s.rjust(width)
|
21
|
+
"#{number}: #{line}"
|
22
|
+
end.join
|
23
|
+
else
|
24
|
+
lines.join
|
25
|
+
end
|
26
|
+
|
27
|
+
{ contents: contents }
|
28
|
+
rescue => e
|
29
|
+
Genie.output "Error: #{e.message}", color: :red
|
30
|
+
|
31
|
+
{ error: e.message }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|