yatfa 1.0.65
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.
- package/LICENSE +21 -0
- package/README.md +252 -0
- package/agents.rb +198 -0
- package/bin/agent-bridge-tmux +63 -0
- package/bin/install-agent.sh +144 -0
- package/bin/run-yatfa-agent.rb +69 -0
- package/lib/yatfa_agent/agent.rb +289 -0
- package/lib/yatfa_agent/config.rb +164 -0
- package/lib/yatfa_agent/docker.rb +72 -0
- package/lib/yatfa_agent/setup.rb +601 -0
- package/package.json +18 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# YATFA Agent Runner (Yet Another Tool For Agents)
|
|
5
|
+
# Usage: npx yatfa-agent [worker|planner|reviewer|researcher]
|
|
6
|
+
# npx yatfa-agent attach [agent-type]
|
|
7
|
+
# npx yatfa-agent setup
|
|
8
|
+
#
|
|
9
|
+
# Requirements:
|
|
10
|
+
# - Docker
|
|
11
|
+
# - Ruby
|
|
12
|
+
# - Dockerfile.sandbox in project root
|
|
13
|
+
# - yatfa.env.rb in project root (gitignored)
|
|
14
|
+
|
|
15
|
+
require_relative "../lib/yatfa_agent/config"
|
|
16
|
+
require_relative "../lib/yatfa_agent/docker"
|
|
17
|
+
require_relative "../lib/yatfa_agent/agent"
|
|
18
|
+
require_relative "../lib/yatfa_agent/setup"
|
|
19
|
+
require_relative "../agents"
|
|
20
|
+
|
|
21
|
+
def show_usage
|
|
22
|
+
puts "YATFA Agent Runner (Yet Another Tool For Agents)"
|
|
23
|
+
puts ""
|
|
24
|
+
puts "Usage:"
|
|
25
|
+
puts " npx yatfa-agent setup # Interactive setup wizard"
|
|
26
|
+
puts " npx yatfa-agent [worker|planner|reviewer|researcher] # Run agent"
|
|
27
|
+
puts " npx yatfa-agent attach [agent-type] # Attach to running agent"
|
|
28
|
+
puts ""
|
|
29
|
+
puts "Setup:"
|
|
30
|
+
puts " Run 'npx yatfa-agent setup' for interactive configuration"
|
|
31
|
+
puts ""
|
|
32
|
+
puts "Manual setup:"
|
|
33
|
+
puts " 1. Create Dockerfile.sandbox (see https://github.com/RoM4iK/yatfa-public/blob/main/README.md)"
|
|
34
|
+
puts " 2. Create yatfa.env.rb (see https://github.com/RoM4iK/yatfa-public/blob/main/README.md)"
|
|
35
|
+
puts " 3. echo 'yatfa.env.rb' >> .gitignore"
|
|
36
|
+
puts " 4. npx yatfa-agent worker"
|
|
37
|
+
exit 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Main
|
|
41
|
+
show_usage if ARGV.empty?
|
|
42
|
+
|
|
43
|
+
command = ARGV[0].downcase
|
|
44
|
+
|
|
45
|
+
# Handle setup command
|
|
46
|
+
if command == "setup"
|
|
47
|
+
wizard = YatfaAgent::Setup::SetupWizard.new
|
|
48
|
+
wizard.run
|
|
49
|
+
exit 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Handle attach command
|
|
53
|
+
if command == "attach"
|
|
54
|
+
agent_type = ARGV[1]&.downcase
|
|
55
|
+
abort "Usage: npx yatfa-agent attach [agent-type]" unless agent_type
|
|
56
|
+
config = YatfaAgent::Config.load
|
|
57
|
+
YatfaAgent::Config.fetch_repositories!(config)
|
|
58
|
+
YatfaAgent::Agent.attach(agent_type, config, AGENT_CONFIGS)
|
|
59
|
+
else
|
|
60
|
+
# Handle run commands (worker, planner, reviewer, researcher)
|
|
61
|
+
config = YatfaAgent::Config.load
|
|
62
|
+
# Fetch repositories from Rails API before building
|
|
63
|
+
YatfaAgent::Config.fetch_repositories!(config)
|
|
64
|
+
YatfaAgent::Docker.build_image(config)
|
|
65
|
+
# For "run" command, use second argument as agent type
|
|
66
|
+
# For "attach" command, handled separately above
|
|
67
|
+
agent_type_to_run = (command == "run") ? ARGV[1] : command
|
|
68
|
+
YatfaAgent::Agent.run(agent_type_to_run, config, AGENT_CONFIGS)
|
|
69
|
+
end
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
|
|
4
|
+
module YatfaAgent
|
|
5
|
+
module Agent
|
|
6
|
+
def self.run(agent_type, config, agent_configs)
|
|
7
|
+
unless agent_configs.keys.include?(agent_type)
|
|
8
|
+
puts "โ Unknown agent type: #{agent_type}"
|
|
9
|
+
puts " Available: #{agent_configs.keys.join(', ')}"
|
|
10
|
+
exit 1
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
agent_def = agent_configs[agent_type]
|
|
14
|
+
agent_config = config.dig("agents", agent_type) || {}
|
|
15
|
+
container_name = agent_config["container_name"] || agent_def[:name]
|
|
16
|
+
|
|
17
|
+
puts "๐ Starting #{agent_type} agent..."
|
|
18
|
+
|
|
19
|
+
# Stop existing container if running
|
|
20
|
+
system("docker", "rm", "-f", container_name, err: File::NULL, out: File::NULL)
|
|
21
|
+
|
|
22
|
+
# Write banner to a persistent temp file (not auto-deleted)
|
|
23
|
+
banner_path = "/tmp/yatfa-agent-banner-#{agent_type}.txt"
|
|
24
|
+
FileUtils.rm_rf(banner_path)
|
|
25
|
+
|
|
26
|
+
# Append repository context to banner
|
|
27
|
+
banner_content = agent_def[:banner].dup
|
|
28
|
+
if config["repositories"]&.any?
|
|
29
|
+
sorted_repos = config["repositories"].sort_by { |r| r["position"] || 0 }
|
|
30
|
+
repo_info = sorted_repos.map do |repo|
|
|
31
|
+
name = repo["name"]
|
|
32
|
+
path = repo["path_in_sandbox"] || "/workspace/#{name}"
|
|
33
|
+
" - **#{name}**: #{path}"
|
|
34
|
+
end.join("\n")
|
|
35
|
+
|
|
36
|
+
banner_content += "\n\n## REPOSITORY STRUCTURE\n\n"
|
|
37
|
+
banner_content += "This project has #{sorted_repos.count} repositories:\n\n"
|
|
38
|
+
banner_content += "#{repo_info}\n\n"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
File.write(banner_path, banner_content)
|
|
42
|
+
|
|
43
|
+
docker_cmd = build_docker_command(container_name, agent_type, config, agent_config, agent_def, banner_path)
|
|
44
|
+
|
|
45
|
+
success = system(*docker_cmd)
|
|
46
|
+
|
|
47
|
+
if success
|
|
48
|
+
puts "โ
Agent started in background"
|
|
49
|
+
puts ""
|
|
50
|
+
puts " Attach: npx yatfa-agent attach #{agent_type}"
|
|
51
|
+
puts " Logs: docker logs -f #{container_name}"
|
|
52
|
+
puts " Stop: docker stop #{container_name}"
|
|
53
|
+
else
|
|
54
|
+
puts "โ Failed to start agent"
|
|
55
|
+
exit 1
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.attach(agent_type, config, agent_configs)
|
|
60
|
+
unless agent_configs.keys.include?(agent_type)
|
|
61
|
+
puts "โ Unknown agent type: #{agent_type}"
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
agent_def = agent_configs[agent_type]
|
|
66
|
+
agent_config = config.dig("agents", agent_type) || {}
|
|
67
|
+
container_name = agent_config["container_name"] || agent_def[:name]
|
|
68
|
+
|
|
69
|
+
running = `docker ps --filter name=^#{container_name}$ --format '{{.Names}}'`.strip
|
|
70
|
+
|
|
71
|
+
if running.empty?
|
|
72
|
+
puts "โ ๏ธ #{agent_type} agent is not running. Auto-starting..."
|
|
73
|
+
Docker.build_image(config)
|
|
74
|
+
run(agent_type, config, agent_configs)
|
|
75
|
+
sleep 3
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
puts "๐ Attaching to #{agent_type} agent..."
|
|
79
|
+
|
|
80
|
+
user = detect_agent_user(container_name)
|
|
81
|
+
puts " User: #{user}"
|
|
82
|
+
|
|
83
|
+
# Wait for tmux session to be ready
|
|
84
|
+
10.times do
|
|
85
|
+
if system("docker", "exec", "-u", user, container_name, "tmux", "has-session", "-t", "agent", err: File::NULL, out: File::NULL)
|
|
86
|
+
break
|
|
87
|
+
end
|
|
88
|
+
sleep 1
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Attach to agent session which has the status bar
|
|
92
|
+
# Must run as agent user since tmux server runs under that user
|
|
93
|
+
exec("docker", "exec", "-it", "-u", user, container_name, "tmux", "attach", "-t", "agent")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def self.build_docker_command(container_name, agent_type, config, agent_config, agent_def, banner_path)
|
|
99
|
+
docker_cmd = [
|
|
100
|
+
"docker", "run", "-d",
|
|
101
|
+
"--name", container_name,
|
|
102
|
+
"--network", "host",
|
|
103
|
+
"--restart", "unless-stopped",
|
|
104
|
+
"--tmpfs", "/rails/tmp",
|
|
105
|
+
"--tmpfs", "/rails/log"
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
# Inject custom environment variables from config
|
|
109
|
+
# Merge global env with agent-specific env (agent-specific takes precedence)
|
|
110
|
+
merged_env = {}
|
|
111
|
+
merged_env.merge!(config["env"]) if config["env"]
|
|
112
|
+
merged_env.merge!(agent_config["env"]) if agent_config["env"]
|
|
113
|
+
|
|
114
|
+
if merged_env.any?
|
|
115
|
+
merged_env.each do |k, v|
|
|
116
|
+
docker_cmd += ["-e", "#{k}=#{v}"]
|
|
117
|
+
end
|
|
118
|
+
global_count = config["env"]&.size || 0
|
|
119
|
+
agent_count = agent_config["env"]&.size || 0
|
|
120
|
+
if agent_count > 0
|
|
121
|
+
puts "๐ฟ Injected #{global_count} global + #{agent_count} agent-specific env vars"
|
|
122
|
+
else
|
|
123
|
+
puts "๐ฟ Injected #{global_count} custom env vars from config"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
docker_cmd += [
|
|
128
|
+
"-v", "#{banner_path}:/etc/yatfa/system-prompt.txt:ro",
|
|
129
|
+
"-e", "YATFA_VERSION=main",
|
|
130
|
+
"-e", "SKILLS=#{agent_def[:skills]&.join(',')}",
|
|
131
|
+
"-e", "AGENT_TYPE=#{agent_type}",
|
|
132
|
+
"-e", "YATFA_URL=#{YatfaAgent::Config.yatfa_url(config)}",
|
|
133
|
+
"-e", "YATFA_API_KEY=#{agent_config['mcp_api_key']}"
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
add_github_auth!(docker_cmd, config)
|
|
137
|
+
add_git_config!(docker_cmd, config)
|
|
138
|
+
add_repository_mounts!(docker_cmd, config)
|
|
139
|
+
add_development_mounts!(docker_cmd)
|
|
140
|
+
|
|
141
|
+
local_setup_script = File.join(File.dirname(__FILE__), "..", "..", "setup-agent.rb")
|
|
142
|
+
|
|
143
|
+
if File.exist?(local_setup_script)
|
|
144
|
+
docker_cmd += [Config.image_name(config), "ruby", "/tmp/setup-agent.rb"]
|
|
145
|
+
else
|
|
146
|
+
docker_cmd += [Config.image_name(config)]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
docker_cmd
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def self.add_github_auth!(docker_cmd, config)
|
|
153
|
+
github = config["github"] || {}
|
|
154
|
+
|
|
155
|
+
# New method: GitHub authentication is handled by the backend
|
|
156
|
+
# Agents get tokens via YATFA_API_KEY using the /github/token endpoint
|
|
157
|
+
# No GitHub credentials need to be passed into the container
|
|
158
|
+
|
|
159
|
+
# Deprecation warning for old GitHub App config
|
|
160
|
+
if github["method"] == "app" || github["app_client_id"] || github["app_installation_id"]
|
|
161
|
+
puts "โ ๏ธ Warning: GitHub App configuration in yatfa.env.rb is deprecated"
|
|
162
|
+
puts " GitHub credentials are now managed centrally in the Rails backend"
|
|
163
|
+
puts " You can remove the 'github' section from yatfa.env.rb"
|
|
164
|
+
puts " The backend will use its own GitHub App credentials to generate tokens"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Optional: Support GH_TOKEN fallback for development/testing
|
|
168
|
+
if github["token"]
|
|
169
|
+
docker_cmd.concat(["-e", "GH_TOKEN=#{github['token']}"])
|
|
170
|
+
puts "๐ Using GitHub token authentication (fallback mode)"
|
|
171
|
+
else
|
|
172
|
+
puts "โ
GitHub authentication will be handled by backend (YATFA_API_KEY)"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.add_git_config!(docker_cmd, config)
|
|
177
|
+
return unless (git_config = config["git"])
|
|
178
|
+
|
|
179
|
+
docker_cmd.concat(["-e", "GIT_USER_NAME=#{git_config['user_name']}"]) if git_config["user_name"]
|
|
180
|
+
docker_cmd.concat(["-e", "GIT_USER_EMAIL=#{git_config['user_email']}"]) if git_config["user_email"]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def self.add_repository_mounts!(docker_cmd, config)
|
|
184
|
+
return unless config["repositories"].is_a?(Array) && config["repositories"].any?
|
|
185
|
+
|
|
186
|
+
puts "๐ Configuring #{config['repositories'].count} repositories..."
|
|
187
|
+
|
|
188
|
+
# Build REPOSITORIES_CONTEXT for sessionstart hook (format: "name:path,name:path")
|
|
189
|
+
sorted_repos = config["repositories"].sort_by { |r| r["position"] || 0 }
|
|
190
|
+
repos_context = sorted_repos.map do |repo|
|
|
191
|
+
repo_name = repo["name"]
|
|
192
|
+
sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
|
|
193
|
+
"#{repo_name}:#{sandbox_path}"
|
|
194
|
+
end.join(",")
|
|
195
|
+
|
|
196
|
+
docker_cmd.concat(["-e", "REPOSITORIES_CONTEXT=#{repos_context}"])
|
|
197
|
+
|
|
198
|
+
# Pass repository config via environment variables for container to clone
|
|
199
|
+
repo_config_json = sorted_repos.to_json
|
|
200
|
+
docker_cmd.concat(["-e", "REPOSITORIES_CONFIG=#{repo_config_json}"])
|
|
201
|
+
|
|
202
|
+
sorted_repos.each do |repo|
|
|
203
|
+
repo_name = repo["name"]
|
|
204
|
+
sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
|
|
205
|
+
puts " - #{repo_name}: #{sandbox_path} (will be cloned in container)"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.add_development_mounts!(docker_cmd)
|
|
210
|
+
# Check for local setup-agent.rb (for development)
|
|
211
|
+
local_setup_script = File.join(File.dirname(__FILE__), "..", "..", "setup-agent.rb")
|
|
212
|
+
|
|
213
|
+
# Check for local agent-bridge binaries (for development)
|
|
214
|
+
arch = `uname -m`.strip
|
|
215
|
+
linux_arch = (arch == "x86_64") ? "amd64" : "arm64"
|
|
216
|
+
|
|
217
|
+
# Try multiple possible paths for the binary (built Go binaries in dist/)
|
|
218
|
+
possible_paths = [
|
|
219
|
+
File.join(Dir.pwd, "overrides", "agent-bridge-linux-#{linux_arch}"), # YATFA_SANDBOX/overrides
|
|
220
|
+
File.join(Dir.pwd, "yatfa-public", "dist", "agent-bridge-linux-#{linux_arch}"), # Running from metafolder
|
|
221
|
+
File.join(Dir.pwd, "dist", "agent-bridge-linux-#{linux_arch}"), # Running from yatfa-public
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
linux_bridge = nil
|
|
225
|
+
possible_paths.each do |path|
|
|
226
|
+
if File.exist?(path)
|
|
227
|
+
linux_bridge = path
|
|
228
|
+
break
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
local_bridge_default = File.join(Dir.pwd, "bin", "agent-bridge")
|
|
233
|
+
local_tmux = File.join(File.dirname(__FILE__), "..", "..", "bin", "agent-bridge-tmux")
|
|
234
|
+
|
|
235
|
+
if File.exist?(local_setup_script)
|
|
236
|
+
puts "๐ง Using local setup-agent.rb for development"
|
|
237
|
+
docker_cmd.concat(["-v", "#{File.expand_path(local_setup_script)}:/tmp/setup-agent.rb:ro"])
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
if linux_bridge && File.exist?(linux_bridge)
|
|
241
|
+
puts "๐ง Using local linux binary: #{linux_bridge}"
|
|
242
|
+
docker_cmd.concat(["-v", "#{File.expand_path(linux_bridge)}:/tmp/agent-bridge:ro"])
|
|
243
|
+
elsif File.exist?(local_bridge_default)
|
|
244
|
+
# Check if it's a binary or script
|
|
245
|
+
is_script = File.read(local_bridge_default, 4) == "#!/b"
|
|
246
|
+
if is_script
|
|
247
|
+
puts "โ ๏ธ bin/agent-bridge is a host wrapper script. Please run 'bin/build-bridge' to generate linux binaries."
|
|
248
|
+
else
|
|
249
|
+
puts "๐ง Using local agent-bridge binary"
|
|
250
|
+
docker_cmd.concat(["-v", "#{local_bridge_default}:/tmp/agent-bridge:ro"])
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
if File.exist?(local_tmux)
|
|
255
|
+
docker_cmd.concat(["-v", "#{File.expand_path(local_tmux)}:/tmp/agent-bridge-tmux:ro"])
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def self.detect_agent_user(container_name)
|
|
260
|
+
# Determine the user to attach as
|
|
261
|
+
# Robust method: find the user running the agent process (tmux or bridge)
|
|
262
|
+
user = `docker exec #{container_name} ps aux | grep "[a]gent-bridge-tmux" | awk '{print $1}' | head -n 1`.strip
|
|
263
|
+
|
|
264
|
+
if user.empty?
|
|
265
|
+
user = `docker exec #{container_name} ps aux | grep "[t]mux new-session" | awk '{print $1}' | head -n 1`.strip
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
if user.empty?
|
|
269
|
+
# Fallback to previous heuristic
|
|
270
|
+
detected_user = `docker exec #{container_name} whoami 2>/dev/null`.strip
|
|
271
|
+
if detected_user == "root" || detected_user.empty?
|
|
272
|
+
uid = Process.uid
|
|
273
|
+
mapped_user = `docker exec #{container_name} getent passwd #{uid} | cut -d: -f1`.strip
|
|
274
|
+
user = mapped_user unless mapped_user.empty?
|
|
275
|
+
else
|
|
276
|
+
user = detected_user
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
if user.empty?
|
|
281
|
+
# Final Fallback
|
|
282
|
+
user = "rails"
|
|
283
|
+
puts "โ ๏ธ Could not detect agent user, defaulting to '#{user}'"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
user
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module YatfaAgent
|
|
6
|
+
module Config
|
|
7
|
+
def self.load
|
|
8
|
+
rb_config_file = File.join(Dir.pwd, "yatfa.env.rb")
|
|
9
|
+
|
|
10
|
+
unless File.exist?(rb_config_file)
|
|
11
|
+
puts "โ Error: yatfa.env.rb not found in current directory"
|
|
12
|
+
puts ""
|
|
13
|
+
puts "Create yatfa.env.rb:"
|
|
14
|
+
puts " {"
|
|
15
|
+
puts " yatfa_url: 'https://yatfa.example.com', # Required"
|
|
16
|
+
puts " # OR legacy name:"
|
|
17
|
+
puts " rails_api_url: 'https://yatfa.example.com/api/v1',"
|
|
18
|
+
puts " # ..."
|
|
19
|
+
puts " # Paste your stripped .env content here:"
|
|
20
|
+
puts " dot_env: <<~ENV"
|
|
21
|
+
puts " STRIPE_KEY=sk_test_..."
|
|
22
|
+
puts " OPENAI_KEY=sk-..."
|
|
23
|
+
puts " ENV"
|
|
24
|
+
puts " }"
|
|
25
|
+
puts " echo 'yatfa.env.rb' >> .gitignore"
|
|
26
|
+
puts " exit 1"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
puts "โ๏ธ Loading configuration from yatfa.env.rb"
|
|
30
|
+
config = eval(File.read(rb_config_file), binding, rb_config_file)
|
|
31
|
+
|
|
32
|
+
# Convert symbols to strings for easier handling before JSON normalization
|
|
33
|
+
config = config.transform_keys(&:to_s)
|
|
34
|
+
|
|
35
|
+
# Parse dot_env heredoc if present
|
|
36
|
+
if (dotenv = config["dot_env"])
|
|
37
|
+
config["env"] ||= {}
|
|
38
|
+
# Ensure env is string-keyed
|
|
39
|
+
config["env"] = config["env"].transform_keys(&:to_s)
|
|
40
|
+
|
|
41
|
+
dotenv.each_line do |line|
|
|
42
|
+
line = line.strip
|
|
43
|
+
next if line.empty? || line.start_with?('#')
|
|
44
|
+
k, v = line.split('=', 2)
|
|
45
|
+
next unless k && v
|
|
46
|
+
# Remove surrounding quotes and trailing comments (simple)
|
|
47
|
+
v = v.strip.gsub(/^['"]|['"]$/, '')
|
|
48
|
+
config["env"][k.strip] = v
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
config.delete("dot_env")
|
|
52
|
+
puts "๐ฟ Parsed dot_env into #{config['env'].size} environment variables"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Normalize per-agent env hashes
|
|
56
|
+
if config["agents"].is_a?(Hash)
|
|
57
|
+
config["agents"].each do |agent_key, agent_config|
|
|
58
|
+
agent_key = agent_key.to_s
|
|
59
|
+
if agent_config.is_a?(Hash) && agent_config["env"].is_a?(Hash)
|
|
60
|
+
agent_config["env"] = agent_config["env"].transform_keys(&:to_s)
|
|
61
|
+
elsif agent_config.is_a?(Hash) && agent_config[:env].is_a?(Hash)
|
|
62
|
+
agent_config["env"] = agent_config[:env].transform_keys(&:to_s)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Normalize symbols to strings for consistency via JSON round-trip
|
|
68
|
+
JSON.parse(JSON.generate(config))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.yatfa_url(config)
|
|
72
|
+
# Support both new name and legacy name for backwards compatibility
|
|
73
|
+
config["yatfa_url"] || config["yafta_url"] || config["rails_api_url"]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.api_url(config)
|
|
77
|
+
# Derive API URL from yatfa_url
|
|
78
|
+
yatfa_base = yatfa_url(config)
|
|
79
|
+
|
|
80
|
+
# If yatfa_url already includes /api/v1, return as-is
|
|
81
|
+
return yatfa_base if yatfa_base.end_with?("/api/v1") || yatfa_base.end_with?("/api/")
|
|
82
|
+
|
|
83
|
+
# Otherwise, assume it's the base URL and append /api/v1
|
|
84
|
+
"#{yatfa_base}/api/v1"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.ws_url(config)
|
|
88
|
+
# Derive WebSocket URL from yatfa_url by replacing protocol and adding /cable endpoint
|
|
89
|
+
api_base = yatfa_url(config)
|
|
90
|
+
"#{api_base.sub(/^https:\/\//, "wss://").sub(/^http:\/\//, "ws://")}/cable"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.fetch_repositories!(config)
|
|
94
|
+
# Support both old and new config keys
|
|
95
|
+
api_base = api_url(config)
|
|
96
|
+
|
|
97
|
+
return unless api_base
|
|
98
|
+
|
|
99
|
+
# Use to first available agent's MCP API key
|
|
100
|
+
mcp_api_key = nil
|
|
101
|
+
if config["agents"].is_a?(Hash)
|
|
102
|
+
config["agents"].each do |_, agent_config|
|
|
103
|
+
if agent_config.is_a?(Hash) && agent_config["mcp_api_key"]
|
|
104
|
+
mcp_api_key = agent_config["mcp_api_key"]
|
|
105
|
+
break
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
return unless mcp_api_key
|
|
111
|
+
|
|
112
|
+
# First, discover which project we belong to via /whoami
|
|
113
|
+
require "net/http"
|
|
114
|
+
require "json"
|
|
115
|
+
require "uri"
|
|
116
|
+
|
|
117
|
+
whoami_uri = URI("#{api_base}/whoami")
|
|
118
|
+
whoami_response = Net::HTTP.get_response(whoami_uri, { "X-API-Key" => mcp_api_key, "Accept" => "application/json" })
|
|
119
|
+
|
|
120
|
+
if whoami_response.code != "200"
|
|
121
|
+
puts "โ ๏ธ Unable to discover project from /whoami (HTTP #{whoami_response.code})"
|
|
122
|
+
return
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
agent_info = JSON.parse(whoami_response.body)
|
|
126
|
+
project_id = agent_info["project_id"]
|
|
127
|
+
project_name = agent_info["name"] rescue "unknown"
|
|
128
|
+
|
|
129
|
+
puts "๐ Authenticated as #{agent_info["name"]} (project #{project_id})"
|
|
130
|
+
|
|
131
|
+
# Then fetch repositories from Rails API (scoped to our project)
|
|
132
|
+
uri = URI("#{api_base}/repositories")
|
|
133
|
+
begin
|
|
134
|
+
response = Net::HTTP.get_response(uri, { "X-API-Key" => mcp_api_key, "Accept" => "application/json" })
|
|
135
|
+
|
|
136
|
+
if response.code == "200"
|
|
137
|
+
repositories = JSON.parse(response.body)
|
|
138
|
+
|
|
139
|
+
if repositories.any?
|
|
140
|
+
config["repositories"] = repositories
|
|
141
|
+
|
|
142
|
+
# Build REPOSITORIES_CONTEXT string: "name:path,name:path"
|
|
143
|
+
repo_context = repositories.map { |r| "#{r['name']}:#{r['path_in_sandbox']}" }.join(",")
|
|
144
|
+
config["env"] ||= {}
|
|
145
|
+
config["env"]["REPOSITORIES_CONTEXT"] = repo_context
|
|
146
|
+
|
|
147
|
+
puts "๐ Loaded #{repositories.count} repositories for project #{project_id} (#{project_name})"
|
|
148
|
+
repositories.each do |repo|
|
|
149
|
+
puts " - #{repo['name']}: #{repo['path_in_sandbox']}"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
else
|
|
153
|
+
puts "โ ๏ธ Unable to fetch repositories (HTTP #{response.code})"
|
|
154
|
+
end
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
puts "โ ๏ธ Failed to fetch repositories: #{e.message}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.image_name(config)
|
|
161
|
+
"yatfa-sandbox"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module YatfaAgent
|
|
6
|
+
module Docker
|
|
7
|
+
def self.check_dockerfile!
|
|
8
|
+
unless File.exist?("Dockerfile.sandbox")
|
|
9
|
+
puts "โ Error: Dockerfile.sandbox not found"
|
|
10
|
+
puts ""
|
|
11
|
+
puts "Please create Dockerfile.sandbox by copying your existing Dockerfile"
|
|
12
|
+
puts "and adding the required agent dependencies."
|
|
13
|
+
puts ""
|
|
14
|
+
puts "See https://github.com/RoM4iK/yatfa-public/blob/main/README.md for instructions."
|
|
15
|
+
exit 1
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.build_image(config)
|
|
20
|
+
check_dockerfile!
|
|
21
|
+
|
|
22
|
+
user_id = `id -u`.strip
|
|
23
|
+
group_id = `id -g`.strip
|
|
24
|
+
|
|
25
|
+
puts "๐๏ธ Building Docker image..."
|
|
26
|
+
|
|
27
|
+
# Handle .dockerignore.sandbox
|
|
28
|
+
dockerignore_sandbox = ".dockerignore.sandbox"
|
|
29
|
+
dockerignore_original = ".dockerignore"
|
|
30
|
+
dockerignore_backup = ".dockerignore.bak"
|
|
31
|
+
|
|
32
|
+
has_sandbox_ignore = File.exist?(dockerignore_sandbox)
|
|
33
|
+
has_original_ignore = File.exist?(dockerignore_original)
|
|
34
|
+
|
|
35
|
+
if has_sandbox_ignore
|
|
36
|
+
puts "๐ฆ Swapping .dockerignore with .dockerignore.sandbox..."
|
|
37
|
+
if has_original_ignore
|
|
38
|
+
FileUtils.mv(dockerignore_original, dockerignore_backup)
|
|
39
|
+
end
|
|
40
|
+
FileUtils.cp(dockerignore_sandbox, dockerignore_original)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
success = false
|
|
44
|
+
begin
|
|
45
|
+
success = system(
|
|
46
|
+
"docker", "build",
|
|
47
|
+
"--build-arg", "USER_ID=#{user_id}",
|
|
48
|
+
"--build-arg", "GROUP_ID=#{group_id}",
|
|
49
|
+
"-t", Config.image_name(config),
|
|
50
|
+
"-f", "Dockerfile.sandbox",
|
|
51
|
+
"."
|
|
52
|
+
)
|
|
53
|
+
ensure
|
|
54
|
+
if has_sandbox_ignore
|
|
55
|
+
# Restore original state
|
|
56
|
+
FileUtils.rm(dockerignore_original) if File.exist?(dockerignore_original)
|
|
57
|
+
if has_original_ignore
|
|
58
|
+
FileUtils.mv(dockerignore_backup, dockerignore_original)
|
|
59
|
+
end
|
|
60
|
+
puts "๐งน Restored original .dockerignore"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
unless success
|
|
65
|
+
puts "โ Failed to build Docker image"
|
|
66
|
+
exit 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
puts "โ
Docker image built"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|