yatfa 1.0.79 → 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.
@@ -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