yatfa 1.0.88 → 1.0.90

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.
@@ -37,16 +37,17 @@ require "time"
37
37
  require "socket"
38
38
  require "timeout"
39
39
  require_relative "setup-github"
40
+ require_relative "repositories"
41
+ require_relative "agent_bridge"
42
+ require_relative "claude_settings"
43
+ require_relative "system_prompt"
44
+ require_relative "skills"
40
45
 
41
46
  SCRIPT_DIR = File.dirname(File.expand_path(__FILE__))
42
47
  YATFA_VERSION = ENV["YATFA_VERSION"] || "main"
43
48
  YATFA_SCRIPTS_BASE_URL = "https://objectstore.fra1.civo.com/yatfa"
44
49
  YATFA_RAW_URL = "#{YATFA_SCRIPTS_BASE_URL}/#{YATFA_VERSION}"
45
50
 
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
51
  # Valid agent types
51
52
  VALID_AGENT_TYPES = %w[planner worker reviewer researcher claw]
52
53
 
@@ -132,15 +133,15 @@ def fetch_agent_id!
132
133
  request = Net::HTTP::Get.new(uri)
133
134
  request["X-API-Key"] = rails_api_key
134
135
  request["Accept"] = "application/json"
135
-
136
+
136
137
  begin
137
138
  puts "🔍 Fetching agent ID from #{rails_api_url}/whoami..."
138
139
  response = http.request(request)
139
-
140
+
140
141
  if response.is_a?(Net::HTTPSuccess)
141
142
  data = JSON.parse(response.body)
142
143
  agent_id = data["agent_id"] || data["id"]
143
-
144
+
144
145
  if agent_id && !agent_id.to_s.empty?
145
146
  ENV["AGENT_ID"] = agent_id.to_s
146
147
  puts "✅ Agent ID fetched: #{agent_id}"
@@ -164,11 +165,11 @@ end
164
165
  def setup_mcp_config!
165
166
  # MCP config is project-specific and should be provided by the Dockerfile
166
167
  # or mounted at runtime. This script only checks if it exists.
167
-
168
+
168
169
  agent_type = ENV["AGENT_TYPE"]
169
170
  rails_api_url = ENV["YATFA_API_URL"]
170
171
  rails_api_key = ENV["YATFA_API_KEY"]
171
-
172
+
172
173
  # Load existing config if present
173
174
  existing_config = {}
174
175
  if File.exist?(".mcp.json")
@@ -179,7 +180,7 @@ def setup_mcp_config!
179
180
  puts "⚠️ Existing .mcp.json is invalid, starting fresh"
180
181
  end
181
182
  end
182
-
183
+
183
184
  # Ensure mcpServers key exists
184
185
  existing_config["mcpServers"] ||= {}
185
186
 
@@ -187,13 +188,13 @@ def setup_mcp_config!
187
188
  # Install yatfa-mcp locally to ensure we can run it with node (bypassing shebang issues)
188
189
  tools_dir = File.expand_path("~/yatfa-tools")
189
190
  FileUtils.mkdir_p(tools_dir)
190
-
191
+
191
192
  puts "📦 Installing yatfa-mcp..."
192
193
  # Redirect output to avoid cluttering logs, unless it fails
193
194
  unless system("npm install --prefix #{tools_dir} yatfa-mcp > /dev/null 2>&1")
194
195
  puts "❌ Failed to install yatfa-mcp"
195
196
  end
196
-
197
+
197
198
  script_path = "#{tools_dir}/node_modules/yatfa-mcp/dist/index.js"
198
199
 
199
200
  yatfa_server_config = {
@@ -204,10 +205,10 @@ def setup_mcp_config!
204
205
  "YATFA_API_KEY" => rails_api_key
205
206
  }
206
207
  }
207
-
208
+
208
209
  # Add/Update yatfa server config
209
210
  existing_config["mcpServers"]["yatfa-#{agent_type}"] = yatfa_server_config
210
-
211
+
211
212
  File.write(".mcp.json", JSON.pretty_generate(existing_config))
212
213
  puts "📝 Updated .mcp.json with yatfa-#{agent_type} server (using yatfa-mcp)"
213
214
  else
@@ -241,523 +242,15 @@ def setup_claude_config!
241
242
  puts "✅ Created minimal claude.json with bypass permissions and workspace trust"
242
243
  end
243
244
 
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
- %w[haiku sonnet opus].each do |tier|
319
- key = "model_#{tier}"
320
- if llm_credentials[key] && !llm_credentials[key].empty?
321
- settings_config["env"]["ANTHROPIC_DEFAULT_#{tier.upcase}_MODEL"] = llm_credentials[key]
322
- puts " #{tier.capitalize} model: #{llm_credentials[key]}"
323
- end
324
- end
325
-
326
- # Configure context window if specified (value is in k-units, append 'k' suffix)
327
- if llm_credentials["context_window"] && !llm_credentials["context_window"].to_s.empty?
328
- context_window_value = llm_credentials["context_window"].to_i
329
- if context_window_value > 0
330
- settings_config["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "#{context_window_value}000"
331
- puts " Context window: #{context_window_value}k tokens"
332
- end
333
- end
334
-
335
- unless %w[haiku sonnet opus].any? { |tier| llm_credentials["model_#{tier}"] }
336
- puts " Using default Anthropic models"
337
- end
338
- else
339
- puts "❌ No LLM credentials found in backend!"
340
- puts " Please configure LLM API key for this agent or its project in the Yatfa dashboard."
341
- puts " Agent will fail to start without credentials."
342
- exit 1
343
- end
344
-
345
- # No settings loaded from host - agent is fully self-contained
346
-
347
- # Configure hooks
348
- settings_config["hooks"] ||= {}
349
- hooks_dir = File.join(settings_dir, "hooks")
350
- FileUtils.mkdir_p(hooks_dir)
351
-
352
- # SessionStart hook: skill knowledge injection
353
- install_hook!(
354
- settings_config, hooks_dir,
355
- hook_type: "SessionStart",
356
- script_name: "inject-skill-knowledge.rb",
357
- match_substring: "inject-skill-knowledge.rb",
358
- command_prefix: "ruby"
359
- )
360
-
361
- # Install sessionstart-input.sh for repository context injection
362
- download_file(
363
- script_name: "sessionstart-input.sh",
364
- remote_url: "#{YATFA_RAW_URL}/hooks/sessionstart-input.sh",
365
- dest_dir: hooks_dir,
366
- label: "SessionStart input hook"
367
- )
368
-
369
- # Add agent-specific settings
370
- settings_config["skipDangerousModePermissionPrompt"] = true
371
-
372
- # Stop hook: session-end learning capture
373
- install_hook!(
374
- settings_config, hooks_dir,
375
- hook_type: "Stop",
376
- script_name: "sessionend-output.sh",
377
- match_substring: "sessionend-output.sh",
378
- command_prefix: "bash"
379
- )
380
-
381
- # Download and register project hooks wrapper
382
- # The wrapper fetches user-defined hooks from the API at runtime (with caching),
383
- # executes them, and reports results back for observability.
384
- setup_project_hooks_wrapper!(settings_config, hooks_dir)
385
-
386
- File.write(home_settings_json, JSON.pretty_generate(settings_config))
387
- puts "✅ ~/.claude/settings.json configured with skill hooks"
388
- end
389
-
390
- # Install a Claude settings hook by downloading the script and registering it.
391
- #
392
- # Handles the common pattern: ensure hook array → remove old entry → download → append.
393
- #
394
- # @param settings_config [Hash] the settings.json hash being built
395
- # @param hooks_dir [String] directory to download hook scripts into
396
- # @param hook_type [String] Claude hook type (e.g. "SessionStart", "Stop")
397
- # @param script_name [String] filename of the hook script to download
398
- # @param match_substring [String] substring to match when removing old hook entries
399
- # @param command_prefix [String] command prefix ("ruby" or "bash")
400
- def install_hook!(settings_config, hooks_dir, hook_type:, script_name:, match_substring:, command_prefix:)
401
- settings_config["hooks"][hook_type] ||= []
402
- # Remove old hook entry if present (idempotent re-runs)
403
- settings_config["hooks"][hook_type].reject! do |h|
404
- h["hooks"]&.first&.dig("command")&.include?(match_substring)
405
- end
406
-
407
- dest_path = download_file(
408
- script_name: script_name,
409
- remote_url: "#{YATFA_RAW_URL}/hooks/#{script_name}",
410
- dest_dir: hooks_dir,
411
- label: "#{hook_type} hook"
412
- )
413
-
414
- if dest_path
415
- settings_config["hooks"][hook_type] << {
416
- "hooks" => [{ "type" => "command", "command" => "#{command_prefix} \"#{dest_path}\"" }]
417
- }
418
- else
419
- puts "⚠️ Skipping #{hook_type} hook configuration (download failed)"
420
- end
421
- end
422
-
423
- # Download and register the project hooks wrapper as a SessionStart hook.
424
- # The wrapper handles fetching user-defined hooks from the API at runtime,
425
- # executing them, and reporting results. Hook content is managed by users
426
- # via the Yatfa config UI and stored as ProjectConfig records.
427
- def setup_project_hooks_wrapper!(settings_config, hooks_dir)
428
- wrapper_path = download_file(
429
- script_name: "project-hooks-wrapper.rb",
430
- remote_url: "#{YATFA_RAW_URL}/hooks/project-hooks-wrapper.rb",
431
- dest_dir: hooks_dir,
432
- label: "Project hooks wrapper"
433
- )
434
-
435
- if wrapper_path
436
- settings_config["hooks"]["SessionStart"] << {
437
- "hooks" => [{ "type" => "command", "command" => "ruby \"#{wrapper_path}\"" }]
438
- }
439
- puts "✅ Registered project hooks wrapper"
440
- else
441
- puts "⚠️ Skipping project hooks wrapper (download failed)"
442
- end
443
- end
444
-
445
- def setup_system_prompt!
446
- agent_type = ENV["AGENT_TYPE"]
447
- system_prompt_path = File.expand_path("~/.yatfa/system-prompt.txt")
448
-
449
- rails_api_url = ENV["YATFA_API_URL"]
450
- rails_api_key = ENV["YATFA_API_KEY"]
451
-
452
- unless rails_api_url && !rails_api_url.empty? && rails_api_key && !rails_api_key.empty?
453
- puts "❌ YATFA_API_URL or YATFA_API_KEY not set"
454
- puts " Cannot fetch system prompt without API credentials"
455
- exit 1
456
- end
457
-
458
- # Fetch system prompt from yatfa API
459
- # Agent prompts are stored in yatfa/prompts/<agent-type>/base.md
460
- # and loaded into the database by AgentPromptLoader
461
- prompt_url = URI("#{rails_api_url}/internal/skill?tags%5B%5D=#{agent_type}&tags%5B%5D=base")
462
- http = Net::HTTP.new(prompt_url.host, prompt_url.port)
463
- http.use_ssl = prompt_url.scheme == "https"
464
- http.open_timeout = 10
465
- http.read_timeout = 15
466
-
467
- request = Net::HTTP::Get.new(prompt_url)
468
- request["X-API-Key"] = rails_api_key
469
- request["Accept"] = "application/json"
470
-
471
- banner_content = nil
472
- begin
473
- puts "📥 Fetching system prompt for #{agent_type}..."
474
- response = http.request(request)
475
-
476
- if response.is_a?(Net::HTTPSuccess)
477
- data = JSON.parse(response.body)
478
- banner_content = data["content"]
479
-
480
- unless banner_content && !banner_content.empty?
481
- puts "❌ System prompt for '#{agent_type}' is empty"
482
- exit 1
483
- end
484
-
485
- puts "✅ Fetched system prompt for #{agent_type}"
486
- else
487
- puts "❌ Failed to fetch system prompt: #{response.code} #{response.message}"
488
- puts " Response: #{response.body}"
489
- puts " Make sure AgentPromptLoader has loaded prompts/ directory"
490
- exit 1
491
- end
492
- rescue Net::OpenTimeout, Net::ReadTimeout => e
493
- puts "❌ Timeout fetching system prompt: #{e.message}"
494
- exit 1
495
- rescue => e
496
- puts "❌ Error fetching system prompt: #{e.message}"
497
- exit 1
498
- end
499
-
500
- # Append repository context from REPOSITORIES_CONTEXT env var
501
- if ENV["REPOSITORIES_CONTEXT"] && !ENV["REPOSITORIES_CONTEXT"].empty?
502
- repos = ENV["REPOSITORIES_CONTEXT"].split(',').map do |repo|
503
- name, path = repo.split(':')
504
- { name: name, path: path || "/workspace/#{name}" }
505
- end
506
-
507
- repo_info = repos.map { |r| " - **#{r[:name]}**: #{r[:path]}" }.join("\n")
508
-
509
- banner_content += "\n\n## REPOSITORY STRUCTURE\n\n"
510
- banner_content += "This project has #{repos.count} repositories:\n\n"
511
- banner_content += "#{repo_info}\n\n"
512
- end
513
-
514
- # Ensure directory exists and write system prompt
515
- FileUtils.mkdir_p(File.dirname(system_prompt_path))
516
- File.write(system_prompt_path, banner_content)
517
- puts "✅ System prompt written to #{system_prompt_path} (agent: #{agent_type})"
518
-
519
- # Add repository context to CLAUDE.md for easy reference
520
- claude_md = File.expand_path("~/CLAUDE.md")
521
- if ENV["REPOSITORIES_CONTEXT"] && !ENV["REPOSITORIES_CONTEXT"].empty?
522
- repo_info = ENV["REPOSITORIES_CONTEXT"].split(',').map do |repo|
523
- name, path = repo.split(':')
524
- " - **#{name}**: #{path}"
525
- end.join("\n")
526
-
527
- repo_context_md = <<~MARKDOWN
528
- ## Repository Structure
529
-
530
- This project has #{ENV["REPOSITORIES_CONTEXT"].split(',').count} repositories:
531
-
532
- #{repo_info}
533
- MARKDOWN
534
-
535
- # Append to existing CLAUDE.md or create new one
536
- existing_content = File.exist?(claude_md) ? File.read(claude_md) : ""
537
- File.write(claude_md, existing_content + "\n" + repo_context_md)
538
- puts "📝 Repository context added to ~/CLAUDE.md"
539
- end
540
- end
541
-
542
-
543
- def setup_skills!
544
- skills = ENV["SKILLS"].to_s.split(",")
545
- return if skills.empty?
546
-
547
- skills_dir = File.expand_path("~/.claude/skills")
548
- puts "🧠 Installing #{skills.size} skills to #{skills_dir}..."
549
- FileUtils.mkdir_p(skills_dir)
550
-
551
- skills.each do |skill|
552
- puts " - #{skill}"
553
-
554
- url = "#{YATFA_RAW_URL}/skills/#{skill}/SKILL.md"
555
-
556
- begin
557
- skill_content = URI.open(url).read
558
- puts " (from remote: #{url})"
559
-
560
- # Write to .claude/skills/[skill]/SKILL.md with proper structure
561
- skill_dir = File.join(skills_dir, skill)
562
- FileUtils.mkdir_p(skill_dir)
563
- File.write(File.join(skill_dir, "SKILL.md"), skill_content)
564
- rescue OpenURI::HTTPError, Errno::ENOENT => e
565
- puts "⚠️ Failed to download skill #{skill}: #{e.message}"
566
- end
567
- end
568
-
569
- puts "✅ Skills installed"
570
- end
571
-
572
- # Community skill source registry
573
- # Maps skill name to its download source configuration
574
- COMMUNITY_SKILL_SOURCES = {
575
- "ui-ux-pro-max" => {
576
- github_repo: "nextlevelbuilder/ui-ux-pro-max-skill",
577
- skill_path: ".claude/skills/ui-ux-pro-max/SKILL.md"
578
- }
579
- }.freeze
580
-
581
- # Fetch community skills assigned to this agent from the backend API
582
- def fetch_community_skills!
583
- rails_api_url = ENV["YATFA_API_URL"]
584
- rails_api_key = ENV["YATFA_API_KEY"]
585
-
586
- unless rails_api_url && !rails_api_url.empty? && rails_api_key && !rails_api_key.empty?
587
- puts "⚠️ Cannot fetch community skills: API URL or key not set"
588
- return []
589
- end
590
-
591
- uri = URI("#{rails_api_url}/whoami")
592
- http = Net::HTTP.new(uri.host, uri.port)
593
- http.use_ssl = uri.scheme == "https"
594
-
595
- request = Net::HTTP::Get.new(uri)
596
- request["X-API-Key"] = rails_api_key
597
- request["Accept"] = "application/json"
598
-
599
- begin
600
- response = http.request(request)
601
- if response.is_a?(Net::HTTPSuccess)
602
- data = JSON.parse(response.body)
603
- skills = data["community_skills"] || []
604
- if skills.any?
605
- puts "📦 Found #{skills.size} community skills: #{skills.join(', ')}"
606
- end
607
- skills
608
- else
609
- puts "⚠️ Could not fetch community skills: #{response.code}"
610
- []
611
- end
612
- rescue => e
613
- puts "⚠️ Error fetching community skills: #{e.message}"
614
- []
615
- end
616
- end
617
-
618
- # Install community skills from external sources (e.g., GitHub repos)
619
- def setup_community_skills!
620
- # Community skills can come from two sources:
621
- # 1. COMMUNITY_SKILLS env var (set during container creation from agent config)
622
- # 2. Fetched from the backend API (whoami response)
623
- env_skills = ENV["COMMUNITY_SKILLS"].to_s.split(",").map(&:strip).reject(&:blank?)
624
- api_skills = fetch_community_skills!
625
- community_skills = (env_skills + api_skills).uniq
626
-
627
- return if community_skills.empty?
628
-
629
- skills_dir = File.expand_path("~/.claude/skills")
630
- puts "🌐 Installing #{community_skills.size} community skills..."
631
- FileUtils.mkdir_p(skills_dir)
632
-
633
- community_skills.each do |skill_name|
634
- puts " - #{skill_name}"
635
-
636
- source = COMMUNITY_SKILL_SOURCES[skill_name]
637
- unless source
638
- puts "⚠️ Unknown community skill: #{skill_name} (no source configured)"
639
- next
640
- end
641
-
642
- begin
643
- # Download SKILL.md from GitHub raw URL
644
- github_repo = source[:github_repo]
645
- skill_path = source[:skill_path] || ".claude/skills/#{skill_name}/SKILL.md"
646
- url = "https://raw.githubusercontent.com/#{github_repo}/main/#{skill_path}"
647
-
648
- skill_content = URI.open(url, open_timeout: 15, read_timeout: 30).read
649
- puts " (from GitHub: #{github_repo})"
650
-
651
- # Write to .claude/skills/[skill_name]/SKILL.md
652
- skill_dir = File.join(skills_dir, skill_name)
653
- FileUtils.mkdir_p(skill_dir)
654
- File.write(File.join(skill_dir, "SKILL.md"), skill_content)
655
- rescue OpenURI::HTTPError, Errno::ENOENT => e
656
- puts "⚠️ Failed to download community skill #{skill_name}: #{e.message}"
657
- rescue => e
658
- puts "⚠️ Error installing community skill #{skill_name}: #{e.message}"
659
- end
660
- end
661
-
662
- puts "✅ Community skills installed"
663
- end
664
-
665
- def clone_repositories!
666
- return unless ENV["REPOSITORIES_CONFIG"]
667
-
668
- begin
669
- repos = JSON.parse(ENV["REPOSITORIES_CONFIG"])
670
- return unless repos.is_a?(Array) && repos.any?
671
-
672
- puts "📚 Cloning #{repos.count} repositories..."
673
-
674
- repos.each do |repo|
675
- repo_name = repo["name"]
676
- github_url = repo["github_url"]
677
- sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
678
-
679
- # Create parent directory if needed
680
- parent_dir = File.dirname(sandbox_path)
681
- FileUtils.mkdir_p(parent_dir) unless File.exist?(parent_dir)
682
-
683
- # Clone repository if it doesn't exist
684
- # Do NOT use --depth to allow full branch access for PR workflows
685
- unless File.exist?(sandbox_path)
686
- puts " 📥 Cloning #{repo_name} to #{sandbox_path}..."
687
- clone_timeout = ENV.fetch("GIT_CLONE_TIMEOUT", "180").to_i
688
- clone_success = begin
689
- Timeout.timeout(clone_timeout) do
690
- system("git", "clone", github_url, sandbox_path)
691
- end
692
- rescue Timeout::Error
693
- # Kill orphan git clone subprocess (Timeout.timeout doesn't kill children)
694
- system("pkill", "-f", "git clone #{github_url}", err: File::NULL)
695
- # Clean up partial clone directory so restart doesn't skip it
696
- FileUtils.rm_rf(sandbox_path) if File.exist?(sandbox_path)
697
- puts "❌ git clone of #{repo_name} timed out after #{clone_timeout}s"
698
- puts " The container will restart automatically."
699
- exit 1
700
- end
701
-
702
- unless clone_success
703
- puts "❌ git clone of #{repo_name} failed"
704
- puts " The container will restart automatically."
705
- exit 1
706
- end
707
- else
708
- puts " ✓ #{repo_name} already exists at #{sandbox_path}"
709
- # Fetch all remotes to ensure branches are up to date
710
- Dir.chdir(sandbox_path) do
711
- system("git", "fetch", "--all")
712
- puts " ✓ Fetched all remotes for #{repo_name}"
713
- end
714
- end
715
-
716
- # Add repository path to git safe.directory to avoid "dubious ownership" errors
717
- system("git", "config", "--global", "--add", "safe.directory", sandbox_path)
718
- puts " ✓ Added #{sandbox_path} to git safe.directory"
719
- end
720
-
721
- puts "✅ Repositories cloned"
722
-
723
- # Track if we have a single repository for later use
724
- single_repo_name = repos.count == 1 ? repos.first['name'] : nil
725
-
726
- # Write repository context to a file for hooks to read
727
- # This is more reliable than environment variables in tmux sessions
728
- if ENV["REPOSITORIES_CONTEXT"]
729
- repo_context_file = File.expand_path("~/.claude/repos_context.txt")
730
- File.write(repo_context_file, ENV["REPOSITORIES_CONTEXT"])
731
- puts "📝 Repository context written to #{repo_context_file}"
732
- end
733
-
734
- # Export REPOSITORIES_CONTEXT to bash profile for shell sessions
735
- if ENV["REPOSITORIES_CONTEXT"]
736
- profile_file = File.expand_path("~/.bashrc")
737
- existing_content = File.exist?(profile_file) ? File.read(profile_file) : ""
738
-
739
- # Remove old export line if present
740
- existing_content.gsub!(/^export REPOSITORIES_CONTEXT=.*$/, "")
741
-
742
- # Add new export at the end
743
- additions = "\nexport REPOSITORIES_CONTEXT=\"#{ENV['REPOSITORIES_CONTEXT']}\"\n"
744
- File.write(profile_file, existing_content + additions)
745
- puts "📝 REPOSITORIES_CONTEXT exported to ~/.bashrc"
746
- end
747
- rescue JSON::ParserError => e
748
- puts "⚠️ Failed to parse REPOSITORIES_CONFIG: #{e.message}"
749
- end
750
- end
751
-
752
245
  def setup_git_config!
753
246
  # Configure identity if provided
754
247
  if ENV["GIT_USER_NAME"] && !ENV["GIT_USER_NAME"].empty?
755
- system("git config --global user.name \"#{ENV['GIT_USER_NAME']}\"")
248
+ system("git", "config", "--global", "user.name", ENV["GIT_USER_NAME"])
756
249
  puts "✅ Git user.name configured"
757
250
  end
758
251
 
759
252
  if ENV["GIT_USER_EMAIL"] && !ENV["GIT_USER_EMAIL"].empty?
760
- system("git config --global user.email \"#{ENV['GIT_USER_EMAIL']}\"")
253
+ system("git", "config", "--global", "user.email", ENV["GIT_USER_EMAIL"])
761
254
  puts "✅ Git user.email configured"
762
255
  end
763
256
 
@@ -794,211 +287,6 @@ def setup_git_hooks!
794
287
  puts "✅ Git hooks installed"
795
288
  end
796
289
 
797
- def download_agent_bridge!
798
- # Detect architecture
799
- arch = `uname -m`.strip
800
- if arch == "x86_64"
801
- arch = "amd64"
802
- elsif arch == "aarch64" || arch == "arm64"
803
- arch = "arm64"
804
- else
805
- puts "❌ Unsupported architecture: #{arch}"
806
- exit 1
807
- end
808
-
809
- target_dir = "/usr/local/bin"
810
-
811
- # 1. Check for local override in /tmp/overrides (development mode)
812
- override_dir = "/tmp/overrides"
813
- if Dir.exist?(override_dir)
814
- local_override = File.join(override_dir, "agent-bridge-linux-#{arch}")
815
- if File.exist?(local_override)
816
- puts "🔧 Using local override: #{local_override}"
817
- system("sudo cp #{local_override} #{target_dir}/agent-bridge")
818
- system("sudo chmod +x #{target_dir}/agent-bridge")
819
- puts "✅ agent-bridge installed from local override"
820
- return download_agent_bridge_tmux!(target_dir)
821
- end
822
- end
823
-
824
- # 2. Check if binaries are mounted at /tmp (dev mode)
825
- if File.exist?("/tmp/agent-bridge")
826
- puts "🔧 Installing mounted agent-bridge..."
827
- system("sudo cp /tmp/agent-bridge #{target_dir}/agent-bridge")
828
- system("sudo chmod +x #{target_dir}/agent-bridge")
829
- puts "✅ agent-bridge installed from mount"
830
- return download_agent_bridge_tmux!(target_dir)
831
- end
832
-
833
- # 3. Try Civo bucket download (production mode)
834
- if ENV["CIVO_ACCESS_KEY_ID"] && ENV["CIVO_SECRET_KEY"]
835
- puts "📥 Downloading agent-bridge from Civo bucket..."
836
- if download_from_civo!(arch, target_dir)
837
- return download_agent_bridge_tmux!(target_dir)
838
- else
839
- puts "⚠️ Civo download failed, falling back to GitHub..."
840
- end
841
- end
842
-
843
- # 4. Fallback to GitHub raw URLs (legacy)
844
- bridge_url = "#{YATFA_RAW_URL}/bin/agent-bridge-linux-#{arch}"
845
- puts "📥 Downloading agent-bridge for linux-#{arch} from GitHub..."
846
- system("sudo curl -fsSL #{bridge_url} -o #{target_dir}/agent-bridge")
847
- system("sudo chmod +x #{target_dir}/agent-bridge")
848
-
849
- puts "✅ agent-bridge installed to #{target_dir}"
850
- download_agent_bridge_tmux!(target_dir)
851
- end
852
-
853
- def download_from_civo!(arch, target_dir)
854
- require "aws-sdk-s3"
855
-
856
- region = ENV["CIVO_REGION"] || "LON1"
857
- endpoint = ENV["CIVO_ENDPOINT"] || "https://objectstore.fra1.civo.com"
858
- bucket = ENV["YATFA_BINARIES_BUCKET"] || "yatfa"
859
- version = ENV["AGENT_BRIDGE_VERSION"] || "latest"
860
-
861
- binary_name = "agent-bridge-linux-#{arch}"
862
- key = "agent-bridge/#{version}/#{binary_name}"
863
-
864
- puts " Region: #{region}"
865
- puts " Bucket: #{bucket}"
866
- puts " Version: #{version}"
867
- puts " Binary: #{binary_name}"
868
-
869
- begin
870
- # Configure Civo S3-compatible client
871
- Aws.config.update({
872
- region: region,
873
- endpoint: endpoint,
874
- access_key_id: ENV["CIVO_ACCESS_KEY_ID"],
875
- secret_access_key: ENV["CIVO_SECRET_KEY"]
876
- })
877
-
878
- s3 = Aws::S3::Resource.new
879
- obj = s3.bucket(bucket).object(key)
880
-
881
- # Generate presigned URL (valid for 1 hour)
882
- url = obj.presigned_url(:get, expires_in: 3600)
883
-
884
- puts "✅ Generated presigned URL"
885
-
886
- # Download via curl
887
- system("sudo curl -fsSL #{url} -o #{target_dir}/agent-bridge")
888
-
889
- unless $?.success?
890
- puts "❌ Failed to download agent-bridge from Civo"
891
- return false
892
- end
893
-
894
- system("sudo chmod +x #{target_dir}/agent-bridge")
895
- puts "✅ Downloaded agent-bridge from Civo"
896
- return true
897
-
898
- rescue LoadError
899
- puts "⚠️ aws-sdk-s3 gem not installed"
900
- return false
901
- rescue Aws::S3::Errors::NoSuchKey
902
- puts "❌ Version #{version} not found in bucket"
903
- return false
904
- rescue => e
905
- puts "❌ Civo download failed: #{e.message}"
906
- return false
907
- end
908
- end
909
-
910
- def download_agent_bridge_tmux!(target_dir)
911
- bridge_tmux_url = "#{YATFA_RAW_URL}/bin/agent-bridge-tmux"
912
-
913
- if File.exist?("/tmp/agent-bridge-tmux")
914
- puts "🔧 Installing mounted agent-bridge-tmux..."
915
- system("sudo cp /tmp/agent-bridge-tmux #{target_dir}/agent-bridge-tmux")
916
- system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
917
- else
918
- system("sudo curl -fsSL #{bridge_tmux_url} -o #{target_dir}/agent-bridge-tmux")
919
- system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
920
- end
921
-
922
- # Patch agent-bridge-tmux to force INSIDE_TMUX=1
923
- # Note: This is now fixed in the repo, but we keep this for backward compatibility
924
- # with older agent-bridge-tmux scripts if cached
925
- puts "🔧 Patching agent-bridge-tmux to force INSIDE_TMUX=1..."
926
-
927
- # Read the file (we can read /usr/local/bin files usually)
928
- content = File.read("#{target_dir}/agent-bridge-tmux")
929
-
930
- # Replace the command if not already present
931
- unless content.include?("export INSIDE_TMUX=1")
932
- new_content = content.gsub(
933
- "&& agent-bridge\"",
934
- "&& export INSIDE_TMUX=1 && agent-bridge\""
935
- )
936
-
937
- # Write to temp file
938
- File.write("/tmp/agent-bridge-tmux-patched", new_content)
939
-
940
- # Move to destination with sudo
941
- system("sudo mv /tmp/agent-bridge-tmux-patched #{target_dir}/agent-bridge-tmux")
942
- system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
943
- end
944
-
945
- return target_dir
946
- end
947
-
948
- def install_helper_script(bin_dir, script_name)
949
- url = "#{YATFA_RAW_URL}/bin/#{script_name}"
950
- begin
951
- script_content = URI.open(url).read
952
- puts " (from remote: #{url})"
953
- rescue OpenURI::HTTPError, Errno::ENOENT => e
954
- puts "⚠️ Failed to download #{script_name}: #{e.message}"
955
- return false
956
- end
957
-
958
- target = File.join(bin_dir, script_name)
959
- temp_script = "/tmp/#{script_name}.temp"
960
- File.write(temp_script, script_content)
961
- system("sudo mv #{temp_script} #{target}")
962
- system("sudo chmod +x #{target}")
963
- puts "✅ Installed #{script_name} to #{bin_dir}"
964
- true
965
- end
966
-
967
- # Download a file from remote URL to a destination directory
968
- # @param script_name [String] filename to download
969
- # @param remote_url [String] full URL to download from
970
- # @param dest_dir [String] directory to save into
971
- # @param executable [Boolean] whether to chmod 0755 (default: true)
972
- # @param label [String] human-readable label for log messages (default: script_name)
973
- # @return [String, nil] downloaded file path, or nil on failure
974
- def download_file(script_name:, remote_url:, dest_dir:, executable: true, label: nil)
975
- label ||= script_name
976
- dest_path = File.join(dest_dir, script_name)
977
-
978
- puts "📥 Downloading #{label}..."
979
- begin
980
- content = URI.open(remote_url).read
981
- File.write(dest_path, content)
982
- File.chmod(0755, dest_path) if executable
983
- puts "✅ Installed #{label} to #{dest_path}"
984
- dest_path
985
- rescue OpenURI::HTTPError => e
986
- puts "⚠️ Failed to download #{label}: #{e.message}"
987
- nil
988
- end
989
- end
990
-
991
- def setup_helper_scripts!
992
- # Install helper scripts that skills use for command injection
993
- bin_dir = "/usr/local/bin"
994
-
995
- puts "🛠️ Setting up helper scripts..."
996
-
997
- HELPER_SCRIPTS.each do |script_name|
998
- install_helper_script(bin_dir, script_name)
999
- end
1000
- end
1001
-
1002
290
  def run_agent!(bin_dir)
1003
291
  agent_type = ENV["AGENT_TYPE"] || "agent"
1004
292
  puts ""
@@ -1006,21 +294,15 @@ def run_agent!(bin_dir)
1006
294
  puts " Press Ctrl+B then D to detach from tmux"
1007
295
  puts ""
1008
296
 
1009
- # Build environment variables to pass to agent-bridge-tmux
1010
- # IMPORTANT: Must pass YATFA_WS_URL, YATFA_API_URL, and YATFA_API_KEY for agent-bridge to connect
1011
- # Use system() with properly formatted command string
1012
- env_cmd = []
1013
- env_cmd << "AGENT_ID=#{ENV['AGENT_ID']}" if ENV["AGENT_ID"]
1014
- env_cmd << "YATFA_WS_URL=#{ENV['YATFA_WS_URL']}" if ENV["YATFA_WS_URL"]
1015
- env_cmd << "YATFA_API_URL=#{ENV['YATFA_API_URL']}" if ENV["YATFA_API_URL"]
1016
- env_cmd << "YATFA_API_KEY=#{ENV['YATFA_API_KEY']}" if ENV["YATFA_API_KEY"]
1017
-
1018
- # Build the full command
1019
- full_cmd = env_cmd.join(" ") + " #{bin_dir}/agent-bridge-tmux"
297
+ # Pass environment variables via env hash instead of command string
298
+ # to avoid exposing credentials in /proc/*/cmdline (visible via ps)
299
+ env = {}
300
+ env["AGENT_ID"] = ENV["AGENT_ID"] if ENV["AGENT_ID"]
301
+ env["YATFA_WS_URL"] = ENV["YATFA_WS_URL"] if ENV["YATFA_WS_URL"]
302
+ env["YATFA_API_URL"] = ENV["YATFA_API_URL"] if ENV["YATFA_API_URL"]
303
+ env["YATFA_API_KEY"] = ENV["YATFA_API_KEY"] if ENV["YATFA_API_KEY"]
1020
304
 
1021
- # Debug: show what command we're running
1022
- puts "🔧 Debug: Running #{full_cmd}"
1023
- system(full_cmd)
305
+ system(env, "#{bin_dir}/agent-bridge-tmux")
1024
306
  end
1025
307
 
1026
308
  # ─── Main ─────────────────────────────────────────────────────────────────