yatfa 1.0.78 → 1.0.80
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/bin/install-agent.sh +13 -9
- package/bin/run-yatfa-agent.rb +40 -4
- package/lib/yatfa_agent/agent.rb +8 -29
- package/package.json +2 -1
- package/setup/setup-agent.rb +1038 -0
- package/setup/setup-claw-agent.rb +494 -0
- package/setup/setup-github.rb +222 -0
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Yatfa Agent - Run inside any Docker container with Ruby
|
|
5
|
+
#
|
|
6
|
+
# This script sets up and runs a Yatfa agent. It:
|
|
7
|
+
# 1. Generates MCP config from environment variables
|
|
8
|
+
# 2. Verifies system prompt (agent banner) exists at ~/.yatfa/system-prompt.txt
|
|
9
|
+
# 3. Downloads and runs agent-bridge
|
|
10
|
+
#
|
|
11
|
+
# Requirements (install in your container):
|
|
12
|
+
# - Ruby 3.x
|
|
13
|
+
# - Node.js 20+
|
|
14
|
+
# - tmux
|
|
15
|
+
# - git, curl, gh (GitHub CLI)
|
|
16
|
+
# - claude CLI: npm install -g @anthropic-ai/claude-code
|
|
17
|
+
#
|
|
18
|
+
# Environment variables:
|
|
19
|
+
# AGENT_TYPE - worker|planner|reviewer|researcher
|
|
20
|
+
# YATFA_URL - Yatfa platform URL (https://yatfa.example.com)
|
|
21
|
+
# YATFA_API_URL - API URL for MCP tools (https://yatfa.example.com/api/v1)
|
|
22
|
+
# YATFA_API_KEY - MCP API key for this agent type
|
|
23
|
+
# GH_TOKEN - GitHub token (or use GitHub App auth)
|
|
24
|
+
#
|
|
25
|
+
# Usage:
|
|
26
|
+
# curl -fsSL https://raw.githubusercontent.com/RoM4iK/yatfa-public/main/yatfa.rb | ruby
|
|
27
|
+
|
|
28
|
+
require "json"
|
|
29
|
+
require "fileutils"
|
|
30
|
+
require "pathname"
|
|
31
|
+
require "open-uri"
|
|
32
|
+
require "openssl"
|
|
33
|
+
require "net/http"
|
|
34
|
+
require "uri"
|
|
35
|
+
require "base64"
|
|
36
|
+
require "time"
|
|
37
|
+
require "socket"
|
|
38
|
+
require "timeout"
|
|
39
|
+
require_relative "setup-github"
|
|
40
|
+
|
|
41
|
+
SCRIPT_DIR = File.dirname(File.expand_path(__FILE__))
|
|
42
|
+
YATFA_VERSION = ENV["YATFA_VERSION"] || "main"
|
|
43
|
+
YATFA_SCRIPTS_BASE_URL = "https://objectstore.fra1.civo.com/yatfa"
|
|
44
|
+
YATFA_RAW_URL = "#{YATFA_SCRIPTS_BASE_URL}/#{YATFA_VERSION}"
|
|
45
|
+
|
|
46
|
+
# Helper scripts installed into /usr/local/bin by setup_helper_scripts!
|
|
47
|
+
# Single source of truth — deploy/deploy reads this list too.
|
|
48
|
+
HELPER_SCRIPTS = %w[fetch-skill-variant.sh fetch-review-context.sh attach-screenshot.sh].freeze
|
|
49
|
+
|
|
50
|
+
# Valid agent types
|
|
51
|
+
VALID_AGENT_TYPES = %w[planner worker reviewer researcher claw]
|
|
52
|
+
|
|
53
|
+
def check_env!
|
|
54
|
+
agent_type = ENV["AGENT_TYPE"]
|
|
55
|
+
|
|
56
|
+
# Note: claw agents have a separate entry point in setup-claw-agent.rb
|
|
57
|
+
# which calls check_env_claw! directly. This method only handles standard agent types.
|
|
58
|
+
|
|
59
|
+
required = %w[YATFA_URL]
|
|
60
|
+
missing = required.select { |var| ENV[var].to_s.empty? }
|
|
61
|
+
unless missing.empty?
|
|
62
|
+
puts "❌ Missing environment variables: #{missing.join(', ')}"
|
|
63
|
+
puts ""
|
|
64
|
+
puts "Required:"
|
|
65
|
+
puts " YATFA_URL - Yatfa platform URL (https://yatfa.example.com)"
|
|
66
|
+
puts ""
|
|
67
|
+
puts "Optional:"
|
|
68
|
+
puts " YATFA_API_URL - API URL for MCP tools (derived from YATFA_URL)"
|
|
69
|
+
puts " YATFA_API_KEY - MCP API key"
|
|
70
|
+
puts " GH_TOKEN - GitHub token"
|
|
71
|
+
exit 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Derive YATFA_API_URL and YATFA_WS_URL from YATFA_URL if not set
|
|
75
|
+
# IMPORTANT: Always use original YATFA_URL for derivations, not modified values
|
|
76
|
+
yatfa_url = ENV["YATFA_URL"]
|
|
77
|
+
|
|
78
|
+
# Derive YATFA_API_URL: append /api/v1 if not already present
|
|
79
|
+
unless ENV["YATFA_API_URL"]
|
|
80
|
+
if yatfa_url.end_with?("/api/v1") || yatfa_url.end_with?("/api/")
|
|
81
|
+
ENV["YATFA_API_URL"] = yatfa_url
|
|
82
|
+
else
|
|
83
|
+
ENV["YATFA_API_URL"] = "#{yatfa_url}/api/v1"
|
|
84
|
+
end
|
|
85
|
+
puts "📡 Derived YATFA_API_URL: #{ENV['YATFA_API_URL']}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Derive YATFA_WS_URL: replace protocol with ws/wss and add /cable endpoint
|
|
89
|
+
# IMPORTANT: Use original yatfa_url before any modifications
|
|
90
|
+
original_yatfa_url = ENV["YATFA_URL"]
|
|
91
|
+
unless ENV["YATFA_WS_URL"]
|
|
92
|
+
ENV["YATFA_WS_URL"] = "#{original_yatfa_url.sub(/^https:\/\//, 'wss://').sub(/^http:\/\//, 'ws://')}/cable"
|
|
93
|
+
puts "📡 Derived YATFA_WS_URL: #{ENV['YATFA_WS_URL']}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
agent_type = ENV["AGENT_TYPE"]
|
|
97
|
+
if agent_type && !VALID_AGENT_TYPES.include?(agent_type)
|
|
98
|
+
puts "❌ Invalid AGENT_TYPE: #{agent_type}"
|
|
99
|
+
puts " Valid types: #{VALID_AGENT_TYPES.join(', ')}"
|
|
100
|
+
exit 1
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fetch_agent_id!
|
|
105
|
+
rails_api_url = ENV["YATFA_API_URL"]
|
|
106
|
+
rails_api_key = ENV["YATFA_API_KEY"]
|
|
107
|
+
|
|
108
|
+
unless rails_api_url && !rails_api_url.empty? && rails_api_key && !rails_api_key.empty?
|
|
109
|
+
puts "❌ YATFA_API_URL or YATFA_API_KEY not set"
|
|
110
|
+
puts " Agent ID is required for the bridge to connect"
|
|
111
|
+
exit 1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Determine agent version for reporting to backend
|
|
115
|
+
agent_version = nil
|
|
116
|
+
begin
|
|
117
|
+
version_url = "#{YATFA_RAW_URL}/VERSION"
|
|
118
|
+
agent_version = URI.open(version_url).read.strip
|
|
119
|
+
puts " (version from remote: #{version_url})"
|
|
120
|
+
rescue OpenURI::HTTPError, Errno::ENOENT => e
|
|
121
|
+
puts " ⚠️ Could not fetch VERSION: #{e.message}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Fetch agent ID from Rails API using api_key
|
|
125
|
+
whoami_url = "#{rails_api_url}/whoami"
|
|
126
|
+
whoami_url += "?version=#{URI.encode_www_form_component(agent_version)}" if agent_version && !agent_version.empty?
|
|
127
|
+
|
|
128
|
+
uri = URI(whoami_url)
|
|
129
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
130
|
+
http.use_ssl = uri.scheme == "https"
|
|
131
|
+
|
|
132
|
+
request = Net::HTTP::Get.new(uri)
|
|
133
|
+
request["X-API-Key"] = rails_api_key
|
|
134
|
+
request["Accept"] = "application/json"
|
|
135
|
+
|
|
136
|
+
begin
|
|
137
|
+
puts "🔍 Fetching agent ID from #{rails_api_url}/whoami..."
|
|
138
|
+
response = http.request(request)
|
|
139
|
+
|
|
140
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
141
|
+
data = JSON.parse(response.body)
|
|
142
|
+
agent_id = data["agent_id"] || data["id"]
|
|
143
|
+
|
|
144
|
+
if agent_id && !agent_id.to_s.empty?
|
|
145
|
+
ENV["AGENT_ID"] = agent_id.to_s
|
|
146
|
+
puts "✅ Agent ID fetched: #{agent_id}"
|
|
147
|
+
else
|
|
148
|
+
puts "❌ Could not find agent_id in API response"
|
|
149
|
+
puts " Response: #{response.body}"
|
|
150
|
+
exit 1
|
|
151
|
+
end
|
|
152
|
+
else
|
|
153
|
+
puts "❌ Failed to fetch agent ID: #{response.code} #{response.message}"
|
|
154
|
+
puts " Response: #{response.body}"
|
|
155
|
+
exit 1
|
|
156
|
+
end
|
|
157
|
+
rescue => e
|
|
158
|
+
puts "❌ Error fetching agent ID: #{e.message}"
|
|
159
|
+
puts " #{e.class}: #{e.backtrace.first}"
|
|
160
|
+
exit 1
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def setup_mcp_config!
|
|
165
|
+
# MCP config is project-specific and should be provided by the Dockerfile
|
|
166
|
+
# or mounted at runtime. This script only checks if it exists.
|
|
167
|
+
|
|
168
|
+
agent_type = ENV["AGENT_TYPE"]
|
|
169
|
+
rails_api_url = ENV["YATFA_API_URL"]
|
|
170
|
+
rails_api_key = ENV["YATFA_API_KEY"]
|
|
171
|
+
|
|
172
|
+
# Load existing config if present
|
|
173
|
+
existing_config = {}
|
|
174
|
+
if File.exist?(".mcp.json")
|
|
175
|
+
begin
|
|
176
|
+
existing_config = JSON.parse(File.read(".mcp.json"))
|
|
177
|
+
puts "✅ Found existing .mcp.json, merging configuration..."
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
puts "⚠️ Existing .mcp.json is invalid, starting fresh"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Ensure mcpServers key exists
|
|
184
|
+
existing_config["mcpServers"] ||= {}
|
|
185
|
+
|
|
186
|
+
if rails_api_url && !rails_api_url.empty? && rails_api_key && !rails_api_key.empty?
|
|
187
|
+
# Install yatfa-mcp locally to ensure we can run it with node (bypassing shebang issues)
|
|
188
|
+
tools_dir = File.expand_path("~/yatfa-tools")
|
|
189
|
+
FileUtils.mkdir_p(tools_dir)
|
|
190
|
+
|
|
191
|
+
puts "📦 Installing yatfa-mcp..."
|
|
192
|
+
# Redirect output to avoid cluttering logs, unless it fails
|
|
193
|
+
unless system("npm install --prefix #{tools_dir} yatfa-mcp > /dev/null 2>&1")
|
|
194
|
+
puts "❌ Failed to install yatfa-mcp"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
script_path = "#{tools_dir}/node_modules/yatfa-mcp/dist/index.js"
|
|
198
|
+
|
|
199
|
+
yatfa_server_config = {
|
|
200
|
+
"command" => "node",
|
|
201
|
+
"args" => [script_path],
|
|
202
|
+
"env" => {
|
|
203
|
+
"YATFA_API_URL" => rails_api_url,
|
|
204
|
+
"YATFA_API_KEY" => rails_api_key
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Add/Update yatfa server config
|
|
209
|
+
existing_config["mcpServers"]["yatfa-#{agent_type}"] = yatfa_server_config
|
|
210
|
+
|
|
211
|
+
File.write(".mcp.json", JSON.pretty_generate(existing_config))
|
|
212
|
+
puts "📝 Updated .mcp.json with yatfa-#{agent_type} server (using yatfa-mcp)"
|
|
213
|
+
else
|
|
214
|
+
# Only write if we don't have existing config
|
|
215
|
+
if existing_config["mcpServers"].empty?
|
|
216
|
+
File.write(".mcp.json", JSON.generate({ "mcpServers" => {} }))
|
|
217
|
+
puts "ℹ️ No MCP API credentials - MCP tools disabled"
|
|
218
|
+
else
|
|
219
|
+
puts "ℹ️ No MCP API credentials - keeping existing config"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def setup_claude_config!
|
|
225
|
+
home_claude_json = File.expand_path("~/.claude.json")
|
|
226
|
+
|
|
227
|
+
# BYOK: No longer depends on host's claude.json
|
|
228
|
+
# Create minimal config with bypass permissions and workspace trust
|
|
229
|
+
claude_config = {}
|
|
230
|
+
claude_config["bypassPermissionsModeAccepted"] = true
|
|
231
|
+
claude_config["hasCompletedOnboarding"] = true
|
|
232
|
+
|
|
233
|
+
# Set up workspace project with trust accepted
|
|
234
|
+
claude_config["projects"] ||= {}
|
|
235
|
+
claude_config["projects"]["/workspace"] = {
|
|
236
|
+
"hasTrustDialogAccepted" => true,
|
|
237
|
+
"projectOnboardingSeenCount" => 1
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
File.write(home_claude_json, JSON.pretty_generate(claude_config))
|
|
241
|
+
puts "✅ Created minimal claude.json with bypass permissions and workspace trust"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def fetch_llm_credentials!
|
|
245
|
+
rails_api_url = ENV["YATFA_API_URL"]
|
|
246
|
+
rails_api_key = ENV["YATFA_API_KEY"]
|
|
247
|
+
|
|
248
|
+
unless rails_api_url && !rails_api_url.empty? && rails_api_key && !rails_api_key.empty?
|
|
249
|
+
puts "❌ YATFA_API_URL or YATFA_API_KEY not set"
|
|
250
|
+
return nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Fetch LLM credentials from Rails API
|
|
254
|
+
uri = URI("#{rails_api_url}/llm/credentials")
|
|
255
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
256
|
+
http.use_ssl = uri.scheme == "https"
|
|
257
|
+
http.open_timeout = 10
|
|
258
|
+
http.read_timeout = 10
|
|
259
|
+
|
|
260
|
+
request = Net::HTTP::Get.new(uri)
|
|
261
|
+
request["X-API-Key"] = rails_api_key
|
|
262
|
+
request["Accept"] = "application/json"
|
|
263
|
+
|
|
264
|
+
begin
|
|
265
|
+
puts "🔍 Fetching LLM credentials from #{rails_api_url}/llm/credentials..."
|
|
266
|
+
response = http.request(request)
|
|
267
|
+
|
|
268
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
269
|
+
data = JSON.parse(response.body)
|
|
270
|
+
puts "✅ LLM credentials fetched successfully"
|
|
271
|
+
data
|
|
272
|
+
elsif response.is_a?(Net::HTTPNotFound)
|
|
273
|
+
puts "❌ No LLM credentials configured in backend"
|
|
274
|
+
nil
|
|
275
|
+
else
|
|
276
|
+
puts "❌ Failed to fetch LLM credentials: #{response.code} #{response.message}"
|
|
277
|
+
puts " Response: #{response.body}"
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
281
|
+
puts "❌ Timeout fetching LLM credentials: #{e.message}"
|
|
282
|
+
nil
|
|
283
|
+
rescue => e
|
|
284
|
+
puts "❌ Error fetching LLM credentials: #{e.message}"
|
|
285
|
+
nil
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def setup_claude_settings!
|
|
290
|
+
settings_dir = File.expand_path("~/.claude")
|
|
291
|
+
FileUtils.mkdir_p(settings_dir)
|
|
292
|
+
home_settings_json = File.join(settings_dir, "settings.json")
|
|
293
|
+
|
|
294
|
+
puts "🔧 Configuring ~/.claude/settings.json..."
|
|
295
|
+
|
|
296
|
+
settings_config = {}
|
|
297
|
+
|
|
298
|
+
# Fetch LLM credentials from Rails backend (BYOK support)
|
|
299
|
+
llm_credentials = fetch_llm_credentials!
|
|
300
|
+
|
|
301
|
+
if llm_credentials
|
|
302
|
+
puts "✅ Fetched LLM credentials from backend (source: #{llm_credentials['source']})"
|
|
303
|
+
|
|
304
|
+
# Configure Claude CLI with LLM provider environment variables
|
|
305
|
+
settings_config["env"] ||= {}
|
|
306
|
+
settings_config["env"]["ANTHROPIC_AUTH_TOKEN"] = llm_credentials["api_key"]
|
|
307
|
+
|
|
308
|
+
if llm_credentials["base_url"] && !llm_credentials["base_url"].empty?
|
|
309
|
+
settings_config["env"]["ANTHROPIC_BASE_URL"] = llm_credentials["base_url"]
|
|
310
|
+
puts " Custom base URL: #{llm_credentials['base_url']}"
|
|
311
|
+
else
|
|
312
|
+
settings_config["env"].delete("ANTHROPIC_BASE_URL") if settings_config["env"]["ANTHROPIC_BASE_URL"]
|
|
313
|
+
puts " Using default Anthropic base URL"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Configure model selection if specified
|
|
317
|
+
# Each model tier can be configured independently
|
|
318
|
+
if llm_credentials["model_haiku"] && !llm_credentials["model_haiku"].empty?
|
|
319
|
+
settings_config["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = llm_credentials["model_haiku"]
|
|
320
|
+
puts " Haiku model: #{llm_credentials['model_haiku']}"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
if llm_credentials["model_sonnet"] && !llm_credentials["model_sonnet"].empty?
|
|
324
|
+
settings_config["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"] = llm_credentials["model_sonnet"]
|
|
325
|
+
puts " Sonnet model: #{llm_credentials['model_sonnet']}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
if llm_credentials["model_opus"] && !llm_credentials["model_opus"].empty?
|
|
329
|
+
settings_config["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"] = llm_credentials["model_opus"]
|
|
330
|
+
puts " Opus model: #{llm_credentials['model_opus']}"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
if !llm_credentials["model_haiku"] && !llm_credentials["model_sonnet"] && !llm_credentials["model_opus"]
|
|
334
|
+
puts " Using default Anthropic models"
|
|
335
|
+
end
|
|
336
|
+
else
|
|
337
|
+
puts "❌ No LLM credentials found in backend!"
|
|
338
|
+
puts " Please configure LLM API key for this agent or its project in the Yatfa dashboard."
|
|
339
|
+
puts " Agent will fail to start without credentials."
|
|
340
|
+
exit 1
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# No settings loaded from host - agent is fully self-contained
|
|
344
|
+
|
|
345
|
+
# Configure hooks
|
|
346
|
+
settings_config["hooks"] ||= {}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# Configure SessionStart hook for skill knowledge injection
|
|
350
|
+
settings_config["hooks"]["SessionStart"] ||= []
|
|
351
|
+
# Remove old hook if present
|
|
352
|
+
settings_config["hooks"]["SessionStart"].reject! do |h|
|
|
353
|
+
h["hooks"]&.first&.dig("command")&.include?("inject-skill-knowledge.rb")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Install hook script to ~/.claude/hooks
|
|
357
|
+
hooks_dir = File.join(settings_dir, "hooks")
|
|
358
|
+
FileUtils.mkdir_p(hooks_dir)
|
|
359
|
+
|
|
360
|
+
session_script_name = "inject-skill-knowledge.rb"
|
|
361
|
+
dest_path = download_file(
|
|
362
|
+
script_name: session_script_name,
|
|
363
|
+
remote_url: "#{YATFA_RAW_URL}/hooks/#{session_script_name}",
|
|
364
|
+
dest_dir: hooks_dir,
|
|
365
|
+
label: "SessionStart hook"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if dest_path
|
|
369
|
+
session_hook_to_add = {
|
|
370
|
+
"hooks" => [{ "type" => "command", "command" => "ruby \"#{dest_path}\"" }]
|
|
371
|
+
}
|
|
372
|
+
settings_config["hooks"]["SessionStart"] << session_hook_to_add
|
|
373
|
+
else
|
|
374
|
+
puts "⚠️ Skipping SessionStart hook configuration (download failed)"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Install sessionstart-input.sh for repository context injection
|
|
378
|
+
# This provides repository information to Claude at session start
|
|
379
|
+
input_script_name = "sessionstart-input.sh"
|
|
380
|
+
download_file(
|
|
381
|
+
script_name: input_script_name,
|
|
382
|
+
remote_url: "#{YATFA_RAW_URL}/hooks/#{input_script_name}",
|
|
383
|
+
dest_dir: hooks_dir,
|
|
384
|
+
label: "SessionStart input hook"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Add agent-specific settings
|
|
388
|
+
settings_config["skipDangerousModePermissionPrompt"] = true
|
|
389
|
+
|
|
390
|
+
# Configure Stop hook for session-end learning capture
|
|
391
|
+
settings_config["hooks"]["Stop"] ||= []
|
|
392
|
+
# Remove old hook if present
|
|
393
|
+
settings_config["hooks"]["Stop"].reject! do |h|
|
|
394
|
+
h["hooks"]&.first&.dig("command")&.include?("sessionend-output.sh")
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Install session-end hook script
|
|
398
|
+
sessionend_script_name = "sessionend-output.sh"
|
|
399
|
+
sessionend_dest_path = download_file(
|
|
400
|
+
script_name: sessionend_script_name,
|
|
401
|
+
remote_url: "#{YATFA_RAW_URL}/hooks/#{sessionend_script_name}",
|
|
402
|
+
dest_dir: hooks_dir,
|
|
403
|
+
label: "Stop hook"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
if sessionend_dest_path
|
|
407
|
+
sessionend_hook_to_add = {
|
|
408
|
+
"hooks" => [{ "type" => "command", "command" => "bash \"#{sessionend_dest_path}\"" }]
|
|
409
|
+
}
|
|
410
|
+
settings_config["hooks"]["Stop"] << sessionend_hook_to_add
|
|
411
|
+
else
|
|
412
|
+
puts "⚠️ Skipping Stop hook configuration (download failed)"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Download and register project hooks wrapper
|
|
416
|
+
# The wrapper fetches user-defined hooks from the API at runtime (with caching),
|
|
417
|
+
# executes them, and reports results back for observability.
|
|
418
|
+
setup_project_hooks_wrapper!(settings_config, hooks_dir)
|
|
419
|
+
|
|
420
|
+
File.write(home_settings_json, JSON.pretty_generate(settings_config))
|
|
421
|
+
puts "✅ ~/.claude/settings.json configured with skill hooks"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Download and register the project hooks wrapper as a SessionStart hook.
|
|
425
|
+
# The wrapper handles fetching user-defined hooks from the API at runtime,
|
|
426
|
+
# executing them, and reporting results. Hook content is managed by users
|
|
427
|
+
# via the Yatfa config UI and stored as ProjectConfig records.
|
|
428
|
+
def setup_project_hooks_wrapper!(settings_config, hooks_dir)
|
|
429
|
+
wrapper_path = download_file(
|
|
430
|
+
script_name: "project-hooks-wrapper.rb",
|
|
431
|
+
remote_url: "#{YATFA_RAW_URL}/hooks/project-hooks-wrapper.rb",
|
|
432
|
+
dest_dir: hooks_dir,
|
|
433
|
+
label: "Project hooks wrapper"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if wrapper_path
|
|
437
|
+
settings_config["hooks"]["SessionStart"] << {
|
|
438
|
+
"hooks" => [{ "type" => "command", "command" => "ruby \"#{wrapper_path}\"" }]
|
|
439
|
+
}
|
|
440
|
+
puts "✅ Registered project hooks wrapper"
|
|
441
|
+
else
|
|
442
|
+
puts "⚠️ Skipping project hooks wrapper (download failed)"
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def setup_system_prompt!
|
|
447
|
+
agent_type = ENV["AGENT_TYPE"]
|
|
448
|
+
system_prompt_path = File.expand_path("~/.yatfa/system-prompt.txt")
|
|
449
|
+
|
|
450
|
+
# Download agents.rb from remote
|
|
451
|
+
agents_rb_url = "#{YATFA_RAW_URL}/agents.rb"
|
|
452
|
+
puts "📥 Downloading agents.rb from #{agents_rb_url}..."
|
|
453
|
+
agents_rb_content = nil
|
|
454
|
+
begin
|
|
455
|
+
agents_rb_content = URI.open(agents_rb_url).read
|
|
456
|
+
puts "✅ Downloaded agents.rb"
|
|
457
|
+
rescue OpenURI::HTTPError => e
|
|
458
|
+
puts "❌ Failed to download agents.rb: #{e.message}"
|
|
459
|
+
puts " Cannot generate system prompt without agent definitions"
|
|
460
|
+
exit 1
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Evaluate agents.rb to get AGENT_CONFIGS
|
|
464
|
+
agents_binding = binding
|
|
465
|
+
begin
|
|
466
|
+
agents_binding.eval(agents_rb_content, "agents.rb")
|
|
467
|
+
rescue => e
|
|
468
|
+
puts "❌ Failed to evaluate agents.rb: #{e.message}"
|
|
469
|
+
exit 1
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
agent_configs = agents_binding.eval("AGENT_CONFIGS")
|
|
473
|
+
|
|
474
|
+
unless agent_configs && agent_configs[agent_type]
|
|
475
|
+
puts "❌ Unknown agent type: #{agent_type}"
|
|
476
|
+
puts " Available: #{agent_configs.keys.join(', ')}"
|
|
477
|
+
exit 1
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
agent_def = agent_configs[agent_type]
|
|
481
|
+
banner_content = agent_def[:banner].dup
|
|
482
|
+
|
|
483
|
+
# Append repository context from REPOSITORIES_CONTEXT env var
|
|
484
|
+
if ENV["REPOSITORIES_CONTEXT"] && !ENV["REPOSITORIES_CONTEXT"].empty?
|
|
485
|
+
repos = ENV["REPOSITORIES_CONTEXT"].split(',').map do |repo|
|
|
486
|
+
name, path = repo.split(':')
|
|
487
|
+
{ name: name, path: path || "/workspace/#{name}" }
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
repo_info = repos.map { |r| " - **#{r[:name]}**: #{r[:path]}" }.join("\n")
|
|
491
|
+
|
|
492
|
+
banner_content += "\n\n## REPOSITORY STRUCTURE\n\n"
|
|
493
|
+
banner_content += "This project has #{repos.count} repositories:\n\n"
|
|
494
|
+
banner_content += "#{repo_info}\n\n"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Ensure directory exists and write system prompt
|
|
498
|
+
FileUtils.mkdir_p(File.dirname(system_prompt_path))
|
|
499
|
+
File.write(system_prompt_path, banner_content)
|
|
500
|
+
puts "✅ System prompt generated at #{system_prompt_path} (agent: #{agent_type})"
|
|
501
|
+
|
|
502
|
+
# Add repository context to CLAUDE.md for easy reference
|
|
503
|
+
claude_md = File.expand_path("~/CLAUDE.md")
|
|
504
|
+
if ENV["REPOSITORIES_CONTEXT"] && !ENV["REPOSITORIES_CONTEXT"].empty?
|
|
505
|
+
repo_info = ENV["REPOSITORIES_CONTEXT"].split(',').map do |repo|
|
|
506
|
+
name, path = repo.split(':')
|
|
507
|
+
" - **#{name}**: #{path}"
|
|
508
|
+
end.join("\n")
|
|
509
|
+
|
|
510
|
+
repo_context_md = <<~MARKDOWN
|
|
511
|
+
## Repository Structure
|
|
512
|
+
|
|
513
|
+
This project has #{ENV["REPOSITORIES_CONTEXT"].split(',').count} repositories:
|
|
514
|
+
|
|
515
|
+
#{repo_info}
|
|
516
|
+
MARKDOWN
|
|
517
|
+
|
|
518
|
+
# Append to existing CLAUDE.md or create new one
|
|
519
|
+
existing_content = File.exist?(claude_md) ? File.read(claude_md) : ""
|
|
520
|
+
File.write(claude_md, existing_content + "\n" + repo_context_md)
|
|
521
|
+
puts "📝 Repository context added to ~/CLAUDE.md"
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def setup_skills!
|
|
527
|
+
skills = ENV["SKILLS"].to_s.split(",")
|
|
528
|
+
return if skills.empty?
|
|
529
|
+
|
|
530
|
+
skills_dir = File.expand_path("~/.claude/skills")
|
|
531
|
+
puts "🧠 Installing #{skills.size} skills to #{skills_dir}..."
|
|
532
|
+
FileUtils.mkdir_p(skills_dir)
|
|
533
|
+
|
|
534
|
+
skills.each do |skill|
|
|
535
|
+
puts " - #{skill}"
|
|
536
|
+
|
|
537
|
+
url = "#{YATFA_RAW_URL}/skills/#{skill}/SKILL.md"
|
|
538
|
+
|
|
539
|
+
begin
|
|
540
|
+
skill_content = URI.open(url).read
|
|
541
|
+
puts " (from remote: #{url})"
|
|
542
|
+
|
|
543
|
+
# Write to .claude/skills/[skill]/SKILL.md with proper structure
|
|
544
|
+
skill_dir = File.join(skills_dir, skill)
|
|
545
|
+
FileUtils.mkdir_p(skill_dir)
|
|
546
|
+
File.write(File.join(skill_dir, "SKILL.md"), skill_content)
|
|
547
|
+
rescue OpenURI::HTTPError, Errno::ENOENT => e
|
|
548
|
+
puts "⚠️ Failed to download skill #{skill}: #{e.message}"
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
puts "✅ Skills installed"
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# Community skill source registry
|
|
556
|
+
# Maps skill name to its download source configuration
|
|
557
|
+
COMMUNITY_SKILL_SOURCES = {
|
|
558
|
+
"ui-ux-pro-max" => {
|
|
559
|
+
github_repo: "nextlevelbuilder/ui-ux-pro-max-skill",
|
|
560
|
+
skill_path: ".claude/skills/ui-ux-pro-max/SKILL.md"
|
|
561
|
+
}
|
|
562
|
+
}.freeze
|
|
563
|
+
|
|
564
|
+
# Fetch community skills assigned to this agent from the backend API
|
|
565
|
+
def fetch_community_skills!
|
|
566
|
+
rails_api_url = ENV["YATFA_API_URL"]
|
|
567
|
+
rails_api_key = ENV["YATFA_API_KEY"]
|
|
568
|
+
|
|
569
|
+
unless rails_api_url && !rails_api_url.empty? && rails_api_key && !rails_api_key.empty?
|
|
570
|
+
puts "⚠️ Cannot fetch community skills: API URL or key not set"
|
|
571
|
+
return []
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
uri = URI("#{rails_api_url}/whoami")
|
|
575
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
576
|
+
http.use_ssl = uri.scheme == "https"
|
|
577
|
+
|
|
578
|
+
request = Net::HTTP::Get.new(uri)
|
|
579
|
+
request["X-API-Key"] = rails_api_key
|
|
580
|
+
request["Accept"] = "application/json"
|
|
581
|
+
|
|
582
|
+
begin
|
|
583
|
+
response = http.request(request)
|
|
584
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
585
|
+
data = JSON.parse(response.body)
|
|
586
|
+
skills = data["community_skills"] || []
|
|
587
|
+
if skills.any?
|
|
588
|
+
puts "📦 Found #{skills.size} community skills: #{skills.join(', ')}"
|
|
589
|
+
end
|
|
590
|
+
skills
|
|
591
|
+
else
|
|
592
|
+
puts "⚠️ Could not fetch community skills: #{response.code}"
|
|
593
|
+
[]
|
|
594
|
+
end
|
|
595
|
+
rescue => e
|
|
596
|
+
puts "⚠️ Error fetching community skills: #{e.message}"
|
|
597
|
+
[]
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Install community skills from external sources (e.g., GitHub repos)
|
|
602
|
+
def setup_community_skills!
|
|
603
|
+
# Community skills can come from two sources:
|
|
604
|
+
# 1. COMMUNITY_SKILLS env var (set during container creation from agent config)
|
|
605
|
+
# 2. Fetched from the backend API (whoami response)
|
|
606
|
+
env_skills = ENV["COMMUNITY_SKILLS"].to_s.split(",").map(&:strip).reject(&:blank?)
|
|
607
|
+
api_skills = fetch_community_skills!
|
|
608
|
+
community_skills = (env_skills + api_skills).uniq
|
|
609
|
+
|
|
610
|
+
return if community_skills.empty?
|
|
611
|
+
|
|
612
|
+
skills_dir = File.expand_path("~/.claude/skills")
|
|
613
|
+
puts "🌐 Installing #{community_skills.size} community skills..."
|
|
614
|
+
FileUtils.mkdir_p(skills_dir)
|
|
615
|
+
|
|
616
|
+
community_skills.each do |skill_name|
|
|
617
|
+
puts " - #{skill_name}"
|
|
618
|
+
|
|
619
|
+
source = COMMUNITY_SKILL_SOURCES[skill_name]
|
|
620
|
+
unless source
|
|
621
|
+
puts "⚠️ Unknown community skill: #{skill_name} (no source configured)"
|
|
622
|
+
next
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
begin
|
|
626
|
+
# Download SKILL.md from GitHub raw URL
|
|
627
|
+
github_repo = source[:github_repo]
|
|
628
|
+
skill_path = source[:skill_path] || ".claude/skills/#{skill_name}/SKILL.md"
|
|
629
|
+
url = "https://raw.githubusercontent.com/#{github_repo}/main/#{skill_path}"
|
|
630
|
+
|
|
631
|
+
skill_content = URI.open(url, open_timeout: 15, read_timeout: 30).read
|
|
632
|
+
puts " (from GitHub: #{github_repo})"
|
|
633
|
+
|
|
634
|
+
# Write to .claude/skills/[skill_name]/SKILL.md
|
|
635
|
+
skill_dir = File.join(skills_dir, skill_name)
|
|
636
|
+
FileUtils.mkdir_p(skill_dir)
|
|
637
|
+
File.write(File.join(skill_dir, "SKILL.md"), skill_content)
|
|
638
|
+
rescue OpenURI::HTTPError, Errno::ENOENT => e
|
|
639
|
+
puts "⚠️ Failed to download community skill #{skill_name}: #{e.message}"
|
|
640
|
+
rescue => e
|
|
641
|
+
puts "⚠️ Error installing community skill #{skill_name}: #{e.message}"
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
puts "✅ Community skills installed"
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def clone_repositories!
|
|
649
|
+
return unless ENV["REPOSITORIES_CONFIG"]
|
|
650
|
+
|
|
651
|
+
begin
|
|
652
|
+
repos = JSON.parse(ENV["REPOSITORIES_CONFIG"])
|
|
653
|
+
return unless repos.is_a?(Array) && repos.any?
|
|
654
|
+
|
|
655
|
+
puts "📚 Cloning #{repos.count} repositories..."
|
|
656
|
+
|
|
657
|
+
repos.each do |repo|
|
|
658
|
+
repo_name = repo["name"]
|
|
659
|
+
github_url = repo["github_url"]
|
|
660
|
+
sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
|
|
661
|
+
|
|
662
|
+
# Create parent directory if needed
|
|
663
|
+
parent_dir = File.dirname(sandbox_path)
|
|
664
|
+
FileUtils.mkdir_p(parent_dir) unless File.exist?(parent_dir)
|
|
665
|
+
|
|
666
|
+
# Clone repository if it doesn't exist
|
|
667
|
+
# Do NOT use --depth to allow full branch access for PR workflows
|
|
668
|
+
unless File.exist?(sandbox_path)
|
|
669
|
+
puts " 📥 Cloning #{repo_name} to #{sandbox_path}..."
|
|
670
|
+
clone_timeout = ENV.fetch("GIT_CLONE_TIMEOUT", "180").to_i
|
|
671
|
+
clone_success = begin
|
|
672
|
+
Timeout.timeout(clone_timeout) do
|
|
673
|
+
system("git", "clone", github_url, sandbox_path)
|
|
674
|
+
end
|
|
675
|
+
rescue Timeout::Error
|
|
676
|
+
# Kill orphan git clone subprocess (Timeout.timeout doesn't kill children)
|
|
677
|
+
system("pkill", "-f", "git clone #{github_url}", err: File::NULL)
|
|
678
|
+
# Clean up partial clone directory so restart doesn't skip it
|
|
679
|
+
FileUtils.rm_rf(sandbox_path) if File.exist?(sandbox_path)
|
|
680
|
+
puts "❌ git clone of #{repo_name} timed out after #{clone_timeout}s"
|
|
681
|
+
puts " The container will restart automatically."
|
|
682
|
+
exit 1
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
unless clone_success
|
|
686
|
+
puts "❌ git clone of #{repo_name} failed"
|
|
687
|
+
puts " The container will restart automatically."
|
|
688
|
+
exit 1
|
|
689
|
+
end
|
|
690
|
+
else
|
|
691
|
+
puts " ✓ #{repo_name} already exists at #{sandbox_path}"
|
|
692
|
+
# Fetch all remotes to ensure branches are up to date
|
|
693
|
+
Dir.chdir(sandbox_path) do
|
|
694
|
+
system("git", "fetch", "--all")
|
|
695
|
+
puts " ✓ Fetched all remotes for #{repo_name}"
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Add repository path to git safe.directory to avoid "dubious ownership" errors
|
|
700
|
+
system("git", "config", "--global", "--add", "safe.directory", sandbox_path)
|
|
701
|
+
puts " ✓ Added #{sandbox_path} to git safe.directory"
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
puts "✅ Repositories cloned"
|
|
705
|
+
|
|
706
|
+
# Track if we have a single repository for later use
|
|
707
|
+
single_repo_name = repos.count == 1 ? repos.first['name'] : nil
|
|
708
|
+
|
|
709
|
+
# Write repository context to a file for hooks to read
|
|
710
|
+
# This is more reliable than environment variables in tmux sessions
|
|
711
|
+
if ENV["REPOSITORIES_CONTEXT"]
|
|
712
|
+
repo_context_file = File.expand_path("~/.claude/repos_context.txt")
|
|
713
|
+
File.write(repo_context_file, ENV["REPOSITORIES_CONTEXT"])
|
|
714
|
+
puts "📝 Repository context written to #{repo_context_file}"
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Export REPOSITORIES_CONTEXT to bash profile for shell sessions
|
|
718
|
+
if ENV["REPOSITORIES_CONTEXT"]
|
|
719
|
+
profile_file = File.expand_path("~/.bashrc")
|
|
720
|
+
existing_content = File.exist?(profile_file) ? File.read(profile_file) : ""
|
|
721
|
+
|
|
722
|
+
# Remove old export line if present
|
|
723
|
+
existing_content.gsub!(/^export REPOSITORIES_CONTEXT=.*$/, "")
|
|
724
|
+
|
|
725
|
+
# Add new export at the end
|
|
726
|
+
additions = "\nexport REPOSITORIES_CONTEXT=\"#{ENV['REPOSITORIES_CONTEXT']}\"\n"
|
|
727
|
+
File.write(profile_file, existing_content + additions)
|
|
728
|
+
puts "📝 REPOSITORIES_CONTEXT exported to ~/.bashrc"
|
|
729
|
+
end
|
|
730
|
+
rescue JSON::ParserError => e
|
|
731
|
+
puts "⚠️ Failed to parse REPOSITORIES_CONFIG: #{e.message}"
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def setup_git_config!
|
|
736
|
+
# Configure identity if provided
|
|
737
|
+
if ENV["GIT_USER_NAME"] && !ENV["GIT_USER_NAME"].empty?
|
|
738
|
+
system("git config --global user.name \"#{ENV['GIT_USER_NAME']}\"")
|
|
739
|
+
puts "✅ Git user.name configured"
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
if ENV["GIT_USER_EMAIL"] && !ENV["GIT_USER_EMAIL"].empty?
|
|
743
|
+
system("git config --global user.email \"#{ENV['GIT_USER_EMAIL']}\"")
|
|
744
|
+
puts "✅ Git user.email configured"
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# Force HTTPS instead of SSH to ensure our token auth works
|
|
748
|
+
# This fixes "Permission denied (publickey)" when the repo uses git@github.com remote
|
|
749
|
+
system("git config --global url.\"https://github.com/\".insteadOf \"git@github.com:\"")
|
|
750
|
+
puts "✅ Git configured to force HTTPS for GitHub"
|
|
751
|
+
|
|
752
|
+
# Avoid "dubious ownership" errors in containers
|
|
753
|
+
# Note: Individual repository paths are added in clone_repositories! after cloning
|
|
754
|
+
system("git config --global --add safe.directory \"#{Dir.pwd}\"")
|
|
755
|
+
puts "✅ Git configured to trust #{Dir.pwd}"
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def setup_git_hooks!
|
|
760
|
+
# Install git hooks to encourage agents to use git-workflow skill
|
|
761
|
+
hooks_dest = File.join(Dir.pwd, ".git", "hooks")
|
|
762
|
+
return unless File.directory?(hooks_dest)
|
|
763
|
+
|
|
764
|
+
puts "🪝 Installing git hooks..."
|
|
765
|
+
|
|
766
|
+
url = "#{YATFA_RAW_URL}/hooks/pre-commit"
|
|
767
|
+
begin
|
|
768
|
+
hook_content = URI.open(url).read
|
|
769
|
+
rescue OpenURI::HTTPError => e
|
|
770
|
+
puts "⚠️ Failed to download pre-commit hook: #{e.message}"
|
|
771
|
+
return
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
hook_path = File.join(hooks_dest, "pre-commit")
|
|
775
|
+
File.write(hook_path, hook_content)
|
|
776
|
+
File.chmod(0755, hook_path)
|
|
777
|
+
puts "✅ Git hooks installed"
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
def download_agent_bridge!
|
|
781
|
+
# Detect architecture
|
|
782
|
+
arch = `uname -m`.strip
|
|
783
|
+
if arch == "x86_64"
|
|
784
|
+
arch = "amd64"
|
|
785
|
+
elsif arch == "aarch64" || arch == "arm64"
|
|
786
|
+
arch = "arm64"
|
|
787
|
+
else
|
|
788
|
+
puts "❌ Unsupported architecture: #{arch}"
|
|
789
|
+
exit 1
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
target_dir = "/usr/local/bin"
|
|
793
|
+
|
|
794
|
+
# 1. Check for local override in /tmp/overrides (development mode)
|
|
795
|
+
override_dir = "/tmp/overrides"
|
|
796
|
+
if Dir.exist?(override_dir)
|
|
797
|
+
local_override = File.join(override_dir, "agent-bridge-linux-#{arch}")
|
|
798
|
+
if File.exist?(local_override)
|
|
799
|
+
puts "🔧 Using local override: #{local_override}"
|
|
800
|
+
system("sudo cp #{local_override} #{target_dir}/agent-bridge")
|
|
801
|
+
system("sudo chmod +x #{target_dir}/agent-bridge")
|
|
802
|
+
puts "✅ agent-bridge installed from local override"
|
|
803
|
+
return download_agent_bridge_tmux!(target_dir)
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
# 2. Check if binaries are mounted at /tmp (dev mode)
|
|
808
|
+
if File.exist?("/tmp/agent-bridge")
|
|
809
|
+
puts "🔧 Installing mounted agent-bridge..."
|
|
810
|
+
system("sudo cp /tmp/agent-bridge #{target_dir}/agent-bridge")
|
|
811
|
+
system("sudo chmod +x #{target_dir}/agent-bridge")
|
|
812
|
+
puts "✅ agent-bridge installed from mount"
|
|
813
|
+
return download_agent_bridge_tmux!(target_dir)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# 3. Try Civo bucket download (production mode)
|
|
817
|
+
if ENV["CIVO_ACCESS_KEY_ID"] && ENV["CIVO_SECRET_KEY"]
|
|
818
|
+
puts "📥 Downloading agent-bridge from Civo bucket..."
|
|
819
|
+
if download_from_civo!(arch, target_dir)
|
|
820
|
+
return download_agent_bridge_tmux!(target_dir)
|
|
821
|
+
else
|
|
822
|
+
puts "⚠️ Civo download failed, falling back to GitHub..."
|
|
823
|
+
end
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
# 4. Fallback to GitHub raw URLs (legacy)
|
|
827
|
+
bridge_url = "#{YATFA_RAW_URL}/bin/agent-bridge-linux-#{arch}"
|
|
828
|
+
puts "📥 Downloading agent-bridge for linux-#{arch} from GitHub..."
|
|
829
|
+
system("sudo curl -fsSL #{bridge_url} -o #{target_dir}/agent-bridge")
|
|
830
|
+
system("sudo chmod +x #{target_dir}/agent-bridge")
|
|
831
|
+
|
|
832
|
+
puts "✅ agent-bridge installed to #{target_dir}"
|
|
833
|
+
download_agent_bridge_tmux!(target_dir)
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def download_from_civo!(arch, target_dir)
|
|
837
|
+
require "aws-sdk-s3"
|
|
838
|
+
|
|
839
|
+
region = ENV["CIVO_REGION"] || "LON1"
|
|
840
|
+
endpoint = ENV["CIVO_ENDPOINT"] || "https://objectstore.fra1.civo.com"
|
|
841
|
+
bucket = ENV["YATFA_BINARIES_BUCKET"] || "yatfa"
|
|
842
|
+
version = ENV["AGENT_BRIDGE_VERSION"] || "latest"
|
|
843
|
+
|
|
844
|
+
binary_name = "agent-bridge-linux-#{arch}"
|
|
845
|
+
key = "agent-bridge/#{version}/#{binary_name}"
|
|
846
|
+
|
|
847
|
+
puts " Region: #{region}"
|
|
848
|
+
puts " Bucket: #{bucket}"
|
|
849
|
+
puts " Version: #{version}"
|
|
850
|
+
puts " Binary: #{binary_name}"
|
|
851
|
+
|
|
852
|
+
begin
|
|
853
|
+
# Configure Civo S3-compatible client
|
|
854
|
+
Aws.config.update({
|
|
855
|
+
region: region,
|
|
856
|
+
endpoint: endpoint,
|
|
857
|
+
access_key_id: ENV["CIVO_ACCESS_KEY_ID"],
|
|
858
|
+
secret_access_key: ENV["CIVO_SECRET_KEY"]
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
s3 = Aws::S3::Resource.new
|
|
862
|
+
obj = s3.bucket(bucket).object(key)
|
|
863
|
+
|
|
864
|
+
# Generate presigned URL (valid for 1 hour)
|
|
865
|
+
url = obj.presigned_url(:get, expires_in: 3600)
|
|
866
|
+
|
|
867
|
+
puts "✅ Generated presigned URL"
|
|
868
|
+
|
|
869
|
+
# Download via curl
|
|
870
|
+
system("sudo curl -fsSL #{url} -o #{target_dir}/agent-bridge")
|
|
871
|
+
|
|
872
|
+
unless $?.success?
|
|
873
|
+
puts "❌ Failed to download agent-bridge from Civo"
|
|
874
|
+
return false
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
system("sudo chmod +x #{target_dir}/agent-bridge")
|
|
878
|
+
puts "✅ Downloaded agent-bridge from Civo"
|
|
879
|
+
return true
|
|
880
|
+
|
|
881
|
+
rescue LoadError
|
|
882
|
+
puts "⚠️ aws-sdk-s3 gem not installed"
|
|
883
|
+
return false
|
|
884
|
+
rescue Aws::S3::Errors::NoSuchKey
|
|
885
|
+
puts "❌ Version #{version} not found in bucket"
|
|
886
|
+
return false
|
|
887
|
+
rescue => e
|
|
888
|
+
puts "❌ Civo download failed: #{e.message}"
|
|
889
|
+
return false
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def download_agent_bridge_tmux!(target_dir)
|
|
894
|
+
bridge_tmux_url = "#{YATFA_RAW_URL}/bin/agent-bridge-tmux"
|
|
895
|
+
|
|
896
|
+
if File.exist?("/tmp/agent-bridge-tmux")
|
|
897
|
+
puts "🔧 Installing mounted agent-bridge-tmux..."
|
|
898
|
+
system("sudo cp /tmp/agent-bridge-tmux #{target_dir}/agent-bridge-tmux")
|
|
899
|
+
system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
|
|
900
|
+
else
|
|
901
|
+
system("sudo curl -fsSL #{bridge_tmux_url} -o #{target_dir}/agent-bridge-tmux")
|
|
902
|
+
system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
# Patch agent-bridge-tmux to force INSIDE_TMUX=1
|
|
906
|
+
# Note: This is now fixed in the repo, but we keep this for backward compatibility
|
|
907
|
+
# with older agent-bridge-tmux scripts if cached
|
|
908
|
+
puts "🔧 Patching agent-bridge-tmux to force INSIDE_TMUX=1..."
|
|
909
|
+
|
|
910
|
+
# Read the file (we can read /usr/local/bin files usually)
|
|
911
|
+
content = File.read("#{target_dir}/agent-bridge-tmux")
|
|
912
|
+
|
|
913
|
+
# Replace the command if not already present
|
|
914
|
+
unless content.include?("export INSIDE_TMUX=1")
|
|
915
|
+
new_content = content.gsub(
|
|
916
|
+
"&& agent-bridge\"",
|
|
917
|
+
"&& export INSIDE_TMUX=1 && agent-bridge\""
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
# Write to temp file
|
|
921
|
+
File.write("/tmp/agent-bridge-tmux-patched", new_content)
|
|
922
|
+
|
|
923
|
+
# Move to destination with sudo
|
|
924
|
+
system("sudo mv /tmp/agent-bridge-tmux-patched #{target_dir}/agent-bridge-tmux")
|
|
925
|
+
system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
return target_dir
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
def install_helper_script(bin_dir, script_name)
|
|
932
|
+
url = "#{YATFA_RAW_URL}/bin/#{script_name}"
|
|
933
|
+
begin
|
|
934
|
+
script_content = URI.open(url).read
|
|
935
|
+
puts " (from remote: #{url})"
|
|
936
|
+
rescue OpenURI::HTTPError, Errno::ENOENT => e
|
|
937
|
+
puts "⚠️ Failed to download #{script_name}: #{e.message}"
|
|
938
|
+
return false
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
target = File.join(bin_dir, script_name)
|
|
942
|
+
temp_script = "/tmp/#{script_name}.temp"
|
|
943
|
+
File.write(temp_script, script_content)
|
|
944
|
+
system("sudo mv #{temp_script} #{target}")
|
|
945
|
+
system("sudo chmod +x #{target}")
|
|
946
|
+
puts "✅ Installed #{script_name} to #{bin_dir}"
|
|
947
|
+
true
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# Download a file from remote URL to a destination directory
|
|
951
|
+
# @param script_name [String] filename to download
|
|
952
|
+
# @param remote_url [String] full URL to download from
|
|
953
|
+
# @param dest_dir [String] directory to save into
|
|
954
|
+
# @param executable [Boolean] whether to chmod 0755 (default: true)
|
|
955
|
+
# @param label [String] human-readable label for log messages (default: script_name)
|
|
956
|
+
# @return [String, nil] downloaded file path, or nil on failure
|
|
957
|
+
def download_file(script_name:, remote_url:, dest_dir:, executable: true, label: nil)
|
|
958
|
+
label ||= script_name
|
|
959
|
+
dest_path = File.join(dest_dir, script_name)
|
|
960
|
+
|
|
961
|
+
puts "📥 Downloading #{label}..."
|
|
962
|
+
begin
|
|
963
|
+
content = URI.open(remote_url).read
|
|
964
|
+
File.write(dest_path, content)
|
|
965
|
+
File.chmod(0755, dest_path) if executable
|
|
966
|
+
puts "✅ Installed #{label} to #{dest_path}"
|
|
967
|
+
dest_path
|
|
968
|
+
rescue OpenURI::HTTPError => e
|
|
969
|
+
puts "⚠️ Failed to download #{label}: #{e.message}"
|
|
970
|
+
nil
|
|
971
|
+
end
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
def setup_helper_scripts!
|
|
975
|
+
# Install helper scripts that skills use for command injection
|
|
976
|
+
bin_dir = "/usr/local/bin"
|
|
977
|
+
|
|
978
|
+
puts "🛠️ Setting up helper scripts..."
|
|
979
|
+
|
|
980
|
+
HELPER_SCRIPTS.each do |script_name|
|
|
981
|
+
install_helper_script(bin_dir, script_name)
|
|
982
|
+
end
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
def run_agent!(bin_dir)
|
|
986
|
+
agent_type = ENV["AGENT_TYPE"] || "agent"
|
|
987
|
+
puts ""
|
|
988
|
+
puts "🚀 Starting #{agent_type}..."
|
|
989
|
+
puts " Press Ctrl+B then D to detach from tmux"
|
|
990
|
+
puts ""
|
|
991
|
+
|
|
992
|
+
# Build environment variables to pass to agent-bridge-tmux
|
|
993
|
+
# IMPORTANT: Must pass YATFA_WS_URL, YATFA_API_URL, and YATFA_API_KEY for agent-bridge to connect
|
|
994
|
+
# Use system() with properly formatted command string
|
|
995
|
+
env_cmd = []
|
|
996
|
+
env_cmd << "AGENT_ID=#{ENV['AGENT_ID']}" if ENV["AGENT_ID"]
|
|
997
|
+
env_cmd << "YATFA_WS_URL=#{ENV['YATFA_WS_URL']}" if ENV["YATFA_WS_URL"]
|
|
998
|
+
env_cmd << "YATFA_API_URL=#{ENV['YATFA_API_URL']}" if ENV["YATFA_API_URL"]
|
|
999
|
+
env_cmd << "YATFA_API_KEY=#{ENV['YATFA_API_KEY']}" if ENV["YATFA_API_KEY"]
|
|
1000
|
+
|
|
1001
|
+
# Build the full command
|
|
1002
|
+
full_cmd = env_cmd.join(" ") + " #{bin_dir}/agent-bridge-tmux"
|
|
1003
|
+
|
|
1004
|
+
# Debug: show what command we're running
|
|
1005
|
+
puts "🔧 Debug: Running #{full_cmd}"
|
|
1006
|
+
system(full_cmd)
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# ─── Main ─────────────────────────────────────────────────────────────────
|
|
1010
|
+
|
|
1011
|
+
# Main
|
|
1012
|
+
puts "🤖 Yatfa Agent Setup"
|
|
1013
|
+
puts "====================="
|
|
1014
|
+
puts ""
|
|
1015
|
+
|
|
1016
|
+
# Claw agent has its own completely separate setup flow
|
|
1017
|
+
if ENV["AGENT_TYPE"] == "claw"
|
|
1018
|
+
require_relative "setup-claw-agent"
|
|
1019
|
+
else
|
|
1020
|
+
check_env!
|
|
1021
|
+
fetch_agent_id!
|
|
1022
|
+
setup_mcp_config!
|
|
1023
|
+
setup_system_prompt!
|
|
1024
|
+
# Clone repositories BEFORE setting up hooks that depend on them
|
|
1025
|
+
setup_github_auth!
|
|
1026
|
+
setup_git_config!
|
|
1027
|
+
clone_repositories!
|
|
1028
|
+
setup_claude_config!
|
|
1029
|
+
setup_claude_settings!
|
|
1030
|
+
# setup_skill_hooks! # Deprecated in favor of global hooks
|
|
1031
|
+
setup_skills!
|
|
1032
|
+
setup_community_skills!
|
|
1033
|
+
setup_git_hooks!
|
|
1034
|
+
setup_helper_scripts!
|
|
1035
|
+
# prepare_git_state! is now in entrypoint.sh
|
|
1036
|
+
bin_dir = download_agent_bridge!
|
|
1037
|
+
run_agent!(bin_dir)
|
|
1038
|
+
end
|