console_agent 0.0.1 → 0.2.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.
@@ -0,0 +1,114 @@
1
+ module ConsoleAgent
2
+ module Providers
3
+ class OpenAI < Base
4
+ API_URL = 'https://api.openai.com'.freeze
5
+
6
+ def chat(messages, system_prompt: nil)
7
+ call_api(messages, system_prompt: system_prompt)
8
+ end
9
+
10
+ def chat_with_tools(messages, tools:, system_prompt: nil)
11
+ call_api(messages, system_prompt: system_prompt, tools: tools)
12
+ end
13
+
14
+ def format_assistant_message(result)
15
+ msg = { role: 'assistant' }
16
+ msg[:content] = result.text if result.text && !result.text.empty?
17
+ if result.tool_calls && !result.tool_calls.empty?
18
+ msg[:tool_calls] = result.tool_calls.map do |tc|
19
+ {
20
+ 'id' => tc[:id],
21
+ 'type' => 'function',
22
+ 'function' => {
23
+ 'name' => tc[:name],
24
+ 'arguments' => JSON.generate(tc[:arguments] || {})
25
+ }
26
+ }
27
+ end
28
+ end
29
+ msg
30
+ end
31
+
32
+ def format_tool_result(tool_call_id, result_string)
33
+ {
34
+ role: 'tool',
35
+ tool_call_id: tool_call_id,
36
+ content: result_string.to_s
37
+ }
38
+ end
39
+
40
+ private
41
+
42
+ def call_api(messages, system_prompt: nil, tools: nil)
43
+ conn = build_connection(API_URL, {
44
+ 'Authorization' => "Bearer #{config.resolved_api_key}"
45
+ })
46
+
47
+ formatted = []
48
+ formatted << { role: 'system', content: system_prompt } if system_prompt
49
+ formatted.concat(format_messages(messages))
50
+
51
+ body = {
52
+ model: config.resolved_model,
53
+ max_tokens: config.max_tokens,
54
+ temperature: config.temperature,
55
+ messages: formatted
56
+ }
57
+ body[:tools] = tools.to_openai_format if tools
58
+
59
+ json_body = JSON.generate(body)
60
+ debug_request("#{API_URL}/v1/chat/completions", body)
61
+ response = conn.post('/v1/chat/completions', json_body)
62
+ debug_response(response.body)
63
+ data = parse_response(response)
64
+ usage = data['usage'] || {}
65
+
66
+ choice = (data['choices'] || []).first || {}
67
+ message = choice['message'] || {}
68
+ finish_reason = choice['finish_reason']
69
+
70
+ tool_calls = extract_tool_calls(message)
71
+ stop = finish_reason == 'tool_calls' ? :tool_use : :end_turn
72
+
73
+ ChatResult.new(
74
+ text: message['content'] || '',
75
+ input_tokens: usage['prompt_tokens'],
76
+ output_tokens: usage['completion_tokens'],
77
+ tool_calls: tool_calls,
78
+ stop_reason: stop
79
+ )
80
+ end
81
+
82
+ def format_messages(messages)
83
+ messages.map do |msg|
84
+ base = { role: msg[:role].to_s }
85
+ if msg[:content]
86
+ base[:content] = msg[:content].is_a?(Array) ? JSON.generate(msg[:content]) : msg[:content].to_s
87
+ end
88
+ base[:tool_calls] = msg[:tool_calls] if msg[:tool_calls]
89
+ base[:tool_call_id] = msg[:tool_call_id] if msg[:tool_call_id]
90
+ base
91
+ end
92
+ end
93
+
94
+ def extract_tool_calls(message)
95
+ calls = message['tool_calls']
96
+ return [] unless calls.is_a?(Array)
97
+
98
+ calls.map do |tc|
99
+ func = tc['function'] || {}
100
+ args = begin
101
+ JSON.parse(func['arguments'] || '{}')
102
+ rescue
103
+ {}
104
+ end
105
+ {
106
+ id: tc['id'],
107
+ name: func['name'],
108
+ arguments: args
109
+ }
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,30 @@
1
+ require 'console_agent'
2
+
3
+ module ConsoleAgent
4
+ class Railtie < Rails::Railtie
5
+ console do
6
+ require 'console_agent/console_methods'
7
+
8
+ # Inject into IRB if available
9
+ if defined?(IRB::ExtendCommandBundle)
10
+ IRB::ExtendCommandBundle.include(ConsoleAgent::ConsoleMethods)
11
+ end
12
+
13
+ # Extend TOPLEVEL_BINDING's receiver as well
14
+ TOPLEVEL_BINDING.eval('self').extend(ConsoleAgent::ConsoleMethods)
15
+
16
+ # Welcome message
17
+ if $stdout.respond_to?(:tty?) && $stdout.tty?
18
+ $stdout.puts "\e[36m[ConsoleAgent] AI assistant loaded. Try: ai \"show me all tables\"\e[0m"
19
+ end
20
+
21
+ # Pre-build context in background
22
+ Thread.new do
23
+ require 'console_agent/context_builder'
24
+ ConsoleAgent::ContextBuilder.new.build
25
+ rescue => e
26
+ ConsoleAgent.logger.debug("ConsoleAgent: background context build failed: #{e.message}")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,342 @@
1
+ require 'readline'
2
+
3
+ module ConsoleAgent
4
+ class Repl
5
+ def initialize(binding_context)
6
+ @binding_context = binding_context
7
+ @executor = Executor.new(binding_context)
8
+ @provider = nil
9
+ @context_builder = nil
10
+ @context = nil
11
+ @history = []
12
+ @total_input_tokens = 0
13
+ @total_output_tokens = 0
14
+ @input_history = []
15
+ end
16
+
17
+ def one_shot(query)
18
+ result = send_query(query)
19
+ track_usage(result)
20
+ code = @executor.display_response(result.text)
21
+ display_usage(result)
22
+ return nil if code.nil? || code.strip.empty?
23
+
24
+ if ConsoleAgent.configuration.auto_execute
25
+ @executor.execute(code)
26
+ else
27
+ @executor.confirm_and_execute(code)
28
+ end
29
+ rescue Providers::ProviderError => e
30
+ $stderr.puts "\e[31mConsoleAgent Error: #{e.message}\e[0m"
31
+ nil
32
+ rescue => e
33
+ $stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
34
+ nil
35
+ end
36
+
37
+ def explain(query)
38
+ result = send_query(query)
39
+ track_usage(result)
40
+ @executor.display_response(result.text)
41
+ display_usage(result)
42
+ nil
43
+ rescue Providers::ProviderError => e
44
+ $stderr.puts "\e[31mConsoleAgent Error: #{e.message}\e[0m"
45
+ nil
46
+ rescue => e
47
+ $stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
48
+ nil
49
+ end
50
+
51
+ def interactive
52
+ $stdout.puts "\e[36mConsoleAgent interactive mode. Type 'exit' or 'quit' to leave.\e[0m"
53
+ @history = []
54
+ @total_input_tokens = 0
55
+ @total_output_tokens = 0
56
+
57
+ loop do
58
+ input = Readline.readline("\e[33mai> \e[0m", false)
59
+ break if input.nil? # Ctrl-D
60
+
61
+ input = input.strip
62
+ break if input.downcase == 'exit' || input.downcase == 'quit'
63
+ next if input.empty?
64
+
65
+ # Add to Readline history (avoid consecutive duplicates)
66
+ Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
67
+
68
+ @history << { role: :user, content: input }
69
+
70
+ begin
71
+ result = send_query(input, conversation: @history)
72
+ rescue Interrupt
73
+ $stdout.puts "\n\e[33m Aborted.\e[0m"
74
+ @history.pop # Remove the user message that never got a response
75
+ next
76
+ end
77
+
78
+ track_usage(result)
79
+ code = @executor.display_response(result.text)
80
+ display_usage(result, show_session: true)
81
+
82
+ @history << { role: :assistant, content: result.text }
83
+
84
+ if code && !code.strip.empty?
85
+ if ConsoleAgent.configuration.auto_execute
86
+ exec_result = @executor.execute(code)
87
+ else
88
+ exec_result = @executor.confirm_and_execute(code)
89
+ end
90
+
91
+ if @executor.last_cancelled?
92
+ @history << { role: :user, content: "User declined to execute the code." }
93
+ else
94
+ output_parts = []
95
+
96
+ # Capture printed output (puts, print, etc.)
97
+ if @executor.last_output && !@executor.last_output.strip.empty?
98
+ output_parts << "Output:\n#{@executor.last_output.strip}"
99
+ end
100
+
101
+ # Capture return value
102
+ if exec_result
103
+ output_parts << "Return value: #{exec_result.inspect}"
104
+ end
105
+
106
+ unless output_parts.empty?
107
+ result_str = output_parts.join("\n\n")
108
+ result_str = result_str[0..1000] + '...' if result_str.length > 1000
109
+ @history << { role: :user, content: "Code was executed. #{result_str}" }
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ display_session_summary
116
+ $stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
117
+ rescue Interrupt
118
+ # Ctrl-C during Readline input — exit cleanly
119
+ $stdout.puts
120
+ display_session_summary
121
+ $stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
122
+ rescue => e
123
+ $stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
124
+ end
125
+
126
+ private
127
+
128
+ def provider
129
+ @provider ||= Providers.build
130
+ end
131
+
132
+ def context_builder
133
+ @context_builder ||= ContextBuilder.new
134
+ end
135
+
136
+ def context
137
+ @context ||= context_builder.build
138
+ end
139
+
140
+ def send_query(query, conversation: nil)
141
+ ConsoleAgent.configuration.validate!
142
+
143
+ messages = if conversation
144
+ conversation.map { |m| { role: m[:role], content: m[:content] } }
145
+ else
146
+ [{ role: :user, content: query }]
147
+ end
148
+
149
+ if ConsoleAgent.configuration.context_mode == :smart
150
+ send_query_with_tools(messages)
151
+ else
152
+ provider.chat(messages, system_prompt: context)
153
+ end
154
+ end
155
+
156
+ def send_query_with_tools(messages)
157
+ require 'console_agent/tools/registry'
158
+ tools = Tools::Registry.new
159
+ max_rounds = ConsoleAgent.configuration.max_tool_rounds
160
+ total_input = 0
161
+ total_output = 0
162
+ result = nil
163
+
164
+ exhausted = false
165
+
166
+ max_rounds.times do |round|
167
+ if round == 0
168
+ $stdout.puts "\e[2m Thinking...\e[0m"
169
+ end
170
+
171
+ result = provider.chat_with_tools(messages, tools: tools, system_prompt: context)
172
+ total_input += result.input_tokens || 0
173
+ total_output += result.output_tokens || 0
174
+
175
+ break unless result.tool_use?
176
+
177
+ # Show what the LLM is thinking (if it returned text alongside tool calls)
178
+ if result.text && !result.text.strip.empty?
179
+ $stdout.puts "\e[2m #{result.text.strip}\e[0m"
180
+ end
181
+
182
+ # Add assistant message with tool calls to conversation
183
+ messages << provider.format_assistant_message(result)
184
+
185
+ # Execute each tool and show progress
186
+ result.tool_calls.each do |tc|
187
+ # ask_user handles its own display (prompt + input)
188
+ if tc[:name] == 'ask_user'
189
+ tool_result = tools.execute(tc[:name], tc[:arguments])
190
+ else
191
+ args_display = format_tool_args(tc[:name], tc[:arguments])
192
+ $stdout.puts "\e[33m -> #{tc[:name]}#{args_display}\e[0m"
193
+
194
+ tool_result = tools.execute(tc[:name], tc[:arguments])
195
+
196
+ preview = compact_tool_result(tc[:name], tool_result)
197
+ cached_tag = tools.last_cached? ? " (cached)" : ""
198
+ $stdout.puts "\e[2m #{preview}#{cached_tag}\e[0m"
199
+ end
200
+
201
+ if ConsoleAgent.configuration.debug
202
+ $stderr.puts "\e[35m[debug tool result] #{tool_result}\e[0m"
203
+ end
204
+
205
+ messages << provider.format_tool_result(tc[:id], tool_result)
206
+ end
207
+
208
+ exhausted = true if round == max_rounds - 1
209
+ end
210
+
211
+ # If we hit the tool round limit, force a final response without tools
212
+ if exhausted
213
+ $stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: ConsoleAgent.configure { |c| c.max_tool_rounds = 200 }\e[0m"
214
+ messages << { role: :user, content: "You've used all available tool rounds. Please provide your best answer now based on what you've learned so far." }
215
+ result = provider.chat(messages, system_prompt: context)
216
+ total_input += result.input_tokens || 0
217
+ total_output += result.output_tokens || 0
218
+ end
219
+
220
+ Providers::ChatResult.new(
221
+ text: result ? result.text : '',
222
+ input_tokens: total_input,
223
+ output_tokens: total_output,
224
+ stop_reason: result ? result.stop_reason : :end_turn
225
+ )
226
+ end
227
+
228
+ def format_tool_args(name, args)
229
+ return '' if args.nil? || args.empty?
230
+
231
+ case name
232
+ when 'describe_table'
233
+ "(\"#{args['table_name']}\")"
234
+ when 'describe_model'
235
+ "(\"#{args['model_name']}\")"
236
+ when 'read_file'
237
+ "(\"#{args['path']}\")"
238
+ when 'search_code'
239
+ dir = args['directory'] ? ", dir: \"#{args['directory']}\"" : ''
240
+ "(\"#{args['query']}\"#{dir})"
241
+ when 'list_files'
242
+ args['directory'] ? "(\"#{args['directory']}\")" : ''
243
+ when 'save_memory'
244
+ "(\"#{args['name']}\")"
245
+ when 'delete_memory'
246
+ "(\"#{args['name']}\")"
247
+ when 'recall_memories'
248
+ args['query'] ? "(\"#{args['query']}\")" : ''
249
+ else
250
+ ''
251
+ end
252
+ end
253
+
254
+ def compact_tool_result(name, result)
255
+ return '(empty)' if result.nil? || result.strip.empty?
256
+
257
+ case name
258
+ when 'list_tables'
259
+ tables = result.split(', ')
260
+ if tables.length > 8
261
+ "#{tables.length} tables: #{tables.first(8).join(', ')}..."
262
+ else
263
+ "#{tables.length} tables: #{result}"
264
+ end
265
+ when 'list_models'
266
+ lines = result.split("\n")
267
+ if lines.length > 6
268
+ "#{lines.length} models: #{lines.first(6).map { |l| l.split(' ').first }.join(', ')}..."
269
+ else
270
+ "#{lines.length} models"
271
+ end
272
+ when 'describe_table'
273
+ col_count = result.scan(/^\s{2}\S/).length
274
+ "#{col_count} columns"
275
+ when 'describe_model'
276
+ parts = []
277
+ assoc_count = result.scan(/^\s{2}(has_many|has_one|belongs_to|has_and_belongs_to_many)/).length
278
+ val_count = result.scan(/^\s{2}(presence|uniqueness|format|length|numericality|inclusion|exclusion|confirmation|acceptance)/).length
279
+ parts << "#{assoc_count} associations" if assoc_count > 0
280
+ parts << "#{val_count} validations" if val_count > 0
281
+ parts.empty? ? truncate(result, 80) : parts.join(', ')
282
+ when 'list_files'
283
+ lines = result.split("\n")
284
+ "#{lines.length} files"
285
+ when 'read_file'
286
+ lines = result.split("\n")
287
+ "#{lines.length} lines"
288
+ when 'search_code'
289
+ if result.start_with?('Found')
290
+ result.split("\n").first
291
+ elsif result.start_with?('No matches')
292
+ result
293
+ else
294
+ truncate(result, 80)
295
+ end
296
+ when 'save_memory'
297
+ (result.start_with?('Memory saved') || result.start_with?('Memory updated')) ? result : truncate(result, 80)
298
+ when 'delete_memory'
299
+ result.start_with?('Memory deleted') ? result : truncate(result, 80)
300
+ when 'recall_memories'
301
+ chunks = result.split("\n\n")
302
+ chunks.length > 1 ? "#{chunks.length} memories found" : truncate(result, 80)
303
+ else
304
+ truncate(result, 80)
305
+ end
306
+ end
307
+
308
+ def truncate(str, max)
309
+ str.length > max ? str[0..max] + '...' : str
310
+ end
311
+
312
+ def track_usage(result)
313
+ @total_input_tokens += result.input_tokens || 0
314
+ @total_output_tokens += result.output_tokens || 0
315
+ end
316
+
317
+ def display_usage(result, show_session: false)
318
+ input = result.input_tokens
319
+ output = result.output_tokens
320
+ return unless input || output
321
+
322
+ parts = []
323
+ parts << "in: #{input}" if input
324
+ parts << "out: #{output}" if output
325
+ parts << "total: #{result.total_tokens}"
326
+
327
+ line = "\e[2m[tokens #{parts.join(' | ')}]\e[0m"
328
+
329
+ if show_session && (@total_input_tokens + @total_output_tokens) > result.total_tokens
330
+ line += "\e[2m [session: in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
331
+ end
332
+
333
+ $stdout.puts line
334
+ end
335
+
336
+ def display_session_summary
337
+ return if @total_input_tokens == 0 && @total_output_tokens == 0
338
+
339
+ $stdout.puts "\e[2m[session totals — in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,27 @@
1
+ module ConsoleAgent
2
+ module Storage
3
+ class StorageError < StandardError; end
4
+
5
+ class Base
6
+ def read(key)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def write(key, content)
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def list(pattern)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def exists?(key)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def delete(key)
23
+ raise NotImplementedError
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,63 @@
1
+ require 'fileutils'
2
+ require 'console_agent/storage/base'
3
+
4
+ module ConsoleAgent
5
+ module Storage
6
+ class FileStorage < Base
7
+ attr_reader :root_path
8
+
9
+ def initialize(root_path = nil)
10
+ @root_path = root_path || default_root
11
+ end
12
+
13
+ def read(key)
14
+ path = full_path(key)
15
+ return nil unless File.exist?(path)
16
+ File.read(path)
17
+ end
18
+
19
+ def write(key, content)
20
+ path = full_path(key)
21
+ FileUtils.mkdir_p(File.dirname(path))
22
+ File.write(path, content)
23
+ true
24
+ rescue Errno::EACCES, Errno::EROFS, IOError => e
25
+ raise StorageError, "Cannot write #{key}: #{e.message}"
26
+ end
27
+
28
+ def list(pattern)
29
+ Dir.glob(File.join(@root_path, pattern)).sort.map do |path|
30
+ path.sub("#{@root_path}/", '')
31
+ end
32
+ end
33
+
34
+ def exists?(key)
35
+ File.exist?(full_path(key))
36
+ end
37
+
38
+ def delete(key)
39
+ path = full_path(key)
40
+ return false unless File.exist?(path)
41
+ File.delete(path)
42
+ true
43
+ rescue Errno::EACCES, Errno::EROFS, IOError => e
44
+ raise StorageError, "Cannot delete #{key}: #{e.message}"
45
+ end
46
+
47
+ private
48
+
49
+ def full_path(key)
50
+ sanitized = key.gsub('..', '').gsub(%r{\A/+}, '')
51
+ File.join(@root_path, sanitized)
52
+ end
53
+
54
+ def default_root
55
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
56
+ File.join(Rails.root.to_s, '.console_agent')
57
+ else
58
+ File.join(Dir.pwd, '.console_agent')
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,114 @@
1
+ module ConsoleAgent
2
+ module Tools
3
+ class CodeTools
4
+ MAX_FILE_LINES = 200
5
+ MAX_LIST_ENTRIES = 100
6
+ MAX_SEARCH_RESULTS = 50
7
+
8
+ def list_files(directory = nil)
9
+ directory = sanitize_directory(directory || 'app')
10
+ root = rails_root
11
+ return "Rails.root is not available." unless root
12
+
13
+ full_path = File.join(root, directory)
14
+ return "Directory '#{directory}' not found." unless File.directory?(full_path)
15
+
16
+ files = Dir.glob(File.join(full_path, '**', '*.rb')).sort
17
+ files = files.map { |f| f.sub("#{root}/", '') }
18
+
19
+ if files.length > MAX_LIST_ENTRIES
20
+ truncated = files.first(MAX_LIST_ENTRIES)
21
+ truncated.join("\n") + "\n... and #{files.length - MAX_LIST_ENTRIES} more files"
22
+ elsif files.empty?
23
+ "No Ruby files found in '#{directory}'."
24
+ else
25
+ files.join("\n")
26
+ end
27
+ rescue => e
28
+ "Error listing files: #{e.message}"
29
+ end
30
+
31
+ def read_file(path)
32
+ return "Error: path is required." if path.nil? || path.strip.empty?
33
+
34
+ root = rails_root
35
+ return "Rails.root is not available." unless root
36
+
37
+ path = sanitize_path(path)
38
+ full_path = File.expand_path(File.join(root, path))
39
+
40
+ # Security: ensure resolved path is under Rails.root
41
+ unless full_path.start_with?(File.expand_path(root))
42
+ return "Error: path must be within the Rails application."
43
+ end
44
+
45
+ return "File '#{path}' not found." unless File.exist?(full_path)
46
+ return "Error: '#{path}' is a directory, not a file." if File.directory?(full_path)
47
+
48
+ lines = File.readlines(full_path)
49
+ if lines.length > MAX_FILE_LINES
50
+ numbered = lines.first(MAX_FILE_LINES).each_with_index.map { |line, i| "#{i + 1}: #{line}" }
51
+ numbered.join + "\n... truncated (#{lines.length} total lines, showing first #{MAX_FILE_LINES})"
52
+ else
53
+ lines.each_with_index.map { |line, i| "#{i + 1}: #{line}" }.join
54
+ end
55
+ rescue => e
56
+ "Error reading file '#{path}': #{e.message}"
57
+ end
58
+
59
+ def search_code(query, directory = nil)
60
+ return "Error: query is required." if query.nil? || query.strip.empty?
61
+
62
+ directory = sanitize_directory(directory || 'app')
63
+ root = rails_root
64
+ return "Rails.root is not available." unless root
65
+
66
+ full_path = File.join(root, directory)
67
+ return "Directory '#{directory}' not found." unless File.directory?(full_path)
68
+
69
+ results = []
70
+ Dir.glob(File.join(full_path, '**', '*.rb')).sort.each do |file|
71
+ break if results.length >= MAX_SEARCH_RESULTS
72
+
73
+ relative = file.sub("#{root}/", '')
74
+ File.readlines(file).each_with_index do |line, idx|
75
+ if line.include?(query)
76
+ results << "#{relative}:#{idx + 1}: #{line.strip}"
77
+ break if results.length >= MAX_SEARCH_RESULTS
78
+ end
79
+ end
80
+ rescue => e
81
+ # skip unreadable files
82
+ end
83
+
84
+ if results.empty?
85
+ "No matches found for '#{query}' in #{directory}/."
86
+ else
87
+ header = "Found #{results.length} match#{'es' if results.length != 1}:\n"
88
+ header + results.join("\n")
89
+ end
90
+ rescue => e
91
+ "Error searching: #{e.message}"
92
+ end
93
+
94
+ private
95
+
96
+ def rails_root
97
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
98
+ Rails.root.to_s
99
+ else
100
+ nil
101
+ end
102
+ end
103
+
104
+ def sanitize_path(path)
105
+ # Remove leading slashes and ../ sequences
106
+ path.strip.gsub(/\A\/+/, '').gsub(/\.\.\//, '').gsub(/\.\.\\/, '')
107
+ end
108
+
109
+ def sanitize_directory(dir)
110
+ sanitize_path(dir || 'app')
111
+ end
112
+ end
113
+ end
114
+ end