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.
@@ -0,0 +1,45 @@
1
+ require "fileutils"
2
+ require "ruby_llm"
3
+
4
+ module Genie
5
+ class RenameFile < RubyLLM::Tool
6
+ include SandboxedFileTool
7
+
8
+ description "Rename a file within the base path to a new location within the base path."
9
+ param :filepath, desc: "The path to the source file to rename."
10
+ param :new_path, desc: "The new path for the file."
11
+
12
+ def execute(filepath:, new_path:)
13
+ # Expand to absolute paths
14
+ src = File.expand_path(filepath)
15
+ dst = File.expand_path(new_path)
16
+
17
+ Genie.output "Renaming file from: #{src} to #{dst}", color: :blue
18
+
19
+ enforce_sandbox!(src)
20
+ enforce_sandbox!(dst)
21
+
22
+ # Check source exists
23
+ unless File.exist?(src)
24
+ raise "File not found. Cannot rename a non-existent file."
25
+ end
26
+
27
+ # Check destination does not exist
28
+ if File.exist?(dst)
29
+ raise "Destination already exists: #{dst}."
30
+ end
31
+
32
+ # Ensure destination directory exists
33
+ dest_dir = File.dirname(dst)
34
+ FileUtils.mkdir_p(dest_dir) unless Dir.exist?(dest_dir)
35
+
36
+ # Perform rename
37
+ FileUtils.mv(src, dst)
38
+
39
+ { success: true }
40
+ rescue => e
41
+ Genie.output "Error: #{e.message}", color: :red
42
+ { error: e.message }
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,58 @@
1
+ require "ruby_llm"
2
+
3
+ module Genie
4
+ class ReplaceLinesInFile < RubyLLM::Tool
5
+ include SandboxedFileTool
6
+
7
+ description "Replace lines in a file between start and end indices (inclusive) with new content."
8
+ param :filepath, desc: "The path to the file to modify (must exist within base path)."
9
+ param :start_line, desc: "Zero-based starting line index to replace."
10
+ param :end_line, desc: "Zero-based ending line index to replace."
11
+ param :content, desc: "The new content to insert in place of the removed lines."
12
+
13
+ def execute(filepath:, start_line:, end_line:, content:)
14
+ start_line = start_line.to_i
15
+ end_line = end_line.to_i
16
+
17
+ # Expand to absolute path
18
+ filepath = File.expand_path(filepath)
19
+ Genie.output "Replacing lines in file: #{filepath}", color: :blue
20
+
21
+ enforce_sandbox!(filepath)
22
+
23
+ # Check file exists
24
+ unless File.exist?(filepath)
25
+ raise "File not found. Cannot replace lines in a non-existent file."
26
+ end
27
+
28
+ # Read lines
29
+ lines = File.readlines(filepath)
30
+ total = lines.size
31
+
32
+ # Validate indices
33
+ if !start_line.is_a?(Integer) || !end_line.is_a?(Integer) || start_line < 0 || end_line < start_line || end_line > total
34
+ raise "Invalid line numbers: start=#{start_line}, end=#{end_line}, file has #{total} lines."
35
+ end
36
+
37
+ # Split head and tail
38
+ head = lines[0...start_line]
39
+ tail = lines[(end_line + 1)..-1] || []
40
+
41
+ # Prepare new content lines
42
+ new_lines = content.to_s.each_line.to_a
43
+
44
+ # Combine
45
+ updated = head + new_lines + tail
46
+
47
+ # Write back
48
+ File.open(filepath, "w") do |f|
49
+ f.write(updated.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,38 @@
1
+ module Genie
2
+ class RunTests < RubyLLM::Tool
3
+ description "Runs the test suite and returns the results"
4
+
5
+ def initialize(base_path:, cmd:)
6
+ @base_path = base_path
7
+ @base_path.freeze
8
+
9
+ @cmd = cmd || "rake test"
10
+ @cmd.freeze
11
+ end
12
+
13
+ # Stubbed execute method; to be implemented in a future iteration
14
+ def execute
15
+ Genie.output "Running tests...", color: :blue
16
+
17
+ # Run CMD within the base path
18
+ Dir.chdir(@base_path) do
19
+ begin
20
+ cmd = TTY::Command.new(printer: :quiet)
21
+ result = cmd.run!(@cmd)
22
+
23
+ if result.failure?
24
+ Genie.output "Tests failed!", color: :red
25
+ { result: "Tests failed", output: result.out, errors: result.err }
26
+ else
27
+ Genie.output "Tests passed successfully!", color: :green
28
+ { result: "Tests passed", output: result.out }
29
+ end
30
+ rescue => e
31
+ Genie.output "Error running tests: #{e.message}", color: :red
32
+ { error: e.message }
33
+ end
34
+ end
35
+
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,14 @@
1
+ module Genie
2
+ class TakeANote < RubyLLM::Tool
3
+ description "Take a note. The user will see this note. It can be useful to remember something, or explain to the user what you're thinking."
4
+ param :note, desc: "The text of the note you want stored."
5
+
6
+ def execute(note:)
7
+ Genie.output "Note: #{note}", color: :green
8
+
9
+ {
10
+ note: note,
11
+ }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,38 @@
1
+ require "fileutils"
2
+
3
+ module Genie
4
+ class WriteFile < RubyLLM::Tool
5
+ include SandboxedFileTool
6
+
7
+ description "Write a string to a file"
8
+ param :filepath, desc: "The path to the file to read (e.g., '/home/user/documents/file.txt')"
9
+ param :content, desc: "The content to write to the file"
10
+
11
+ def execute(filepath:, content:)
12
+ filepath = File.expand_path(filepath)
13
+
14
+ Genie.output "Writing file: #{filepath}", color: :blue
15
+
16
+ enforce_sandbox!(filepath)
17
+
18
+ indented_content = content.each_line.map { |line| " #{line}" }.join
19
+
20
+ Genie.output "#{indented_content}", color: :green
21
+
22
+ # Ensure the directory exists
23
+ FileUtils.mkdir_p(File.dirname(filepath))
24
+
25
+ File.open(filepath, "w") do |file|
26
+ file.write(content)
27
+ end
28
+
29
+ {
30
+ success: true,
31
+ }
32
+ rescue => e
33
+ Genie.output "Error: #{e.message}", color: :red
34
+
35
+ { error: e.message }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ ---
2
+ base_path: "/tmp/myapp/from_config"
3
+ run_tests_cmd: "bundle exec tests_from_config_ex"
4
+ model: "test_model_from_config"
5
+ instructions: "Instructions from config file"
File without changes
File without changes
@@ -0,0 +1,3 @@
1
+ Line1
2
+ Line2
3
+ Line3
data/test/test_cli.rb ADDED
@@ -0,0 +1,11 @@
1
+ require_relative "test_helper"
2
+ require 'yaml'
3
+
4
+ class CliTest < TLDR
5
+
6
+ def test_runs_without_blowing_up
7
+ config = Genie::SessionConfig.default
8
+ Genie::Cli.new(config: config)
9
+ end
10
+
11
+ end
@@ -0,0 +1,5 @@
1
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
2
+
3
+ require "genie"
4
+
5
+ Genie.quiet! # Don't output the extra information
@@ -0,0 +1,13 @@
1
+ require_relative "test_helper"
2
+ require 'tmpdir'
3
+
4
+ class SessionTest < TLDR
5
+
6
+ def test_session_does_not_blow_up
7
+ config = Genie::SessionConfig.default
8
+ session = Genie::Session.new(config: config)
9
+
10
+ refute_equal nil, session
11
+ end
12
+
13
+ end
@@ -0,0 +1,55 @@
1
+ require_relative "test_helper"
2
+ require 'tmpdir'
3
+
4
+ class SessionConfigTest < TLDR
5
+ def test_basic_session_config
6
+ config = Genie::SessionConfig.new(
7
+ base_path: "/my/cool/path",
8
+ run_tests_cmd: "bundle exec testit",
9
+ model: "gpt-4",
10
+ first_question: "What is the meaning of life?",
11
+ instructions: "Default instructions"
12
+ )
13
+
14
+ assert_equal "/my/cool/path", config.base_path
15
+ assert_equal "bundle exec testit", config.run_tests_cmd
16
+ assert_equal "gpt-4", config.model
17
+ assert_equal "What is the meaning of life?", config.first_question
18
+ assert config.instructions.include?("Default instructions")
19
+ end
20
+
21
+ def test_session_from_argv
22
+ argv = ["-c", "asdf.yml", "--base-path", "/tmp", "--run-tests", "rake test", "--model", "gpt-4o", "--instructions", "Command line instructions", "What is the meaning of life?"]
23
+ config = Genie::SessionConfig.from_argv(argv)
24
+
25
+ assert_equal "/tmp", config.base_path
26
+ assert_equal "rake test", config.run_tests_cmd
27
+ assert_equal "gpt-4o", config.model
28
+ assert_equal "What is the meaning of life?", config.first_question
29
+ assert config.instructions.include?("Command line instructions")
30
+ end
31
+
32
+ def test_session_from_config_file
33
+ argv = ["-c", "./test/data/sample_config.yml"]
34
+ config = Genie::SessionConfig.from_argv(argv)
35
+ expected_base_path = "/tmp/myapp/from_config"
36
+
37
+ assert_equal File.realpath(expected_base_path), File.realpath(config.base_path)
38
+ assert_equal "bundle exec tests_from_config_ex", config.run_tests_cmd
39
+ assert_equal "test_model_from_config", config.model
40
+ assert_equal nil, config.first_question
41
+ assert config.instructions.include?("Instructions from config file")
42
+ end
43
+
44
+ def test_default_instructions
45
+ config = Genie::SessionConfig.default
46
+ default_instructions = config.instructions
47
+ assert_includes default_instructions, "Genie coding assistant"
48
+ assert_includes default_instructions, "Test Driven Development"
49
+ assert_includes default_instructions, "tools available"
50
+ assert_includes default_instructions, "write tests first"
51
+ assert_includes default_instructions, "do not have access to any files outside"
52
+ assert_includes default_instructions, "do not have access to the internet"
53
+ end
54
+
55
+ end
@@ -0,0 +1,29 @@
1
+ require "tmpdir"
2
+
3
+ require "tools/append_to_file"
4
+
5
+ class TestAppendToFile < TLDR
6
+ def test_append_to_existing_file
7
+ Dir.mktmpdir do |dir|
8
+ file = "#{dir}/test_file.txt"
9
+ # Create initial content
10
+ File.write(file, "Hello\n")
11
+
12
+ result = Genie::AppendToFile.new(base_path: dir).execute(filepath: file, content: "World\n")
13
+ assert_equal true, result[:success]
14
+
15
+ actual = File.read(file)
16
+ expected = "Hello\nWorld\n"
17
+ assert_equal expected, actual
18
+ end
19
+ end
20
+
21
+ def test_error_for_file_outside_base_path
22
+ Dir.mktmpdir do |dir|
23
+ outside = "#{dir}/../outside.txt"
24
+
25
+ result = Genie::AppendToFile.new(base_path: dir).execute(filepath: outside, content: "data")
26
+ assert result[:error]&.include?("File not allowed"), "Expected error for file outside base path"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,97 @@
1
+ require "tmpdir"
2
+
3
+ class TestInsertIntoFile < TLDR
4
+ def test_insert_at_middle
5
+ Dir.mktmpdir do |dir|
6
+ file = "#{dir}/test.txt"
7
+ # Create initial content
8
+ File.write(file, "A\nB\nC\n")
9
+
10
+ result = Genie::InsertIntoFile.new(base_path: dir).execute(
11
+ filepath: file,
12
+ content: "X\n",
13
+ line_number: 2
14
+ )
15
+ assert_equal true, result[:success]
16
+
17
+ actual = File.read(file)
18
+ expected = "A\nX\nB\nC\n"
19
+ assert_equal expected, actual
20
+ end
21
+ end
22
+
23
+ def test_insert_at_beginning
24
+ Dir.mktmpdir do |dir|
25
+ file = "#{dir}/test.txt"
26
+ File.write(file, "Line1\nLine2\n")
27
+
28
+ result = Genie::InsertIntoFile.new(base_path: dir).execute(
29
+ filepath: file,
30
+ content: "New\n",
31
+ line_number: 1
32
+ )
33
+ assert_equal true, result[:success]
34
+ actual = File.read(file)
35
+ expected = "New\nLine1\nLine2\n"
36
+ assert_equal expected, actual
37
+ end
38
+ end
39
+
40
+ def test_insert_at_end
41
+ Dir.mktmpdir do |dir|
42
+ file = "#{dir}/test.txt"
43
+ File.write(file, "1\n2\n3\n")
44
+
45
+ # Append by specifying line_number one past last line
46
+ result = Genie::InsertIntoFile.new(base_path: dir).execute(
47
+ filepath: file,
48
+ content: "4\n",
49
+ line_number: 4
50
+ )
51
+ assert_equal true, result[:success]
52
+ actual = File.read(file)
53
+ expected = "1\n2\n3\n4\n"
54
+ assert_equal expected, actual
55
+ end
56
+ end
57
+
58
+ def test_error_for_invalid_line_number
59
+ Dir.mktmpdir do |dir|
60
+ file = "#{dir}/test.txt"
61
+ File.write(file, "Only one line\n")
62
+
63
+ result = Genie::InsertIntoFile.new(base_path: dir).execute(
64
+ filepath: file,
65
+ content: "Oops\n",
66
+ line_number: 0
67
+ )
68
+ assert result[:error]&.include?("Invalid line number"), "Expected error for invalid line number"
69
+ end
70
+ end
71
+
72
+ def test_error_for_file_outside_base_path
73
+ Dir.mktmpdir do |dir|
74
+ outside = "#{dir}/../outside.txt"
75
+
76
+ result = Genie::InsertIntoFile.new(base_path: dir).execute(
77
+ filepath: outside,
78
+ content: "data\n",
79
+ line_number: 1
80
+ )
81
+ assert result[:error]&.include?("File not allowed"), "Expected error for file outside base path"
82
+ end
83
+ end
84
+
85
+ def test_error_for_nonexistent_file
86
+ Dir.mktmpdir do |dir|
87
+ file = "#{dir}/no_file.txt"
88
+
89
+ result = Genie::InsertIntoFile.new(base_path: dir).execute(
90
+ filepath: file,
91
+ content: "data\n",
92
+ line_number: 1
93
+ )
94
+ assert result[:error]&.include?("File not found"), "Expected error for nonexistent file"
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,31 @@
1
+ require "tools/list_files"
2
+
3
+ class TestListFiles < TLDR
4
+ def test_list_files
5
+ actual = Genie::ListFiles.new(base_path: File.expand_path("../../", __dir__)).execute(directory: "./test/data/sample_files")
6
+
7
+ expected = [{ name: "read_file_test.txt", type: "file" },
8
+ { name: "one.txt", type: "file" },
9
+ { name: "a_dir", type: "directory" }]
10
+
11
+ assert_equal expected, actual
12
+ end
13
+
14
+ def test_list_files_in_invalid_directory
15
+ actual = Genie::ListFiles.new(base_path: "/tmp").execute(directory: "/blah/nothing/data/invalid_dir")
16
+
17
+ expected = { error: "Directory not allowed: /blah/nothing/data/invalid_dir. Must be within base path: /tmp" }
18
+
19
+ assert_equal expected, actual
20
+ end
21
+
22
+ def test_list_files_with_filter
23
+ base_path = File.expand_path("../../", __dir__)
24
+ t = Genie::ListFiles.new(base_path: base_path)
25
+ actual = t.execute(directory: "./test/data/sample_files", filter: "one")
26
+
27
+ expected = [{ name: "one.txt", type: "file" }]
28
+
29
+ assert_equal expected, actual
30
+ end
31
+ end
@@ -0,0 +1,26 @@
1
+ require "tools/read_file"
2
+ require 'tmpdir'
3
+
4
+ class TestReadFile < TLDR
5
+ def setup
6
+ @tmp_dir = Dir.mktmpdir
7
+ @file_path = File.join(@tmp_dir, "read_file_test.txt")
8
+ File.write(@file_path, "Line1\nLine2\nLine3\n")
9
+ end
10
+
11
+ def teardown
12
+ FileUtils.remove_entry(@tmp_dir)
13
+ end
14
+
15
+ def test_read_file_without_line_numbers
16
+ actual = Genie::ReadFile.new(base_path: @tmp_dir).execute(filepath: @file_path)
17
+ expected_contents = "Line1\nLine2\nLine3\n"
18
+ assert_equal({ contents: expected_contents }, actual)
19
+ end
20
+
21
+ def test_read_file_with_line_numbers
22
+ actual = Genie::ReadFile.new(base_path: @tmp_dir).execute(filepath: @file_path, include_line_numbers: true)
23
+ expected_contents = "1: Line1\n2: Line2\n3: Line3\n"
24
+ assert_equal({ contents: expected_contents }, actual)
25
+ end
26
+ end
@@ -0,0 +1,76 @@
1
+ require "tmpdir"
2
+
3
+ class TestRenameFile < TLDR
4
+ def test_rename_success
5
+ Dir.mktmpdir do |dir|
6
+ src = "#{dir}/old.txt"
7
+ dst = "#{dir}/new.txt"
8
+ content = "Hello"
9
+ File.write(src, content)
10
+
11
+ result = Genie::RenameFile.new(base_path: dir).execute(
12
+ filepath: src,
13
+ new_path: dst
14
+ )
15
+ assert_equal true, result[:success]
16
+
17
+ # Source should no longer exist, target should exist with content preserved
18
+ assert !File.exist?(src), "Expected source file to be removed"
19
+ assert File.exist?(dst), "Expected target file to exist"
20
+ assert_equal content, File.read(dst)
21
+ end
22
+ end
23
+
24
+ def test_error_for_source_outside_base_path
25
+ Dir.mktmpdir do |dir|
26
+ outside = "#{dir}/../outside.txt"
27
+ result = Genie::RenameFile.new(base_path: dir).execute(
28
+ filepath: outside,
29
+ new_path: "#{dir}/new.txt"
30
+ )
31
+ assert result[:error]&.include?("File not allowed"), "Expected error for source outside base path"
32
+ end
33
+ end
34
+
35
+ def test_error_for_destination_outside_base_path
36
+ Dir.mktmpdir do |dir|
37
+ src = "#{dir}/file.txt"
38
+ File.write(src, "data")
39
+ outside_dst = "/blah/new.txt"
40
+ result = Genie::RenameFile.new(base_path: dir).execute(
41
+ filepath: src,
42
+ new_path: outside_dst
43
+ )
44
+
45
+ assert result[:error]&.include?("File not allowed"), "Expected error for outside sandbox source file"
46
+
47
+ end
48
+ end
49
+
50
+ def test_error_for_nonexistent_source_file
51
+ Dir.mktmpdir do |dir|
52
+ src = "#{dir}/no_file.txt"
53
+ dst = "#{dir}/new.txt"
54
+ result = Genie::RenameFile.new(base_path: dir).execute(
55
+ filepath: src,
56
+ new_path: dst
57
+ )
58
+ assert result[:error]&.include?("File not found"), "Expected error for nonexistent source file"
59
+ end
60
+ end
61
+
62
+ def test_error_for_existing_destination_file
63
+ Dir.mktmpdir do |dir|
64
+ src = "#{dir}/a.txt"
65
+ dst = "#{dir}/b.txt"
66
+ File.write(src, "A")
67
+ File.write(dst, "B")
68
+
69
+ result = Genie::RenameFile.new(base_path: dir).execute(
70
+ filepath: src,
71
+ new_path: dst
72
+ )
73
+ assert result[:error]&.include?("Destination already exists"), "Expected error for existing destination file"
74
+ end
75
+ end
76
+ end