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 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
@@ -0,0 +1,8 @@
1
+ # Rakefile
2
+
3
+ desc "Run tests with tldr"
4
+ task :test do
5
+ sh "bundle exec tldr --no-warnings"
6
+ end
7
+
8
+ task default: :test
data/bin/genie ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/genie"
4
+
5
+ config = Genie::SessionConfig.from_argv(ARGV)
6
+
7
+ # Start the CLI with provided arguments
8
+ Genie::Cli.new(config: config).run
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
@@ -0,0 +1,3 @@
1
+ module Genie
2
+ VERSION = '0.1.1'
3
+ 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