yatfa 1.0.91 → 1.0.93

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/agents.rb CHANGED
@@ -19,19 +19,19 @@ AGENT_CONFIGS = {
19
19
 
20
20
  'worker' => {
21
21
  name: 'yatfa-autonomous-worker',
22
- skills: ['git-workflow', 'worker-workflow', 'memory', 'session-learnings', 'screenshot', 'agent-browser'],
22
+ skills: ['git-workflow', 'worker-workflow', 'memory', 'session-learnings', 'screenshot', 'agent-browser', 'test-discovery'],
23
23
  prompt_name: 'worker'
24
24
  },
25
25
 
26
26
  'reviewer' => {
27
27
  name: 'yatfa-autonomous-reviewer',
28
- skills: ['reviewer-workflow', 'qa-workflow', 'memory', 'session-learnings', 'proposal-reviewer', 'screenshot', 'agent-browser'],
28
+ skills: ['reviewer-workflow', 'qa-workflow', 'memory', 'session-learnings', 'proposal-reviewer', 'screenshot', 'agent-browser', 'test-discovery'],
29
29
  prompt_name: 'reviewer'
30
30
  },
31
31
 
32
32
  'researcher' => {
33
33
  name: 'yatfa-autonomous-researcher',
34
- skills: ['researcher-tactical', 'researcher-strategic', 'researcher-digest', 'researcher-telegram-processor', 'researcher-meta-learning', 'researcher-approval-review', 'researcher-confirm-review', 'researcher-task-proposal-review', 'memory', 'session-learnings', 'proposal-execution', 'proposal-rework-responder', 'memory-consolidation', 'retrospective', 'knowledge-management', 'sentry-investigation', 'agent-browser'],
34
+ skills: ['researcher-tactical', 'researcher-strategic', 'researcher-digest', 'researcher-telegram-processor', 'researcher-meta-learning', 'researcher-approval-review', 'researcher-confirm-review', 'researcher-task-proposal-review', 'memory', 'session-learnings', 'proposal-execution', 'proposal-rework-responder', 'memory-consolidation', 'retrospective', 'knowledge-management', 'sentry-investigation', 'agent-browser', 'test-discovery'],
35
35
  prompt_name: 'researcher'
36
36
  },
37
37
 
@@ -13,6 +13,7 @@
13
13
 
14
14
  require_relative "../lib/yatfa_agent/config"
15
15
  require_relative "../lib/yatfa_agent/docker"
16
+ require_relative "../lib/yatfa_agent/http"
16
17
  require_relative "../lib/yatfa_agent/agent"
17
18
  require_relative "../lib/yatfa_agent/setup"
18
19
  require_relative "../lib/yatfa_agent/updater"
@@ -27,12 +28,8 @@ def current_version
27
28
  end
28
29
 
29
30
  def latest_npm_version
30
- require "net/http"
31
- require "json"
32
- uri = URI("https://registry.npmjs.org/yatfa/latest")
33
- response = Net::HTTP.get_response(uri)
34
- return nil unless response.is_a?(Net::HTTPSuccess)
35
- JSON.parse(response.body)["version"]
31
+ data = YatfaAgent::HTTP.get("https://registry.npmjs.org/yatfa/latest")
32
+ data&.dig("version")
36
33
  rescue StandardError
37
34
  nil
38
35
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "http"
4
5
 
5
6
  module YatfaAgent
6
7
  module Config
@@ -122,49 +123,54 @@ module YatfaAgent
122
123
 
123
124
  return unless mcp_api_key
124
125
 
125
- # First, discover which project we belong to via /whoami
126
- require "net/http"
127
- require "json"
128
- require "uri"
126
+ auth_headers = { "X-API-Key" => mcp_api_key, "Accept" => "application/json" }
129
127
 
130
- whoami_uri = URI("#{api_base}/whoami")
131
- whoami_response = Net::HTTP.get_response(whoami_uri, { "X-API-Key" => mcp_api_key, "Accept" => "application/json" })
128
+ # First, discover which project we belong to via /whoami
129
+ begin
130
+ agent_info = YatfaAgent::HTTP.get("#{api_base}/whoami", headers: auth_headers)
132
131
 
133
- if whoami_response.code != "200"
134
- puts "⚠️ Unable to discover project from /whoami (HTTP #{whoami_response.code})"
132
+ # Under the raise-based HTTP contract, a nil result here means an empty
133
+ # 2xx body (real failures raise YatfaAgent::HTTP::Error below).
134
+ unless agent_info
135
+ puts "⚠️ Empty response from /whoami, skipping repository fetch"
136
+ return
137
+ end
138
+ rescue YatfaAgent::HTTP::Error => e
139
+ puts "⚠️ Unable to discover project from /whoami (HTTP #{e.status_code})"
140
+ return
141
+ rescue StandardError => e
142
+ puts "⚠️ Unable to discover project from /whoami: #{e.message}"
135
143
  return
136
144
  end
137
145
 
138
- agent_info = JSON.parse(whoami_response.body)
139
146
  project_id = agent_info["project_id"]
140
147
  project_name = agent_info["name"] rescue "unknown"
141
148
 
142
149
  puts "🔑 Authenticated as #{agent_info["name"]} (project #{project_id})"
143
150
 
144
151
  # Then fetch repositories from Rails API (scoped to our project)
145
- uri = URI("#{api_base}/repositories")
146
152
  begin
147
- response = Net::HTTP.get_response(uri, { "X-API-Key" => mcp_api_key, "Accept" => "application/json" })
148
-
149
- if response.code == "200"
150
- repositories = JSON.parse(response.body)
153
+ repositories = YatfaAgent::HTTP.get("#{api_base}/repositories", headers: auth_headers)
151
154
 
152
- if repositories.any?
153
- config["repositories"] = repositories
155
+ if repositories.is_a?(Array) && repositories.any?
156
+ config["repositories"] = repositories
154
157
 
155
- # Build REPOSITORIES_CONTEXT string: "name:path,name:path"
156
- repo_context = repositories.map { |r| "#{r['name']}:#{r['path_in_sandbox']}" }.join(",")
157
- config["env"] ||= {}
158
- config["env"]["REPOSITORIES_CONTEXT"] = repo_context
158
+ # Build REPOSITORIES_CONTEXT string: "name:path,name:path"
159
+ repo_context = repositories.map { |r| "#{r['name']}:#{r['path_in_sandbox']}" }.join(",")
160
+ config["env"] ||= {}
161
+ config["env"]["REPOSITORIES_CONTEXT"] = repo_context
159
162
 
160
- puts "📚 Loaded #{repositories.count} repositories for project #{project_id} (#{project_name})"
161
- repositories.each do |repo|
162
- puts " - #{repo['name']}: #{repo['path_in_sandbox']}"
163
- end
163
+ puts "📚 Loaded #{repositories.count} repositories for project #{project_id} (#{project_name})"
164
+ repositories.each do |repo|
165
+ puts " - #{repo['name']}: #{repo['path_in_sandbox']}"
164
166
  end
165
- else
166
- puts "⚠️ Unable to fetch repositories (HTTP #{response.code})"
167
+ elsif repositories.nil?
168
+ # Empty 2xx body the raise-based contract returns nil only then.
169
+ puts "⚠️ Empty response when fetching repositories"
167
170
  end
171
+ # An empty Array ([]) means no repositories are configured; nothing to do.
172
+ rescue YatfaAgent::HTTP::Error => e
173
+ puts "⚠️ Failed to fetch repositories (HTTP #{e.status_code})"
168
174
  rescue StandardError => e
169
175
  puts "⚠️ Failed to fetch repositories: #{e.message}"
170
176
  end
@@ -1,10 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "net/http"
5
4
  require "json"
6
5
  require "time"
7
- require "uri"
6
+ require_relative "http"
8
7
 
9
8
  module YatfaAgent
10
9
  module Docker
@@ -24,12 +23,16 @@ module YatfaAgent
24
23
  end
25
24
 
26
25
  def self.fetch_remote_version
27
- uri = URI(VERSION_URL)
28
- response = Net::HTTP.get_response(uri)
29
- return nil unless response.is_a?(Net::HTTPSuccess)
30
- data = JSON.parse(response.body)
31
- data["version"]
32
- rescue StandardError
26
+ data = YatfaAgent::HTTP.get(VERSION_URL)
27
+ data&.dig("version")
28
+ rescue YatfaAgent::HTTP::Error => e
29
+ # Non-2xx response: surface the status so a version-fetch failure is
30
+ # distinguishable from "no remote update" (which returns nil on a 2xx).
31
+ warn "⚠️ Remote version check failed (HTTP #{e.status_code})"
32
+ nil
33
+ rescue StandardError => e
34
+ # Genuine network/timeout error (no HTTP status code available).
35
+ warn "⚠️ Remote version check failed (#{e.class}: #{e.message})"
33
36
  nil
34
37
  end
35
38
 
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module YatfaAgent
8
+ module HTTP
9
+ DEFAULT_OPEN_TIMEOUT = 5
10
+ DEFAULT_READ_TIMEOUT = 10
11
+
12
+ # Custom error for HTTP failures with response details.
13
+ # Raised for non-2xx responses so callers can access status code and body.
14
+ class Error < StandardError
15
+ attr_reader :status_code, :response_body
16
+
17
+ # @param message [String] error message (e.g., "HTTP 401 Unauthorized")
18
+ # @param status_code [Integer] HTTP status code
19
+ # @param response_body [String] raw response body
20
+ def initialize(message, status_code:, response_body:)
21
+ super(message)
22
+ @status_code = status_code
23
+ @response_body = response_body
24
+ end
25
+ end
26
+
27
+ # Perform a GET request. Returns parsed JSON body on success.
28
+ # Raises YatfaAgent::HTTP::Error for non-2xx responses.
29
+ # Raises exceptions on network/timeout errors.
30
+ #
31
+ # @param url [String] full URL to request
32
+ # @param headers [Hash] HTTP headers (e.g., { "X-API-Key" => "key" })
33
+ # @param open_timeout [Integer] connection timeout in seconds (default: 5)
34
+ # @param read_timeout [Integer] response timeout in seconds (default: 10)
35
+ # @return [Hash, Array, nil] parsed JSON response, or nil for empty 2xx responses
36
+ # @raise [Error] for non-2xx HTTP responses
37
+ def self.get(url, headers: {}, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
38
+ uri = URI(url)
39
+ http = build_http(uri, open_timeout: open_timeout, read_timeout: read_timeout)
40
+ request = Net::HTTP::Get.new(uri)
41
+ headers.each { |k, v| request[k] = v }
42
+ response = http.request(request)
43
+ handle_response(response)
44
+ end
45
+
46
+ # Perform a POST request. Returns parsed JSON body on success.
47
+ # Raises YatfaAgent::HTTP::Error for non-2xx responses.
48
+ # Raises exceptions on network/timeout errors.
49
+ #
50
+ # @param url [String] full URL to request
51
+ # @param body [Hash, String] request body (Hash is auto-serialized to JSON)
52
+ # @param headers [Hash] HTTP headers
53
+ # @param open_timeout [Integer] connection timeout in seconds (default: 5)
54
+ # @param read_timeout [Integer] response timeout in seconds (default: 10)
55
+ # @return [Hash, Array, nil] parsed JSON response, or nil for empty 2xx responses
56
+ # @raise [Error] for non-2xx HTTP responses
57
+ def self.post(url, body:, headers: {}, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
58
+ uri = URI(url)
59
+ http = build_http(uri, open_timeout: open_timeout, read_timeout: read_timeout)
60
+ request = Net::HTTP::Post.new(uri)
61
+ request["Content-Type"] = "application/json" unless headers.key?("Content-Type")
62
+ headers.each { |k, v| request[k] = v }
63
+ request.body = body.is_a?(String) ? body : JSON.generate(body)
64
+ response = http.request(request)
65
+ handle_response(response)
66
+ end
67
+
68
+ class << self
69
+ private
70
+
71
+ def build_http(uri, open_timeout:, read_timeout:)
72
+ http = Net::HTTP.new(uri.host, uri.port)
73
+ http.use_ssl = uri.scheme == "https"
74
+ http.open_timeout = open_timeout
75
+ http.read_timeout = read_timeout
76
+ http
77
+ end
78
+
79
+ def parse_json(body)
80
+ return nil if body.nil? || body.empty?
81
+ JSON.parse(body)
82
+ end
83
+
84
+ def handle_response(response)
85
+ if response.is_a?(Net::HTTPSuccess)
86
+ parse_json(response.body)
87
+ else
88
+ raise Error.new(
89
+ "HTTP #{response.code} #{response.message}",
90
+ status_code: response.code.to_i,
91
+ response_body: response.body
92
+ )
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
3
  require "uri"
5
4
  require "json"
6
5
  require "readline"
7
6
  require "fileutils"
7
+ require_relative "http"
8
8
 
9
9
  module YatfaAgent
10
10
  module Setup
@@ -581,21 +581,25 @@ module YatfaAgent
581
581
  # Helper methods
582
582
 
583
583
  def validate_api_key(api_key, yatfa_url)
584
- # Derive API URL
585
584
  api_base = "#{yatfa_url}/api/v1"
586
- uri = URI("#{api_base}/whoami")
587
-
588
- begin
589
- response = Net::HTTP.get_response(
590
- uri,
591
- { "X-API-Key" => api_key, "Accept" => "application/json" }
592
- )
593
-
594
- response.code == "200"
595
- rescue StandardError => e
596
- @errors << "API key validation failed: #{e.message}"
597
- false
585
+ result = YatfaAgent::HTTP.get(
586
+ "#{api_base}/whoami",
587
+ headers: { "X-API-Key" => api_key, "Accept" => "application/json" }
588
+ )
589
+ !result.nil?
590
+ rescue YatfaAgent::HTTP::Error => e
591
+ case e.status_code
592
+ when 401, 403
593
+ @errors << "API key is invalid or unauthorized (HTTP #{e.status_code})"
594
+ when 500..599
595
+ @errors << "Yatfa server returned HTTP #{e.status_code}; service may be unavailable, please retry"
596
+ else
597
+ @errors << "API key validation failed (HTTP #{e.status_code})"
598
598
  end
599
+ false
600
+ rescue StandardError => e
601
+ @errors << "API key validation failed: #{e.message}"
602
+ false
599
603
  end
600
604
 
601
605
  def validate_github_credentials
@@ -625,16 +629,25 @@ module YatfaAgent
625
629
 
626
630
  # If gh CLI is not configured, try direct API call
627
631
  unless status.success?
628
- uri = URI("https://api.github.com/user")
629
- response = Net::HTTP.get_response(
630
- uri,
631
- { "Authorization" => "Bearer #{token}", "Accept" => "application/json" }
632
+ result = YatfaAgent::HTTP.get(
633
+ "https://api.github.com/user",
634
+ headers: { "Authorization" => "Bearer #{token}", "Accept" => "application/json" }
632
635
  )
633
636
 
634
- return response.code == "200"
637
+ return !result.nil?
635
638
  end
636
639
 
637
640
  true
641
+ rescue YatfaAgent::HTTP::Error => e
642
+ case e.status_code
643
+ when 401, 403
644
+ @errors << "GitHub token is invalid or unauthorized (HTTP #{e.status_code})"
645
+ when 500..599
646
+ @errors << "GitHub API returned HTTP #{e.status_code}; service may be unavailable, please retry"
647
+ else
648
+ @errors << "GitHub token validation failed (HTTP #{e.status_code})"
649
+ end
650
+ false
638
651
  end
639
652
 
640
653
  def validate_github_app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yatfa",
3
- "version": "1.0.91",
3
+ "version": "1.0.93",
4
4
  "description": "YATFA - Yet Another Tool For Agents",
5
5
  "bin": "./bin/run-yatfa-agent.rb",
6
6
  "files": [
@@ -20,7 +20,7 @@
20
20
 
21
21
  # Helper scripts installed into /usr/local/bin by setup_helper_scripts!
22
22
  # Single source of truth — deploy/deploy reads this list too.
23
- HELPER_SCRIPTS = %w[fetch-skill-variant.sh fetch-review-context.sh attach-screenshot.sh].freeze
23
+ HELPER_SCRIPTS = %w[fetch-skill-variant.sh fetch-review-context.sh attach-screenshot.sh test-discovery].freeze
24
24
 
25
25
  def download_agent_bridge!
26
26
  # Detect architecture
@@ -42,8 +42,8 @@ def download_agent_bridge!
42
42
  local_override = File.join(override_dir, "agent-bridge-linux-#{arch}")
43
43
  if File.exist?(local_override)
44
44
  puts "🔧 Using local override: #{local_override}"
45
- system("sudo cp #{local_override} #{target_dir}/agent-bridge")
46
- system("sudo chmod +x #{target_dir}/agent-bridge")
45
+ system("sudo", "cp", local_override, "#{target_dir}/agent-bridge")
46
+ system("sudo", "chmod", "+x", "#{target_dir}/agent-bridge")
47
47
  puts "✅ agent-bridge installed from local override"
48
48
  return download_agent_bridge_tmux!(target_dir)
49
49
  end
@@ -52,8 +52,8 @@ def download_agent_bridge!
52
52
  # 2. Check if binaries are mounted at /tmp (dev mode)
53
53
  if File.exist?("/tmp/agent-bridge")
54
54
  puts "🔧 Installing mounted agent-bridge..."
55
- system("sudo cp /tmp/agent-bridge #{target_dir}/agent-bridge")
56
- system("sudo chmod +x #{target_dir}/agent-bridge")
55
+ system("sudo", "cp", "/tmp/agent-bridge", "#{target_dir}/agent-bridge")
56
+ system("sudo", "chmod", "+x", "#{target_dir}/agent-bridge")
57
57
  puts "✅ agent-bridge installed from mount"
58
58
  return download_agent_bridge_tmux!(target_dir)
59
59
  end
@@ -71,8 +71,8 @@ def download_agent_bridge!
71
71
  # 4. Fallback to GitHub raw URLs (legacy)
72
72
  bridge_url = "#{YATFA_RAW_URL}/bin/agent-bridge-linux-#{arch}"
73
73
  puts "📥 Downloading agent-bridge for linux-#{arch} from GitHub..."
74
- system("sudo curl -fsSL #{bridge_url} -o #{target_dir}/agent-bridge")
75
- system("sudo chmod +x #{target_dir}/agent-bridge")
74
+ system("sudo", "curl", "-fsSL", bridge_url, "-o", "#{target_dir}/agent-bridge")
75
+ system("sudo", "chmod", "+x", "#{target_dir}/agent-bridge")
76
76
 
77
77
  puts "✅ agent-bridge installed to #{target_dir}"
78
78
  download_agent_bridge_tmux!(target_dir)
@@ -119,7 +119,7 @@ def download_from_civo!(arch, target_dir)
119
119
  return false
120
120
  end
121
121
 
122
- system("sudo chmod +x #{target_dir}/agent-bridge")
122
+ system("sudo", "chmod", "+x", "#{target_dir}/agent-bridge")
123
123
  puts "✅ Downloaded agent-bridge from Civo"
124
124
  return true
125
125
 
@@ -140,11 +140,11 @@ def download_agent_bridge_tmux!(target_dir)
140
140
 
141
141
  if File.exist?("/tmp/agent-bridge-tmux")
142
142
  puts "🔧 Installing mounted agent-bridge-tmux..."
143
- system("sudo cp /tmp/agent-bridge-tmux #{target_dir}/agent-bridge-tmux")
144
- system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
143
+ system("sudo", "cp", "/tmp/agent-bridge-tmux", "#{target_dir}/agent-bridge-tmux")
144
+ system("sudo", "chmod", "+x", "#{target_dir}/agent-bridge-tmux")
145
145
  else
146
- system("sudo curl -fsSL #{bridge_tmux_url} -o #{target_dir}/agent-bridge-tmux")
147
- system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
146
+ system("sudo", "curl", "-fsSL", bridge_tmux_url, "-o", "#{target_dir}/agent-bridge-tmux")
147
+ system("sudo", "chmod", "+x", "#{target_dir}/agent-bridge-tmux")
148
148
  end
149
149
 
150
150
  # Patch agent-bridge-tmux to force INSIDE_TMUX=1
@@ -166,8 +166,8 @@ def download_agent_bridge_tmux!(target_dir)
166
166
  File.write("/tmp/agent-bridge-tmux-patched", new_content)
167
167
 
168
168
  # Move to destination with sudo
169
- system("sudo mv /tmp/agent-bridge-tmux-patched #{target_dir}/agent-bridge-tmux")
170
- system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
169
+ system("sudo", "mv", "/tmp/agent-bridge-tmux-patched", "#{target_dir}/agent-bridge-tmux")
170
+ system("sudo", "chmod", "+x", "#{target_dir}/agent-bridge-tmux")
171
171
  end
172
172
 
173
173
  return target_dir
@@ -186,8 +186,8 @@ def install_helper_script(bin_dir, script_name)
186
186
  target = File.join(bin_dir, script_name)
187
187
  temp_script = "/tmp/#{script_name}.temp"
188
188
  File.write(temp_script, script_content)
189
- system("sudo mv #{temp_script} #{target}")
190
- system("sudo chmod +x #{target}")
189
+ system("sudo", "mv", temp_script, target)
190
+ system("sudo", "chmod", "+x", target)
191
191
  puts "✅ Installed #{script_name} to #{bin_dir}"
192
192
  true
193
193
  end
@@ -24,37 +24,35 @@ def fetch_llm_credentials!
24
24
  return nil
25
25
  end
26
26
 
27
- # Fetch LLM credentials from Rails API
28
- uri = URI("#{rails_api_url}/llm/credentials")
29
- http = Net::HTTP.new(uri.host, uri.port)
30
- http.use_ssl = uri.scheme == "https"
31
- http.open_timeout = 10
32
- http.read_timeout = 10
33
-
34
- request = Net::HTTP::Get.new(uri)
35
- request["X-API-Key"] = rails_api_key
36
- request["Accept"] = "application/json"
37
-
38
27
  begin
39
28
  puts "🔍 Fetching LLM credentials from #{rails_api_url}/llm/credentials..."
40
- response = http.request(request)
41
-
42
- if response.is_a?(Net::HTTPSuccess)
43
- data = JSON.parse(response.body)
44
- puts "✅ LLM credentials fetched successfully"
45
- data
46
- elsif response.is_a?(Net::HTTPNotFound)
47
- puts "❌ No LLM credentials configured in backend"
48
- nil
29
+ data = YatfaAgent::HTTP.get(
30
+ "#{rails_api_url}/llm/credentials",
31
+ headers: { "X-API-Key" => rails_api_key, "Accept" => "application/json" },
32
+ open_timeout: 10,
33
+ read_timeout: 10
34
+ )
35
+
36
+ # Under the raise-based HTTP contract, a nil result means an empty 2xx body
37
+ # (real failures raise YatfaAgent::HTTP::Error below).
38
+ unless data
39
+ puts "❌ Empty response from /llm/credentials; no LLM credentials configured"
40
+ return nil
41
+ end
42
+
43
+ puts "✅ LLM credentials fetched successfully"
44
+ data
45
+ rescue YatfaAgent::HTTP::Error => e
46
+ case e.status_code
47
+ when 401, 403
48
+ puts "❌ Yatfa API key or token is invalid or unauthorized (HTTP #{e.status_code})"
49
+ when 500..599
50
+ puts "❌ Yatfa server returned HTTP #{e.status_code}; service may be unavailable, please retry"
49
51
  else
50
- puts "❌ Failed to fetch LLM credentials: #{response.code} #{response.message}"
51
- puts " Response: #{response.body}"
52
- nil
52
+ puts "❌ Failed to fetch LLM credentials (HTTP #{e.status_code})"
53
53
  end
54
- rescue Net::OpenTimeout, Net::ReadTimeout => e
55
- puts "❌ Timeout fetching LLM credentials: #{e.message}"
56
54
  nil
57
- rescue => e
55
+ rescue StandardError => e
58
56
  puts "❌ Error fetching LLM credentials: #{e.message}"
59
57
  nil
60
58
  end
@@ -123,6 +121,15 @@ def setup_claude_settings!
123
121
  hooks_dir = File.join(settings_dir, "hooks")
124
122
  FileUtils.mkdir_p(hooks_dir)
125
123
 
124
+ # Download shared HTTP client for hook scripts
125
+ download_file(
126
+ script_name: "http_client.rb",
127
+ remote_url: "#{YATFA_RAW_URL}/hooks/http_client.rb",
128
+ dest_dir: hooks_dir,
129
+ executable: false,
130
+ label: "HTTP client helper"
131
+ )
132
+
126
133
  # SessionStart hook: skill knowledge injection
127
134
  install_hook!(
128
135
  settings_config, hooks_dir,
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module YatfaAgent
8
+ module HTTP
9
+ DEFAULT_OPEN_TIMEOUT = 5
10
+ DEFAULT_READ_TIMEOUT = 10
11
+
12
+ # Custom error for HTTP failures with response details.
13
+ # Raised for non-2xx responses so callers can access status code and body.
14
+ class Error < StandardError
15
+ attr_reader :status_code, :response_body
16
+
17
+ # @param message [String] error message (e.g., "HTTP 401 Unauthorized")
18
+ # @param status_code [Integer] HTTP status code
19
+ # @param response_body [String] raw response body
20
+ def initialize(message, status_code:, response_body:)
21
+ super(message)
22
+ @status_code = status_code
23
+ @response_body = response_body
24
+ end
25
+ end
26
+
27
+ # Perform a GET request. Returns parsed JSON body on success.
28
+ # Raises YatfaAgent::HTTP::Error for non-2xx responses.
29
+ # Raises exceptions on network/timeout errors.
30
+ #
31
+ # @param url [String] full URL to request
32
+ # @param headers [Hash] HTTP headers (e.g., { "X-API-Key" => "key" })
33
+ # @param open_timeout [Integer] connection timeout in seconds (default: 5)
34
+ # @param read_timeout [Integer] response timeout in seconds (default: 10)
35
+ # @return [Hash, Array, nil] parsed JSON response, or nil for empty 2xx responses
36
+ # @raise [Error] for non-2xx HTTP responses
37
+ def self.get(url, headers: {}, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
38
+ uri = URI(url)
39
+ http = build_http(uri, open_timeout: open_timeout, read_timeout: read_timeout)
40
+ request = Net::HTTP::Get.new(uri)
41
+ headers.each { |k, v| request[k] = v }
42
+ response = http.request(request)
43
+ handle_response(response)
44
+ end
45
+
46
+ # Perform a POST request. Returns parsed JSON body on success.
47
+ # Raises YatfaAgent::HTTP::Error for non-2xx responses.
48
+ # Raises exceptions on network/timeout errors.
49
+ #
50
+ # @param url [String] full URL to request
51
+ # @param body [Hash, String] request body (Hash is auto-serialized to JSON)
52
+ # @param headers [Hash] HTTP headers
53
+ # @param open_timeout [Integer] connection timeout in seconds (default: 5)
54
+ # @param read_timeout [Integer] response timeout in seconds (default: 10)
55
+ # @return [Hash, Array, nil] parsed JSON response, or nil for empty 2xx responses
56
+ # @raise [Error] for non-2xx HTTP responses
57
+ def self.post(url, body:, headers: {}, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
58
+ uri = URI(url)
59
+ http = build_http(uri, open_timeout: open_timeout, read_timeout: read_timeout)
60
+ request = Net::HTTP::Post.new(uri)
61
+ request["Content-Type"] = "application/json" unless headers.key?("Content-Type")
62
+ headers.each { |k, v| request[k] = v }
63
+ request.body = body.is_a?(String) ? body : JSON.generate(body)
64
+ response = http.request(request)
65
+ handle_response(response)
66
+ end
67
+
68
+ class << self
69
+ private
70
+
71
+ def build_http(uri, open_timeout:, read_timeout:)
72
+ http = Net::HTTP.new(uri.host, uri.port)
73
+ http.use_ssl = uri.scheme == "https"
74
+ http.open_timeout = open_timeout
75
+ http.read_timeout = read_timeout
76
+ http
77
+ end
78
+
79
+ def parse_json(body)
80
+ return nil if body.nil? || body.empty?
81
+ JSON.parse(body)
82
+ end
83
+
84
+ def handle_response(response)
85
+ if response.is_a?(Net::HTTPSuccess)
86
+ parse_json(response.body)
87
+ else
88
+ raise Error.new(
89
+ "HTTP #{response.code} #{response.message}",
90
+ status_code: response.code.to_i,
91
+ response_body: response.body
92
+ )
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -30,12 +30,12 @@ require "fileutils"
30
30
  require "pathname"
31
31
  require "open-uri"
32
32
  require "openssl"
33
- require "net/http"
34
33
  require "uri"
35
34
  require "base64"
36
35
  require "time"
37
36
  require "socket"
38
37
  require "timeout"
38
+ require_relative "http_client"
39
39
  require_relative "setup-github"
40
40
  require_relative "repositories"
41
41
  require_relative "agent_bridge"
@@ -126,36 +126,37 @@ def fetch_agent_id!
126
126
  whoami_url = "#{rails_api_url}/whoami"
127
127
  whoami_url += "?version=#{URI.encode_www_form_component(agent_version)}" if agent_version && !agent_version.empty?
128
128
 
129
- uri = URI(whoami_url)
130
- http = Net::HTTP.new(uri.host, uri.port)
131
- http.use_ssl = uri.scheme == "https"
132
-
133
- request = Net::HTTP::Get.new(uri)
134
- request["X-API-Key"] = rails_api_key
135
- request["Accept"] = "application/json"
136
-
137
129
  begin
138
130
  puts "🔍 Fetching agent ID from #{rails_api_url}/whoami..."
139
- response = http.request(request)
140
-
141
- if response.is_a?(Net::HTTPSuccess)
142
- data = JSON.parse(response.body)
143
- agent_id = data["agent_id"] || data["id"]
144
-
145
- if agent_id && !agent_id.to_s.empty?
146
- ENV["AGENT_ID"] = agent_id.to_s
147
- puts "✅ Agent ID fetched: #{agent_id}"
148
- else
149
- puts "❌ Could not find agent_id in API response"
150
- puts " Response: #{response.body}"
151
- exit 1
152
- end
131
+ data = YatfaAgent::HTTP.get(whoami_url, headers: { "X-API-Key" => rails_api_key, "Accept" => "application/json" })
132
+
133
+ # Under the raise-based HTTP contract, a nil result means an empty 2xx body
134
+ # (real failures raise YatfaAgent::HTTP::Error below).
135
+ unless data
136
+ puts "❌ Empty response from /whoami; cannot determine agent ID"
137
+ exit 1
138
+ end
139
+
140
+ agent_id = data["agent_id"] || data["id"]
141
+
142
+ if agent_id && !agent_id.to_s.empty?
143
+ ENV["AGENT_ID"] = agent_id.to_s
144
+ puts "✅ Agent ID fetched: #{agent_id}"
153
145
  else
154
- puts "❌ Failed to fetch agent ID: #{response.code} #{response.message}"
155
- puts " Response: #{response.body}"
146
+ puts "❌ Could not find agent_id in API response"
156
147
  exit 1
157
148
  end
158
- rescue => e
149
+ rescue YatfaAgent::HTTP::Error => e
150
+ case e.status_code
151
+ when 401, 403
152
+ puts "❌ Yatfa API key or token is invalid or unauthorized (HTTP #{e.status_code})"
153
+ when 500..599
154
+ puts "❌ Yatfa server returned HTTP #{e.status_code}; service may be unavailable, please retry"
155
+ else
156
+ puts "❌ Failed to fetch agent ID (HTTP #{e.status_code})"
157
+ end
158
+ exit 1
159
+ rescue StandardError => e
159
160
  puts "❌ Error fetching agent ID: #{e.message}"
160
161
  puts " #{e.class}: #{e.backtrace.first}"
161
162
  exit 1
@@ -191,7 +192,7 @@ def setup_mcp_config!
191
192
 
192
193
  puts "📦 Installing yatfa-mcp..."
193
194
  # Redirect output to avoid cluttering logs, unless it fails
194
- unless system("npm install --prefix #{tools_dir} yatfa-mcp > /dev/null 2>&1")
195
+ unless system("npm", "install", "--prefix", tools_dir, "yatfa-mcp", out: "/dev/null", err: "/dev/null")
195
196
  puts "❌ Failed to install yatfa-mcp"
196
197
  end
197
198
 
@@ -261,7 +262,7 @@ def setup_git_config!
261
262
 
262
263
  # Avoid "dubious ownership" errors in containers
263
264
  # Note: Individual repository paths are added in clone_repositories! after cloning
264
- system("git config --global --add safe.directory \"#{Dir.pwd}\"")
265
+ system("git", "config", "--global", "--add", "safe.directory", Dir.pwd)
265
266
  puts "✅ Git configured to trust #{Dir.pwd}"
266
267
  end
267
268
 
@@ -308,30 +309,33 @@ end
308
309
  # ─── Main ─────────────────────────────────────────────────────────────────
309
310
 
310
311
  # Main
311
- puts "🤖 Yatfa Agent Setup"
312
- puts "====================="
313
- puts ""
314
-
315
- # Claw agent has its own completely separate setup flow
316
- if ENV["AGENT_TYPE"] == "claw"
317
- require_relative "setup-claw-agent"
318
- else
319
- check_env!
320
- fetch_agent_id!
321
- setup_mcp_config!
322
- setup_system_prompt!
323
- # Clone repositories BEFORE setting up hooks that depend on them
324
- setup_github_auth!
325
- setup_git_config!
326
- clone_repositories!
327
- setup_claude_config!
328
- setup_claude_settings!
329
- # setup_skill_hooks! # Deprecated in favor of global hooks
330
- setup_skills!
331
- setup_community_skills!
332
- setup_git_hooks!
333
- setup_helper_scripts!
334
- # prepare_git_state! is now in entrypoint.sh
335
- bin_dir = download_agent_bridge!
336
- run_agent!(bin_dir)
312
+ if __FILE__ == $PROGRAM_NAME
313
+ puts "🤖 Yatfa Agent Setup"
314
+ puts "====================="
315
+ puts ""
316
+
317
+ # Claw agent has its own completely separate setup flow
318
+ if ENV["AGENT_TYPE"] == "claw"
319
+ require_relative "setup-claw-agent"
320
+ setup_claw_agent!
321
+ else
322
+ check_env!
323
+ fetch_agent_id!
324
+ setup_mcp_config!
325
+ setup_system_prompt!
326
+ # Clone repositories BEFORE setting up hooks that depend on them
327
+ setup_github_auth!
328
+ setup_git_config!
329
+ clone_repositories!
330
+ setup_claude_config!
331
+ setup_claude_settings!
332
+ # setup_skill_hooks! # Deprecated in favor of global hooks
333
+ setup_skills!
334
+ setup_community_skills!
335
+ setup_git_hooks!
336
+ setup_helper_scripts!
337
+ # prepare_git_state! is now in entrypoint.sh
338
+ bin_dir = download_agent_bridge!
339
+ run_agent!(bin_dir)
340
+ end
337
341
  end
@@ -93,7 +93,7 @@ def install_openclaw!
93
93
  # Fix ownership of ~/.openclaw if sudo created it as root
94
94
  openclaw_dir = File.expand_path("~/.openclaw")
95
95
  if File.exist?(openclaw_dir) && Process.uid != 0
96
- system("sudo chown -R #{Process.uid}:#{Process.gid} #{openclaw_dir} 2>/dev/null")
96
+ system("sudo", "chown", "-R", "#{Process.uid}:#{Process.gid}", openclaw_dir, err: "/dev/null")
97
97
  end
98
98
  end
99
99
 
@@ -249,22 +249,7 @@ def setup_yatfa_tools_for_claw!
249
249
  # Fetch tool definitions from Rails now (during setup) so the plugin can
250
250
  # register them synchronously — register() must NOT be async.
251
251
  tool_defs_json = File.join(ext_dir, "tools.json")
252
- begin
253
- uri = URI("#{yatfa_api_url}/mcp/tools")
254
- req = Net::HTTP::Get.new(uri)
255
- req["Content-Type"] = "application/json"
256
- req["X-API-Key"] = yatfa_api_key
257
- req["Accept"] = "application/json"
258
- http = Net::HTTP.new(uri.host, uri.port)
259
- http.use_ssl = uri.scheme == "https"
260
- resp = http.request(req)
261
- tools_data = JSON.parse(resp.body)
262
- File.write(tool_defs_json, JSON.pretty_generate(tools_data["tools"] || []))
263
- puts "✅ Fetched #{(tools_data["tools"] || []).length} tool definitions from Yatfa API"
264
- rescue => e
265
- puts "⚠️ Failed to fetch tool definitions: #{e.message}"
266
- File.write(tool_defs_json, "[]")
267
- end
252
+ fetch_tool_definitions!(yatfa_api_url: yatfa_api_url, yatfa_api_key: yatfa_api_key, dest_path: tool_defs_json)
268
253
 
269
254
  File.write(plugin_js, <<~JS)
270
255
  import { t as definePluginEntry } from "#{rel_path}/#{plugin_entry_file}";
@@ -402,6 +387,38 @@ def setup_yatfa_tools_for_claw!
402
387
  puts "✅ yatfa-bridge skill installed to #{skills_dir}"
403
388
  end
404
389
 
390
+ # Fetch MCP tool definitions from the Yatfa backend and write them to +dest_path+
391
+ # as a JSON array. Called during claw setup so the OpenClaw plugin can register
392
+ # tools synchronously.
393
+ #
394
+ # Under the raise-based HTTP contract, a nil tools_data means an empty 2xx body
395
+ # (real failures raise YatfaAgent::HTTP::Error below). On any failure the
396
+ # destination is still written as "[]" so the plugin can boot with no tools.
397
+ def fetch_tool_definitions!(yatfa_api_url:, yatfa_api_key:, dest_path:)
398
+ tools_data = YatfaAgent::HTTP.get(
399
+ "#{yatfa_api_url}/mcp/tools",
400
+ headers: { "Content-Type" => "application/json", "X-API-Key" => yatfa_api_key, "Accept" => "application/json" }
401
+ )
402
+
403
+ # Empty 2xx body — raise-based contract returns nil only then.
404
+ tools_list = tools_data&.dig("tools") || []
405
+ File.write(dest_path, JSON.pretty_generate(tools_list))
406
+ puts "✅ Fetched #{tools_list.length} tool definitions from Yatfa API"
407
+ rescue YatfaAgent::HTTP::Error => e
408
+ case e.status_code
409
+ when 401, 403
410
+ puts "⚠️ Yatfa API key or token is invalid or unauthorized (HTTP #{e.status_code})"
411
+ when 500..599
412
+ puts "⚠️ Yatfa server returned HTTP #{e.status_code}; service may be unavailable, please retry"
413
+ else
414
+ puts "⚠️ Failed to fetch tool definitions (HTTP #{e.status_code})"
415
+ end
416
+ File.write(dest_path, "[]")
417
+ rescue StandardError => e
418
+ puts "⚠️ Failed to fetch tool definitions: #{e.message}"
419
+ File.write(dest_path, "[]")
420
+ end
421
+
405
422
  def run_openclaw_gateway!
406
423
  port = ENV["OPENCLAW_PORT"] || "18789"
407
424
 
@@ -491,4 +508,11 @@ end
491
508
 
492
509
  # ─── Main ─────────────────────────────────────────────────────────────────
493
510
 
494
- setup_claw_agent!
511
+ # This file is normally required by setup-agent.rb, which invokes
512
+ # setup_claw_agent! explicitly (see the claw branch there). The guard below
513
+ # preserves `ruby setup-claw-agent.rb` as a direct entry point while letting
514
+ # the file be required as a library (e.g. by the spec suite) without
515
+ # auto-running setup.
516
+ if __FILE__ == $PROGRAM_NAME
517
+ setup_claw_agent!
518
+ end
@@ -116,8 +116,8 @@ def setup_github_auth!
116
116
  # Install helper via sudo to /usr/local/bin
117
117
  helper_path = "/usr/local/bin/git-auth-helper"
118
118
  File.write("/tmp/git-auth-helper", helper_content)
119
- system("sudo mv /tmp/git-auth-helper #{helper_path}")
120
- system("sudo chmod +x #{helper_path}")
119
+ system("sudo", "mv", "/tmp/git-auth-helper", helper_path)
120
+ system("sudo", "chmod", "+x", helper_path)
121
121
 
122
122
  # Configure git (array form avoids shell interpolation of helper path)
123
123
  system("git", "config", "--global", "credential.helper", "!f() { test \"$1\" = get && echo \"protocol=https\" && echo \"host=github.com\" && echo \"username=x-access-token\" && p=$(#{helper_path}) && echo \"password=$p\"; }; f")
@@ -167,7 +167,7 @@ def install_gh_wrapper!(real_gh_path:, with_token_refresh: false, token_helper_p
167
167
 
168
168
  # Move real gh to gh.real if not already done
169
169
  unless File.exist?("#{real_gh_path}.real")
170
- system("sudo mv #{real_gh_path} #{real_gh_path}.real")
170
+ system("sudo", "mv", real_gh_path, "#{real_gh_path}.real")
171
171
  end
172
172
 
173
173
  wrapper_path = "/usr/local/bin/gh"
@@ -224,7 +224,7 @@ def install_gh_wrapper!(real_gh_path:, with_token_refresh: false, token_helper_p
224
224
  BASH
225
225
 
226
226
  File.write("/tmp/gh-wrapper", wrapper_content)
227
- system("sudo mv /tmp/gh-wrapper #{wrapper_path}")
228
- system("sudo chmod +x #{wrapper_path}")
227
+ system("sudo", "mv", "/tmp/gh-wrapper", wrapper_path)
228
+ system("sudo", "chmod", "+x", wrapper_path)
229
229
  true
230
230
  end
package/setup/skills.rb CHANGED
@@ -61,28 +61,35 @@ def fetch_community_skills!
61
61
  return []
62
62
  end
63
63
 
64
- uri = URI("#{rails_api_url}/whoami")
65
- http = Net::HTTP.new(uri.host, uri.port)
66
- http.use_ssl = uri.scheme == "https"
67
-
68
- request = Net::HTTP::Get.new(uri)
69
- request["X-API-Key"] = rails_api_key
70
- request["Accept"] = "application/json"
71
-
72
64
  begin
73
- response = http.request(request)
74
- if response.is_a?(Net::HTTPSuccess)
75
- data = JSON.parse(response.body)
76
- skills = data["community_skills"] || []
77
- if skills.any?
78
- puts "📦 Found #{skills.size} community skills: #{skills.join(', ')}"
79
- end
80
- skills
65
+ data = YatfaAgent::HTTP.get(
66
+ "#{rails_api_url}/whoami",
67
+ headers: { "X-API-Key" => rails_api_key, "Accept" => "application/json" }
68
+ )
69
+
70
+ # Under the raise-based HTTP contract, a nil result means an empty 2xx body
71
+ # (real failures raise YatfaAgent::HTTP::Error below).
72
+ unless data
73
+ puts "⚠️ Empty response from /whoami; no community skills configured"
74
+ return []
75
+ end
76
+
77
+ skills = data["community_skills"] || []
78
+ if skills.any?
79
+ puts "📦 Found #{skills.size} community skills: #{skills.join(', ')}"
80
+ end
81
+ skills
82
+ rescue YatfaAgent::HTTP::Error => e
83
+ case e.status_code
84
+ when 401, 403
85
+ puts "⚠️ Yatfa API key or token is invalid or unauthorized (HTTP #{e.status_code})"
86
+ when 500..599
87
+ puts "⚠️ Yatfa server returned HTTP #{e.status_code}; service may be unavailable, please retry"
81
88
  else
82
- puts "⚠️ Could not fetch community skills: #{response.code}"
83
- []
89
+ puts "⚠️ Failed to fetch community skills (HTTP #{e.status_code})"
84
90
  end
85
- rescue => e
91
+ []
92
+ rescue StandardError => e
86
93
  puts "⚠️ Error fetching community skills: #{e.message}"
87
94
  []
88
95
  end
@@ -28,41 +28,45 @@ def setup_system_prompt!
28
28
  # Fetch system prompt from yatfa API
29
29
  # Agent prompts are stored in yatfa/prompts/<agent-type>/base.md
30
30
  # and loaded into the database by AgentPromptLoader
31
- prompt_url = URI("#{rails_api_url}/internal/skill?tags%5B%5D=#{agent_type}&tags%5B%5D=base")
32
- http = Net::HTTP.new(prompt_url.host, prompt_url.port)
33
- http.use_ssl = prompt_url.scheme == "https"
34
- http.open_timeout = 10
35
- http.read_timeout = 15
36
-
37
- request = Net::HTTP::Get.new(prompt_url)
38
- request["X-API-Key"] = rails_api_key
39
- request["Accept"] = "application/json"
31
+ prompt_url = "#{rails_api_url}/internal/skill?tags%5B%5D=#{agent_type}&tags%5B%5D=base"
40
32
 
41
33
  banner_content = nil
42
34
  begin
43
35
  puts "📥 Fetching system prompt for #{agent_type}..."
44
- response = http.request(request)
36
+ data = YatfaAgent::HTTP.get(
37
+ prompt_url,
38
+ headers: { "X-API-Key" => rails_api_key, "Accept" => "application/json" },
39
+ open_timeout: 10,
40
+ read_timeout: 15
41
+ )
42
+
43
+ # Under the raise-based HTTP contract, a nil result means an empty 2xx body
44
+ # (real failures raise YatfaAgent::HTTP::Error below).
45
+ unless data
46
+ puts "❌ Empty response from system-prompt endpoint"
47
+ puts " Make sure AgentPromptLoader has loaded prompts/ directory"
48
+ exit 1
49
+ end
45
50
 
46
- if response.is_a?(Net::HTTPSuccess)
47
- data = JSON.parse(response.body)
48
- banner_content = data["content"]
51
+ banner_content = data["content"]
49
52
 
50
- unless banner_content && !banner_content.empty?
51
- puts "❌ System prompt for '#{agent_type}' is empty"
52
- exit 1
53
- end
53
+ unless banner_content && !banner_content.empty?
54
+ puts "❌ System prompt for '#{agent_type}' is empty"
55
+ exit 1
56
+ end
54
57
 
55
- puts "✅ Fetched system prompt for #{agent_type}"
58
+ puts "✅ Fetched system prompt for #{agent_type}"
59
+ rescue YatfaAgent::HTTP::Error => e
60
+ case e.status_code
61
+ when 401, 403
62
+ puts "❌ Yatfa API key or token is invalid or unauthorized (HTTP #{e.status_code})"
63
+ when 500..599
64
+ puts "❌ Yatfa server returned HTTP #{e.status_code}; service may be unavailable, please retry"
56
65
  else
57
- puts "❌ Failed to fetch system prompt: #{response.code} #{response.message}"
58
- puts " Response: #{response.body}"
59
- puts " Make sure AgentPromptLoader has loaded prompts/ directory"
60
- exit 1
66
+ puts "❌ Failed to fetch system prompt (HTTP #{e.status_code})"
61
67
  end
62
- rescue Net::OpenTimeout, Net::ReadTimeout => e
63
- puts "❌ Timeout fetching system prompt: #{e.message}"
64
68
  exit 1
65
- rescue => e
69
+ rescue StandardError => e
66
70
  puts "❌ Error fetching system prompt: #{e.message}"
67
71
  exit 1
68
72
  end