rubyn 0.1.5 → 0.1.6
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/lib/rubyn/client/api_client.rb +63 -6
- data/lib/rubyn/commands/init.rb +34 -0
- data/lib/rubyn/context/response_parser.rb +8 -6
- data/lib/rubyn/tools/base_tool.rb +49 -0
- data/lib/rubyn/tools/executor.rb +14 -1
- data/lib/rubyn/tools/find_files.rb +1 -0
- data/lib/rubyn/tools/find_references.rb +1 -0
- data/lib/rubyn/tools/read_file.rb +5 -1
- data/lib/rubyn/tools/search_files.rb +2 -0
- data/lib/rubyn/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f569a00574395ca84d1262fe5e1b1e43ae3a9f45cbad8bcd635b5f77bc8f964
|
|
4
|
+
data.tar.gz: b649ccb9d369f8d043c7894a2e203aa2de135b5696f0ba3385d58d9cf104595d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fba7fe70671bbd022cee098572ce9afa1fe5e936983321a7ab04dde76ef088014ac5adad49baa5d9cdb3ea3ee0aa107a36ce5f44c91113dfdc8643fac799c3d6
|
|
7
|
+
data.tar.gz: d64d44658a121a84810c8c659ecf94b71310b6a5f5d88a6954b5301869f2422a2b968001b1fd8612b664b892426fa8b6d769a5eeb15e61610f4724d7ac63f887
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "faraday"
|
|
4
4
|
require "faraday/multipart"
|
|
5
5
|
require "json"
|
|
6
|
+
require "securerandom"
|
|
6
7
|
|
|
7
8
|
module Rubyn
|
|
8
9
|
module Client
|
|
@@ -19,7 +20,7 @@ module Rubyn
|
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def refactor(file_path:, code:, context_files:, project_token:)
|
|
22
|
-
|
|
23
|
+
post_with_recovery("/api/v1/ai/refactor", {
|
|
23
24
|
file_path: file_path,
|
|
24
25
|
code: code,
|
|
25
26
|
context_files: context_files,
|
|
@@ -28,7 +29,7 @@ module Rubyn
|
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
def generate_spec(file_path:, code:, context_files:, project_token:)
|
|
31
|
-
|
|
32
|
+
post_with_recovery("/api/v1/ai/spec", {
|
|
32
33
|
file_path: file_path,
|
|
33
34
|
code: code,
|
|
34
35
|
context_files: context_files,
|
|
@@ -37,7 +38,7 @@ module Rubyn
|
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def review(files:, context_files:, project_token:)
|
|
40
|
-
|
|
41
|
+
post_with_recovery("/api/v1/ai/review", {
|
|
41
42
|
files: files,
|
|
42
43
|
context_files: context_files,
|
|
43
44
|
project_token: project_token
|
|
@@ -45,7 +46,7 @@ module Rubyn
|
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def agent_message(conversation_id:, message:, file_context:, project_token:)
|
|
48
|
-
|
|
49
|
+
post_with_recovery("/api/v1/ai/agent", {
|
|
49
50
|
conversation_id: conversation_id,
|
|
50
51
|
message: message,
|
|
51
52
|
file_context: file_context,
|
|
@@ -99,6 +100,10 @@ module Rubyn
|
|
|
99
100
|
get("/api/v1/usage/history", page: page)
|
|
100
101
|
end
|
|
101
102
|
|
|
103
|
+
def fetch_interaction(request_uuid:)
|
|
104
|
+
get("/api/v1/ai/interactions/#{request_uuid}")
|
|
105
|
+
end
|
|
106
|
+
|
|
102
107
|
def submit_feedback(interaction_id:, rating:, feedback: nil)
|
|
103
108
|
post("/api/v1/feedback", {
|
|
104
109
|
interaction_id: interaction_id,
|
|
@@ -128,17 +133,69 @@ module Rubyn
|
|
|
128
133
|
def get(path, params = {})
|
|
129
134
|
response = connection.get(path, params)
|
|
130
135
|
parse_response(response)
|
|
136
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
137
|
+
raise APIError, "Could not connect to #{@base_url} — #{e.message}"
|
|
131
138
|
end
|
|
132
139
|
|
|
133
140
|
def post(path, body = {})
|
|
134
141
|
response = connection.post(path, body)
|
|
135
142
|
parse_response(response)
|
|
143
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
144
|
+
raise APIError, "Could not connect to #{@base_url} — #{e.message}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def post_with_recovery(path, body = {})
|
|
148
|
+
request_uuid = SecureRandom.uuid
|
|
149
|
+
body[:request_uuid] = request_uuid
|
|
150
|
+
|
|
151
|
+
response = connection.post(path, body)
|
|
152
|
+
parse_response(response)
|
|
153
|
+
rescue Faraday::TimeoutError
|
|
154
|
+
recover_response(request_uuid)
|
|
155
|
+
rescue Faraday::ConnectionFailed => e
|
|
156
|
+
raise APIError, "Could not connect to #{@base_url} — #{e.message}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
RECOVERY_MESSAGES = [
|
|
160
|
+
"Still polishing those gems...",
|
|
161
|
+
"Matz would want us to be patient...",
|
|
162
|
+
"DHH didn't build Rails in a day...",
|
|
163
|
+
"Optimizing for developer happiness...",
|
|
164
|
+
"Almost there, just one more .each...",
|
|
165
|
+
"Your code is worth the wait..."
|
|
166
|
+
].freeze
|
|
167
|
+
|
|
168
|
+
def recover_response(request_uuid)
|
|
169
|
+
Rubyn::Output::Formatter.newline
|
|
170
|
+
Rubyn::Output::Formatter.warning("Hang tight! Your response is still cooking...")
|
|
171
|
+
|
|
172
|
+
RECOVERY_MESSAGES.each do |message|
|
|
173
|
+
sleep 5
|
|
174
|
+
begin
|
|
175
|
+
result = fetch_interaction(request_uuid: request_uuid)
|
|
176
|
+
if result && result["response"].to_s.length > 0
|
|
177
|
+
Rubyn::Output::Formatter.success("Got it!")
|
|
178
|
+
Rubyn::Output::Formatter.newline
|
|
179
|
+
return result
|
|
180
|
+
end
|
|
181
|
+
rescue APIError => e
|
|
182
|
+
break if e.message.include?("not found")
|
|
183
|
+
end
|
|
184
|
+
Rubyn::Output::Formatter.info(message)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
raise APIError, "Request timed out and the response could not be recovered. Your credits were not charged."
|
|
136
188
|
end
|
|
137
189
|
|
|
138
190
|
def parse_response(response)
|
|
139
|
-
|
|
191
|
+
json_body = response.body.is_a?(Hash) || response.body.is_a?(Array)
|
|
192
|
+
|
|
193
|
+
if response.status.between?(200, 299)
|
|
194
|
+
raise APIError, "Unexpected response from server (status #{response.status}). Expected JSON but got #{response.headers["content-type"] || "unknown content type"}." unless json_body
|
|
195
|
+
return response.body
|
|
196
|
+
end
|
|
140
197
|
|
|
141
|
-
message = extract_error_message(response)
|
|
198
|
+
message = json_body ? extract_error_message(response) : nil
|
|
142
199
|
|
|
143
200
|
case response.status
|
|
144
201
|
when 400
|
data/lib/rubyn/commands/init.rb
CHANGED
|
@@ -114,9 +114,43 @@ module Rubyn
|
|
|
114
114
|
"project_token" => project.fetch("project_token"),
|
|
115
115
|
"project_id" => project.fetch("id")
|
|
116
116
|
})
|
|
117
|
+
write_default_security_config
|
|
117
118
|
ensure_gitignore
|
|
118
119
|
end
|
|
119
120
|
|
|
121
|
+
def write_default_security_config
|
|
122
|
+
path = File.join(Dir.pwd, ".rubyn", "security.yml")
|
|
123
|
+
return if File.exist?(path)
|
|
124
|
+
|
|
125
|
+
content = <<~YAML
|
|
126
|
+
# Rubyn Security Configuration
|
|
127
|
+
#
|
|
128
|
+
# blocked_files: Files the agent can NEVER read or write.
|
|
129
|
+
# These are hard-blocked — no confirmation prompt, just denied.
|
|
130
|
+
#
|
|
131
|
+
# sensitive_files: Files the agent can read ONLY with your approval.
|
|
132
|
+
# You'll be prompted before the agent can access these.
|
|
133
|
+
#
|
|
134
|
+
# Patterns support exact paths and directory prefixes:
|
|
135
|
+
# - config/master.key (exact file)
|
|
136
|
+
# - config/credentials (entire directory)
|
|
137
|
+
# - .env.* (glob pattern)
|
|
138
|
+
|
|
139
|
+
blocked_files:
|
|
140
|
+
- config/master.key
|
|
141
|
+
- config/credentials.yml.enc
|
|
142
|
+
- config/credentials
|
|
143
|
+
|
|
144
|
+
sensitive_files:
|
|
145
|
+
- .env
|
|
146
|
+
- .env.*
|
|
147
|
+
- config/database.yml
|
|
148
|
+
- config/secrets.yml
|
|
149
|
+
YAML
|
|
150
|
+
|
|
151
|
+
File.write(path, content)
|
|
152
|
+
end
|
|
153
|
+
|
|
120
154
|
def ensure_gitignore
|
|
121
155
|
gitignore_path = File.join(Dir.pwd, ".gitignore")
|
|
122
156
|
return unless File.exist?(gitignore_path)
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
module Rubyn
|
|
4
4
|
module Context
|
|
5
5
|
class ResponseParser
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
FILE_EXT = /[a-zA-Z0-9_\/\.\-]+\.[a-zA-Z0-9\.]+/
|
|
7
|
+
BOLD_HEADER = /\*\*(New|Updated|Modified)\s*(?:file)?:\s*(#{FILE_EXT})\*\*/i
|
|
8
|
+
BACKTICK_PATH = /`(#{FILE_EXT})`\s*\z/
|
|
9
|
+
INLINE_COMMENT = /^(?:#|<%#)\s*(#{FILE_EXT})/
|
|
10
|
+
CODE_FENCE = /```\w+\n.*?```/m
|
|
9
11
|
|
|
10
12
|
def self.extract_file_blocks(response)
|
|
11
13
|
new(response).extract
|
|
@@ -17,12 +19,12 @@ module Rubyn
|
|
|
17
19
|
|
|
18
20
|
def extract
|
|
19
21
|
blocks = []
|
|
20
|
-
parts = @response.split(/(
|
|
22
|
+
parts = @response.split(/(#{CODE_FENCE})/m)
|
|
21
23
|
|
|
22
24
|
parts.each_with_index do |part, i|
|
|
23
|
-
next unless part.
|
|
25
|
+
next unless part.match?(/\A```\w+\n/)
|
|
24
26
|
|
|
25
|
-
code = part.sub(/\A
|
|
27
|
+
code = part.sub(/\A```\w+\n/, "").sub(/```\z/, "")
|
|
26
28
|
preceding = i > 0 ? parts[i - 1] : ""
|
|
27
29
|
result = detect_path(preceding, code)
|
|
28
30
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
3
5
|
module Rubyn
|
|
4
6
|
module Tools
|
|
5
7
|
class BaseTool
|
|
@@ -38,6 +40,18 @@ module Rubyn
|
|
|
38
40
|
EXCLUDED_DIRS = %w[.git node_modules vendor/bundle vendor tmp].freeze
|
|
39
41
|
DEFAULT_MAX_OUTPUT_LENGTH = 10_000
|
|
40
42
|
|
|
43
|
+
BLOCKED_FILES = %w[
|
|
44
|
+
config/master.key
|
|
45
|
+
config/credentials.yml.enc
|
|
46
|
+
config/credentials
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
SENSITIVE_FILES = %w[
|
|
50
|
+
.env
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
SECURITY_CONFIG_FILE = File.join(".rubyn", "security.yml").freeze
|
|
54
|
+
|
|
41
55
|
private
|
|
42
56
|
|
|
43
57
|
def excluded?(path)
|
|
@@ -55,9 +69,44 @@ module Rubyn
|
|
|
55
69
|
expanded = File.expand_path(path, project_root)
|
|
56
70
|
return error("Access denied: path is outside project root") unless expanded.start_with?(project_root)
|
|
57
71
|
|
|
72
|
+
rel = expanded.sub("#{project_root}/", "")
|
|
73
|
+
return error("Access denied: #{rel} is a protected file") if blocked_file?(rel)
|
|
74
|
+
|
|
58
75
|
expanded
|
|
59
76
|
end
|
|
60
77
|
|
|
78
|
+
def blocked_file?(relative_path)
|
|
79
|
+
all_blocked = BLOCKED_FILES + security_config("blocked_files")
|
|
80
|
+
matches_any?(relative_path, all_blocked)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def sensitive_file?(relative_path)
|
|
84
|
+
all_sensitive = SENSITIVE_FILES + security_config("sensitive_files")
|
|
85
|
+
matches_any?(relative_path, all_sensitive)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def matches_any?(relative_path, patterns)
|
|
89
|
+
patterns.any? do |pattern|
|
|
90
|
+
relative_path == pattern ||
|
|
91
|
+
relative_path.start_with?("#{pattern}/") ||
|
|
92
|
+
File.fnmatch?(pattern, relative_path, File::FNM_PATHNAME)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def security_config(key)
|
|
97
|
+
@security_config ||= load_security_config
|
|
98
|
+
@security_config.fetch(key, [])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def load_security_config
|
|
102
|
+
path = File.join(project_root, SECURITY_CONFIG_FILE)
|
|
103
|
+
return {} unless File.exist?(path)
|
|
104
|
+
|
|
105
|
+
YAML.safe_load_file(path) || {}
|
|
106
|
+
rescue Psych::SyntaxError
|
|
107
|
+
{}
|
|
108
|
+
end
|
|
109
|
+
|
|
61
110
|
def relative_path(full_path)
|
|
62
111
|
full_path.sub("#{project_root}/", "")
|
|
63
112
|
end
|
data/lib/rubyn/tools/executor.rb
CHANGED
|
@@ -28,7 +28,20 @@ module Rubyn
|
|
|
28
28
|
return { success: false, error: "denied_by_user" }
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
tool.call(params)
|
|
31
|
+
result = tool.call(params)
|
|
32
|
+
|
|
33
|
+
if result.is_a?(Hash) && result[:error].to_s.start_with?("sensitive_file:")
|
|
34
|
+
file = result[:error].sub("sensitive_file:", "")
|
|
35
|
+
Rubyn::Output::Formatter.warning("Agent wants to read sensitive file: #{file}")
|
|
36
|
+
print "Allow? (y/n) "
|
|
37
|
+
if $stdin.gets&.strip&.downcase == "y"
|
|
38
|
+
tool.call(params.merge(skip_sensitive_check: true))
|
|
39
|
+
else
|
|
40
|
+
{ success: false, error: "denied_by_user" }
|
|
41
|
+
end
|
|
42
|
+
else
|
|
43
|
+
result
|
|
44
|
+
end
|
|
32
45
|
rescue StandardError => e
|
|
33
46
|
{ success: false, error: "#{e.class}: #{e.message}" }
|
|
34
47
|
end
|
|
@@ -19,9 +19,13 @@ module Rubyn
|
|
|
19
19
|
return resolved if resolved.is_a?(Hash) && resolved[:error]
|
|
20
20
|
|
|
21
21
|
return error("File not found: #{params[:path]}") unless File.exist?(resolved)
|
|
22
|
-
|
|
23
22
|
return error("Not a file: #{params[:path]}") unless File.file?(resolved)
|
|
24
23
|
|
|
24
|
+
rel = relative_path(resolved)
|
|
25
|
+
if sensitive_file?(rel) && !params[:skip_sensitive_check]
|
|
26
|
+
return error("sensitive_file:#{params[:path]}")
|
|
27
|
+
end
|
|
28
|
+
|
|
25
29
|
lines = File.readlines(resolved)
|
|
26
30
|
total_lines = lines.length
|
|
27
31
|
|
|
@@ -39,6 +39,8 @@ module Rubyn
|
|
|
39
39
|
break if matches.size >= MAX_MATCHES
|
|
40
40
|
next unless File.file?(file)
|
|
41
41
|
next if excluded?(file)
|
|
42
|
+
next if blocked_file?(relative_path(file))
|
|
43
|
+
next if sensitive_file?(relative_path(file))
|
|
42
44
|
next if binary?(file)
|
|
43
45
|
|
|
44
46
|
search_in_file(file, regex, matches)
|
data/lib/rubyn/version.rb
CHANGED