rcrewai 0.2.0 → 0.3.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 +4 -4
- data/.rubocop.yml +1 -0
- data/.rubocop_todo.yml +99 -0
- data/CHANGELOG.md +24 -0
- data/README.md +33 -1
- data/Rakefile +53 -53
- data/bin/rcrewai +3 -3
- data/docs/mcp.md +109 -0
- data/docs/superpowers/plans/2026-05-11-llm-modernization.md +2753 -0
- data/docs/superpowers/specs/2026-05-11-llm-modernization-design.md +479 -0
- data/docs/upgrading-to-0.3.md +163 -0
- data/examples/async_execution_example.rb +82 -81
- data/examples/hierarchical_crew_example.rb +68 -72
- data/examples/human_in_the_loop_example.rb +73 -74
- data/examples/mcp_example.rb +48 -0
- data/examples/native_tools_example.rb +64 -0
- data/examples/streaming_example.rb +56 -0
- data/lib/rcrewai/agent.rb +148 -287
- data/lib/rcrewai/async_executor.rb +43 -43
- data/lib/rcrewai/cli.rb +11 -11
- data/lib/rcrewai/configuration.rb +14 -9
- data/lib/rcrewai/crew.rb +56 -39
- data/lib/rcrewai/events.rb +30 -0
- data/lib/rcrewai/human_input.rb +104 -114
- data/lib/rcrewai/legacy_react_runner.rb +172 -0
- data/lib/rcrewai/llm_client.rb +1 -1
- data/lib/rcrewai/llm_clients/anthropic.rb +174 -54
- data/lib/rcrewai/llm_clients/azure.rb +23 -128
- data/lib/rcrewai/llm_clients/base.rb +11 -7
- data/lib/rcrewai/llm_clients/google.rb +159 -95
- data/lib/rcrewai/llm_clients/ollama.rb +150 -106
- data/lib/rcrewai/llm_clients/openai.rb +140 -63
- data/lib/rcrewai/mcp/client.rb +101 -0
- data/lib/rcrewai/mcp/tool_adapter.rb +59 -0
- data/lib/rcrewai/mcp/transport/http.rb +53 -0
- data/lib/rcrewai/mcp/transport/stdio.rb +55 -0
- data/lib/rcrewai/mcp.rb +8 -0
- data/lib/rcrewai/memory.rb +45 -37
- data/lib/rcrewai/pricing.rb +34 -0
- data/lib/rcrewai/process.rb +86 -95
- data/lib/rcrewai/provider_schema.rb +38 -0
- data/lib/rcrewai/sse_parser.rb +55 -0
- data/lib/rcrewai/task.rb +56 -64
- data/lib/rcrewai/tool_runner.rb +132 -0
- data/lib/rcrewai/tool_schema.rb +97 -0
- data/lib/rcrewai/tools/base.rb +98 -37
- data/lib/rcrewai/tools/code_executor.rb +71 -74
- data/lib/rcrewai/tools/email_sender.rb +70 -78
- data/lib/rcrewai/tools/file_reader.rb +38 -30
- data/lib/rcrewai/tools/file_writer.rb +40 -38
- data/lib/rcrewai/tools/pdf_processor.rb +115 -130
- data/lib/rcrewai/tools/sql_database.rb +58 -55
- data/lib/rcrewai/tools/web_search.rb +26 -25
- data/lib/rcrewai/version.rb +2 -2
- data/lib/rcrewai.rb +18 -10
- data/rcrewai.gemspec +55 -36
- metadata +86 -50
|
@@ -7,10 +7,20 @@ require 'mail'
|
|
|
7
7
|
module RCrewAI
|
|
8
8
|
module Tools
|
|
9
9
|
class EmailSender < Base
|
|
10
|
+
tool_name 'email_sender'
|
|
11
|
+
description 'Send an email via configured SMTP'
|
|
12
|
+
param :to, type: :string, required: true,
|
|
13
|
+
description: 'Recipient address. Multiple addresses may be separated by commas.'
|
|
14
|
+
param :subject, type: :string, required: true, description: 'Email subject'
|
|
15
|
+
param :body, type: :string, required: true, description: 'Email body (plain text or HTML)'
|
|
16
|
+
param :cc, type: :string, required: false, description: 'CC recipients (comma-separated)'
|
|
17
|
+
param :bcc, type: :string, required: false, description: 'BCC recipients (comma-separated)'
|
|
18
|
+
param :reply_to, type: :string, required: false, description: 'Reply-to address'
|
|
19
|
+
param :attachments, type: :array, required: false, items: { type: :string },
|
|
20
|
+
description: 'File paths to attach. Allowed extensions: pdf, txt, doc(x), xls(x), jpg, png, gif, zip.'
|
|
21
|
+
|
|
10
22
|
def initialize(**options)
|
|
11
23
|
super()
|
|
12
|
-
@name = 'emailsender'
|
|
13
|
-
@description = 'Send emails via SMTP'
|
|
14
24
|
@smtp_server = options[:smtp_server] || 'localhost'
|
|
15
25
|
@smtp_port = options[:smtp_port] || 587
|
|
16
26
|
@username = options[:username]
|
|
@@ -23,20 +33,20 @@ module RCrewAI
|
|
|
23
33
|
|
|
24
34
|
def execute(**params)
|
|
25
35
|
validate_params!(
|
|
26
|
-
params,
|
|
27
|
-
required: [
|
|
28
|
-
optional: [
|
|
36
|
+
params,
|
|
37
|
+
required: %i[to subject body],
|
|
38
|
+
optional: %i[cc bcc reply_to attachments]
|
|
29
39
|
)
|
|
30
|
-
|
|
40
|
+
|
|
31
41
|
to_addresses = normalize_email_addresses(params[:to])
|
|
32
42
|
cc_addresses = normalize_email_addresses(params[:cc]) if params[:cc]
|
|
33
43
|
bcc_addresses = normalize_email_addresses(params[:bcc]) if params[:bcc]
|
|
34
|
-
|
|
44
|
+
|
|
35
45
|
begin
|
|
36
46
|
validate_email_params!(to_addresses, cc_addresses, bcc_addresses, params)
|
|
37
47
|
result = send_email(to_addresses, cc_addresses, bcc_addresses, params)
|
|
38
48
|
format_email_result(result, to_addresses)
|
|
39
|
-
rescue => e
|
|
49
|
+
rescue StandardError => e
|
|
40
50
|
"Email sending failed: #{e.message}"
|
|
41
51
|
end
|
|
42
52
|
end
|
|
@@ -70,33 +80,27 @@ module RCrewAI
|
|
|
70
80
|
def validate_email_params!(to_addresses, cc_addresses, bcc_addresses, params)
|
|
71
81
|
# Validate email addresses
|
|
72
82
|
all_addresses = to_addresses + (cc_addresses || []) + (bcc_addresses || [])
|
|
73
|
-
|
|
83
|
+
|
|
74
84
|
all_addresses.each do |address|
|
|
75
|
-
unless valid_email?(address)
|
|
76
|
-
raise ToolError, "Invalid email address: #{address}"
|
|
77
|
-
end
|
|
85
|
+
raise ToolError, "Invalid email address: #{address}" unless valid_email?(address)
|
|
78
86
|
end
|
|
79
|
-
|
|
87
|
+
|
|
80
88
|
# Check recipient limits
|
|
81
89
|
if all_addresses.length > @max_recipients
|
|
82
90
|
raise ToolError, "Too many recipients: #{all_addresses.length} (max: #{@max_recipients})"
|
|
83
91
|
end
|
|
84
|
-
|
|
92
|
+
|
|
85
93
|
# Validate subject and body
|
|
86
|
-
if params[:subject].to_s.strip.empty?
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if params[:body].to_s.strip.empty?
|
|
91
|
-
raise ToolError, "Email body cannot be empty"
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
+
raise ToolError, 'Email subject cannot be empty' if params[:subject].to_s.strip.empty?
|
|
95
|
+
|
|
96
|
+
raise ToolError, 'Email body cannot be empty' if params[:body].to_s.strip.empty?
|
|
97
|
+
|
|
94
98
|
# Check for spam-like content
|
|
95
99
|
validate_content_safety!(params[:subject], params[:body])
|
|
96
100
|
end
|
|
97
101
|
|
|
98
102
|
def valid_email?(address)
|
|
99
|
-
address.match?(/\A[\w+\-.]+@[a-z\d
|
|
103
|
+
address.match?(/\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i)
|
|
100
104
|
end
|
|
101
105
|
|
|
102
106
|
def validate_content_safety!(subject, body)
|
|
@@ -105,20 +109,18 @@ module RCrewAI
|
|
|
105
109
|
'viagra', 'casino', 'lottery', 'winner', 'congratulations',
|
|
106
110
|
'million dollars', 'click here', 'urgent', 'act now'
|
|
107
111
|
]
|
|
108
|
-
|
|
112
|
+
|
|
109
113
|
content = "#{subject} #{body}".downcase
|
|
110
|
-
|
|
114
|
+
|
|
111
115
|
spam_indicators.each do |indicator|
|
|
112
|
-
if content.include?(indicator)
|
|
113
|
-
raise ToolError, "Potentially spam content detected: #{indicator}"
|
|
114
|
-
end
|
|
116
|
+
raise ToolError, "Potentially spam content detected: #{indicator}" if content.include?(indicator)
|
|
115
117
|
end
|
|
116
|
-
|
|
118
|
+
|
|
117
119
|
# Check for excessive caps
|
|
118
120
|
caps_ratio = content.gsub(/[^A-Z]/, '').length.to_f / content.gsub(/[^A-Za-z]/, '').length
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
return unless caps_ratio > 0.5 && content.length > 50
|
|
122
|
+
|
|
123
|
+
raise ToolError, 'Excessive capitalization detected (possible spam)'
|
|
122
124
|
end
|
|
123
125
|
|
|
124
126
|
def send_email(to_addresses, cc_addresses, bcc_addresses, params)
|
|
@@ -129,20 +131,16 @@ module RCrewAI
|
|
|
129
131
|
bcc bcc_addresses if bcc_addresses&.any?
|
|
130
132
|
subject params[:subject]
|
|
131
133
|
body params[:body]
|
|
132
|
-
|
|
133
|
-
if params[:reply_to]
|
|
134
|
-
reply_to params[:reply_to]
|
|
135
|
-
end
|
|
134
|
+
|
|
135
|
+
reply_to params[:reply_to] if params[:reply_to]
|
|
136
136
|
end
|
|
137
|
-
|
|
137
|
+
|
|
138
138
|
# Add attachments if provided
|
|
139
|
-
if params[:attachments]
|
|
140
|
-
|
|
141
|
-
end
|
|
142
|
-
|
|
139
|
+
add_attachments(mail, params[:attachments]) if params[:attachments]
|
|
140
|
+
|
|
143
141
|
# Send the email
|
|
144
142
|
mail.deliver!
|
|
145
|
-
|
|
143
|
+
|
|
146
144
|
{
|
|
147
145
|
message_id: mail.message_id,
|
|
148
146
|
to: to_addresses,
|
|
@@ -156,55 +154,49 @@ module RCrewAI
|
|
|
156
154
|
|
|
157
155
|
def add_attachments(mail, attachments)
|
|
158
156
|
attachment_list = case attachments
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
157
|
+
when String
|
|
158
|
+
[attachments]
|
|
159
|
+
when Array
|
|
160
|
+
attachments
|
|
161
|
+
else
|
|
162
|
+
[attachments.to_s]
|
|
163
|
+
end
|
|
164
|
+
|
|
167
165
|
attachment_list.each do |attachment_path|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
# Size check (max 10MB per attachment)
|
|
178
|
-
if File.size(attachment_path) > 10_000_000
|
|
179
|
-
raise ToolError, "Attachment too large: #{File.basename(attachment_path)} (max 10MB)"
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
mail.add_file(attachment_path)
|
|
183
|
-
else
|
|
184
|
-
raise ToolError, "Attachment file not found: #{attachment_path}"
|
|
166
|
+
raise ToolError, "Attachment file not found: #{attachment_path}" unless File.exist?(attachment_path)
|
|
167
|
+
|
|
168
|
+
# Security check - only allow certain file types
|
|
169
|
+
allowed_extensions = %w[.pdf .txt .doc .docx .xls .xlsx .jpg .png .gif .zip]
|
|
170
|
+
extension = File.extname(attachment_path).downcase
|
|
171
|
+
|
|
172
|
+
unless allowed_extensions.include?(extension)
|
|
173
|
+
raise ToolError, "Attachment file type not allowed: #{extension}"
|
|
185
174
|
end
|
|
175
|
+
|
|
176
|
+
# Size check (max 10MB per attachment)
|
|
177
|
+
if File.size(attachment_path) > 10_000_000
|
|
178
|
+
raise ToolError, "Attachment too large: #{File.basename(attachment_path)} (max 10MB)"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
mail.add_file(attachment_path)
|
|
186
182
|
end
|
|
187
183
|
end
|
|
188
184
|
|
|
189
185
|
def format_email_result(result, to_addresses)
|
|
190
186
|
output = []
|
|
191
|
-
output <<
|
|
187
|
+
output << 'Email sent successfully!'
|
|
192
188
|
output << "Message ID: #{result[:message_id]}"
|
|
193
189
|
output << "Recipients: #{to_addresses.join(', ')}"
|
|
194
190
|
output << "Subject: #{result[:subject]}"
|
|
195
191
|
output << "Sent at: #{result[:sent_at].strftime('%Y-%m-%d %H:%M:%S')}"
|
|
196
192
|
output << "Message size: #{result[:size]} bytes"
|
|
197
|
-
|
|
198
|
-
if result[:cc]&.any?
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if result[:bcc]&.any?
|
|
203
|
-
output << "BCC: #{result[:bcc].length} recipients"
|
|
204
|
-
end
|
|
205
|
-
|
|
193
|
+
|
|
194
|
+
output << "CC: #{result[:cc].join(', ')}" if result[:cc]&.any?
|
|
195
|
+
|
|
196
|
+
output << "BCC: #{result[:bcc].length} recipients" if result[:bcc]&.any?
|
|
197
|
+
|
|
206
198
|
output.join("\n")
|
|
207
199
|
end
|
|
208
200
|
end
|
|
209
201
|
end
|
|
210
|
-
end
|
|
202
|
+
end
|
|
@@ -6,24 +6,31 @@ require 'pathname'
|
|
|
6
6
|
module RCrewAI
|
|
7
7
|
module Tools
|
|
8
8
|
class FileReader < Base
|
|
9
|
+
tool_name 'file_reader'
|
|
10
|
+
description 'Read the contents of a text file from disk'
|
|
11
|
+
param :file_path, type: :string, required: true,
|
|
12
|
+
description: 'Absolute or relative path to the file'
|
|
13
|
+
param :encoding, type: :string, default: 'utf-8',
|
|
14
|
+
description: 'Text encoding (e.g. utf-8, iso-8859-1)'
|
|
15
|
+
param :lines, type: :integer, required: false,
|
|
16
|
+
description: 'If set, read only the first N lines'
|
|
17
|
+
|
|
9
18
|
def initialize(**options)
|
|
10
19
|
super()
|
|
11
|
-
@name = 'filereader'
|
|
12
|
-
@description = 'Read contents from files'
|
|
13
20
|
@max_file_size = options.fetch(:max_file_size, 10_000_000) # 10MB
|
|
14
21
|
@allowed_extensions = options.fetch(:allowed_extensions, %w[.txt .md .json .yaml .yml .csv .log])
|
|
15
22
|
end
|
|
16
23
|
|
|
17
24
|
def execute(**params)
|
|
18
|
-
validate_params!(params, required: [:file_path], optional: [
|
|
19
|
-
|
|
25
|
+
validate_params!(params, required: [:file_path], optional: %i[encoding lines])
|
|
26
|
+
|
|
20
27
|
file_path = params[:file_path]
|
|
21
28
|
encoding = params[:encoding] || 'utf-8'
|
|
22
29
|
lines = params[:lines] # Optional: read only N lines
|
|
23
|
-
|
|
30
|
+
|
|
24
31
|
begin
|
|
25
32
|
read_file(file_path, encoding, lines)
|
|
26
|
-
rescue => e
|
|
33
|
+
rescue StandardError => e
|
|
27
34
|
"File read failed: #{e.message}"
|
|
28
35
|
end
|
|
29
36
|
end
|
|
@@ -32,17 +39,17 @@ module RCrewAI
|
|
|
32
39
|
|
|
33
40
|
def read_file(file_path, encoding, lines = nil)
|
|
34
41
|
path = Pathname.new(file_path)
|
|
35
|
-
|
|
42
|
+
|
|
36
43
|
# Security checks
|
|
37
44
|
validate_file_path!(path)
|
|
38
45
|
validate_file_size!(path)
|
|
39
46
|
validate_file_extension!(path)
|
|
40
|
-
|
|
47
|
+
|
|
41
48
|
content = if lines
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
read_lines(path, encoding, lines)
|
|
50
|
+
else
|
|
51
|
+
read_full_file(path, encoding)
|
|
52
|
+
end
|
|
46
53
|
|
|
47
54
|
format_file_content(path, content, lines)
|
|
48
55
|
end
|
|
@@ -51,27 +58,27 @@ module RCrewAI
|
|
|
51
58
|
raise ToolError, "File does not exist: #{path}" unless path.exist?
|
|
52
59
|
raise ToolError, "Path is not a file: #{path}" unless path.file?
|
|
53
60
|
raise ToolError, "File is not readable: #{path}" unless path.readable?
|
|
54
|
-
|
|
61
|
+
|
|
55
62
|
# Prevent directory traversal
|
|
56
63
|
resolved_path = path.realpath.to_s
|
|
57
64
|
working_dir = Dir.pwd
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
return if resolved_path.start_with?(working_dir)
|
|
66
|
+
|
|
67
|
+
raise ToolError, 'Access denied: file outside working directory'
|
|
61
68
|
end
|
|
62
69
|
|
|
63
70
|
def validate_file_size!(path)
|
|
64
71
|
size = path.size
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
return unless size > @max_file_size
|
|
73
|
+
|
|
74
|
+
raise ToolError, "File too large: #{size} bytes (max: #{@max_file_size})"
|
|
68
75
|
end
|
|
69
76
|
|
|
70
77
|
def validate_file_extension!(path)
|
|
71
78
|
extension = path.extname.downcase
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
return if @allowed_extensions.include?(extension) || @allowed_extensions.include?('*')
|
|
80
|
+
|
|
81
|
+
raise ToolError, "File type not allowed: #{extension}"
|
|
75
82
|
end
|
|
76
83
|
|
|
77
84
|
def read_lines(path, encoding, line_count)
|
|
@@ -80,6 +87,7 @@ module RCrewAI
|
|
|
80
87
|
line_count.times do
|
|
81
88
|
line = file.gets
|
|
82
89
|
break unless line
|
|
90
|
+
|
|
83
91
|
lines << line.chomp
|
|
84
92
|
end
|
|
85
93
|
end
|
|
@@ -95,17 +103,17 @@ module RCrewAI
|
|
|
95
103
|
header += " (first #{lines} lines)" if lines
|
|
96
104
|
header += "\nSize: #{path.size} bytes"
|
|
97
105
|
header += "\nModified: #{path.mtime.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
98
|
-
header += "\n
|
|
99
|
-
|
|
106
|
+
header += "\n#{'=' * 50}\n"
|
|
107
|
+
|
|
100
108
|
# Truncate very long content for display
|
|
101
109
|
display_content = if content.length > 5000
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
"#{content[0..4997]}..."
|
|
111
|
+
else
|
|
112
|
+
content
|
|
113
|
+
end
|
|
114
|
+
|
|
107
115
|
header + display_content
|
|
108
116
|
end
|
|
109
117
|
end
|
|
110
118
|
end
|
|
111
|
-
end
|
|
119
|
+
end
|
|
@@ -7,26 +7,33 @@ require 'fileutils'
|
|
|
7
7
|
module RCrewAI
|
|
8
8
|
module Tools
|
|
9
9
|
class FileWriter < Base
|
|
10
|
+
tool_name 'file_writer'
|
|
11
|
+
description 'Write content to a text file on disk'
|
|
12
|
+
param :file_path, type: :string, required: true, description: 'Path to write to'
|
|
13
|
+
param :content, type: :string, required: true, description: 'Content to write'
|
|
14
|
+
param :mode, type: :enum, default: 'w', values: %w[w a],
|
|
15
|
+
description: "Write mode: 'w' to overwrite, 'a' to append"
|
|
16
|
+
param :encoding, type: :string, default: 'utf-8',
|
|
17
|
+
description: 'Text encoding'
|
|
18
|
+
|
|
10
19
|
def initialize(**options)
|
|
11
20
|
super()
|
|
12
|
-
@name = 'filewriter'
|
|
13
|
-
@description = 'Write content to files'
|
|
14
21
|
@max_file_size = options.fetch(:max_file_size, 10_000_000) # 10MB
|
|
15
22
|
@allowed_extensions = options.fetch(:allowed_extensions, %w[.txt .md .json .yaml .yml .csv .log])
|
|
16
23
|
@create_directories = options.fetch(:create_directories, true)
|
|
17
24
|
end
|
|
18
25
|
|
|
19
26
|
def execute(**params)
|
|
20
|
-
validate_params!(params, required: [
|
|
21
|
-
|
|
27
|
+
validate_params!(params, required: %i[file_path content], optional: %i[mode encoding])
|
|
28
|
+
|
|
22
29
|
file_path = params[:file_path]
|
|
23
30
|
content = params[:content]
|
|
24
|
-
mode = params[:mode] || 'w'
|
|
31
|
+
mode = params[:mode] || 'w' # 'w' for write, 'a' for append
|
|
25
32
|
encoding = params[:encoding] || 'utf-8'
|
|
26
|
-
|
|
33
|
+
|
|
27
34
|
begin
|
|
28
35
|
write_file(file_path, content, mode, encoding)
|
|
29
|
-
rescue => e
|
|
36
|
+
rescue StandardError => e
|
|
30
37
|
"File write failed: #{e.message}"
|
|
31
38
|
end
|
|
32
39
|
end
|
|
@@ -35,20 +42,20 @@ module RCrewAI
|
|
|
35
42
|
|
|
36
43
|
def write_file(file_path, content, mode, encoding)
|
|
37
44
|
path = Pathname.new(file_path)
|
|
38
|
-
|
|
45
|
+
|
|
39
46
|
# Security checks
|
|
40
47
|
validate_file_path!(path)
|
|
41
48
|
validate_content!(content)
|
|
42
49
|
validate_mode!(mode)
|
|
43
|
-
|
|
50
|
+
|
|
44
51
|
# Create directory if needed
|
|
45
52
|
create_parent_directories!(path) if @create_directories
|
|
46
|
-
|
|
53
|
+
|
|
47
54
|
# Write the file
|
|
48
55
|
File.open(path, mode, encoding: encoding) do |file|
|
|
49
56
|
file.write(content)
|
|
50
57
|
end
|
|
51
|
-
|
|
58
|
+
|
|
52
59
|
format_write_result(path, content, mode)
|
|
53
60
|
end
|
|
54
61
|
|
|
@@ -56,60 +63,55 @@ module RCrewAI
|
|
|
56
63
|
# Prevent directory traversal
|
|
57
64
|
resolved_path = path.expand_path.to_s
|
|
58
65
|
working_dir = Dir.pwd
|
|
59
|
-
unless resolved_path.start_with?(working_dir)
|
|
60
|
-
|
|
61
|
-
end
|
|
62
|
-
|
|
66
|
+
raise ToolError, 'Access denied: file outside working directory' unless resolved_path.start_with?(working_dir)
|
|
67
|
+
|
|
63
68
|
# Check file extension
|
|
64
69
|
extension = path.extname.downcase
|
|
65
70
|
unless @allowed_extensions.include?(extension) || @allowed_extensions.include?('*')
|
|
66
71
|
raise ToolError, "File type not allowed: #{extension}"
|
|
67
72
|
end
|
|
68
|
-
|
|
73
|
+
|
|
69
74
|
# If file exists, check if it's writable
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
end
|
|
75
|
+
return unless path.exist?
|
|
76
|
+
raise ToolError, "Path is not a file: #{path}" unless path.file?
|
|
77
|
+
raise ToolError, "File is not writable: #{path}" unless path.writable?
|
|
74
78
|
end
|
|
75
79
|
|
|
76
80
|
def validate_content!(content)
|
|
77
|
-
raise ToolError,
|
|
78
|
-
|
|
81
|
+
raise ToolError, 'Content cannot be nil' if content.nil?
|
|
82
|
+
|
|
79
83
|
content_size = content.bytesize
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
return unless content_size > @max_file_size
|
|
85
|
+
|
|
86
|
+
raise ToolError, "Content too large: #{content_size} bytes (max: #{@max_file_size})"
|
|
83
87
|
end
|
|
84
88
|
|
|
85
89
|
def validate_mode!(mode)
|
|
86
90
|
valid_modes = %w[w a w+ a+ wb ab]
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
return if valid_modes.include?(mode)
|
|
92
|
+
|
|
93
|
+
raise ToolError, "Invalid file mode: #{mode}. Valid modes: #{valid_modes.join(', ')}"
|
|
90
94
|
end
|
|
91
95
|
|
|
92
96
|
def create_parent_directories!(path)
|
|
93
97
|
parent_dir = path.parent
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
return if parent_dir.exist?
|
|
99
|
+
|
|
100
|
+
FileUtils.mkdir_p(parent_dir)
|
|
97
101
|
end
|
|
98
102
|
|
|
99
103
|
def format_write_result(path, content, mode)
|
|
100
104
|
action = mode.start_with?('a') ? 'appended to' : 'written to'
|
|
101
105
|
size = content.bytesize
|
|
102
|
-
|
|
106
|
+
|
|
103
107
|
result = "Content successfully #{action} #{path.basename}\n"
|
|
104
108
|
result += "File size: #{size} bytes\n"
|
|
105
109
|
result += "Full path: #{path.expand_path}\n"
|
|
106
|
-
|
|
107
|
-
if path.exist?
|
|
108
|
-
|
|
109
|
-
end
|
|
110
|
-
|
|
110
|
+
|
|
111
|
+
result += "File modified: #{path.mtime.strftime('%Y-%m-%d %H:%M:%S')}" if path.exist?
|
|
112
|
+
|
|
111
113
|
result
|
|
112
114
|
end
|
|
113
115
|
end
|
|
114
116
|
end
|
|
115
|
-
end
|
|
117
|
+
end
|