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.
- package/lib/yatfa_agent/agent.rb +12 -374
- package/lib/yatfa_agent/command_builder.rb +159 -0
- package/lib/yatfa_agent/container.rb +65 -0
- package/lib/yatfa_agent/interaction.rb +163 -0
- package/package.json +1 -1
- package/setup/agent_bridge.rb +228 -0
- package/setup/claude_settings.rb +217 -0
- package/setup/repositories.rb +98 -0
- package/setup/setup-agent.rb +25 -743
- package/setup/setup-github.rb +12 -4
- package/setup/skills.rb +136 -0
- package/setup/system_prompt.rb +110 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YatfaAgent
|
|
4
|
+
# User interaction and container lifecycle decisions.
|
|
5
|
+
# Mixed into Agent via extend — methods become available as Agent.xxx.
|
|
6
|
+
# Responsible for prompts, user input handling, and container state conflict resolution.
|
|
7
|
+
#
|
|
8
|
+
# NOTE: These methods use bare calls (e.g., `non_interactive?` not `self.non_interactive?`)
|
|
9
|
+
# because they're resolved on Agent's singleton class at runtime, where all extended
|
|
10
|
+
# module methods and Agent's own methods are available. This also ensures test stubs
|
|
11
|
+
# (via define_singleton_method on Agent) intercept internal calls correctly.
|
|
12
|
+
module Interaction
|
|
13
|
+
# Prompt user for input with a default value
|
|
14
|
+
def prompt(message, default: nil, options: nil)
|
|
15
|
+
loop do
|
|
16
|
+
print message
|
|
17
|
+
input = $stdin.gets&.strip&.downcase || ""
|
|
18
|
+
|
|
19
|
+
# Handle default on empty input
|
|
20
|
+
if input.empty? && default
|
|
21
|
+
return default
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# If options specified, validate input
|
|
25
|
+
if options
|
|
26
|
+
valid = options.any? { |opt| opt.downcase == input || opt.downcase.start_with?(input) }
|
|
27
|
+
if valid
|
|
28
|
+
# Return the full option that matches
|
|
29
|
+
return options.find { |opt| opt.downcase.start_with?(input) }
|
|
30
|
+
end
|
|
31
|
+
puts " Invalid option. Please choose: #{options.join('/')}"
|
|
32
|
+
else
|
|
33
|
+
return input
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if running in non-interactive mode
|
|
39
|
+
def non_interactive?
|
|
40
|
+
!$stdin.tty? || ENV['YATFA_NO_PROMPT'] == '1' || ARGV.include?('--no-prompt') || ARGV.include?('-d')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Handle running container: prompt user for action
|
|
44
|
+
def handle_running_container(agent_type, container_name, config, agent_configs)
|
|
45
|
+
puts "⚠️ #{agent_type} agent is already running (#{container_name})"
|
|
46
|
+
|
|
47
|
+
# Check if container was created before last deploy
|
|
48
|
+
deploy_manifest = File.join(File.dirname(__FILE__), '..', '..', 'deploy', '.deploy-manifest.json')
|
|
49
|
+
version_file = File.join(File.dirname(__FILE__), '..', '..', 'VERSION')
|
|
50
|
+
stale = false
|
|
51
|
+
|
|
52
|
+
if File.exist?(version_file)
|
|
53
|
+
current_version = File.read(version_file).strip
|
|
54
|
+
container_version = IO.popen(["docker", "exec", container_name, "printenv", "YATFA_VERSION_NUM"], err: File::NULL, &:read).strip
|
|
55
|
+
if !container_version.empty? && container_version != current_version
|
|
56
|
+
puts " 🆕 New version deployed (container: #{container_version}, current: #{current_version})"
|
|
57
|
+
stale = true
|
|
58
|
+
end
|
|
59
|
+
elsif File.exist?(deploy_manifest)
|
|
60
|
+
deploy_time = File.mtime(deploy_manifest)
|
|
61
|
+
container_created = container_creation_time(container_name)
|
|
62
|
+
if container_created && container_created < deploy_time
|
|
63
|
+
puts " 🆕 New deploy detected (container from #{format_time(container_created)}, deploy at #{format_time(deploy_time)})"
|
|
64
|
+
stale = true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
puts ""
|
|
69
|
+
|
|
70
|
+
if non_interactive?
|
|
71
|
+
puts " Running in non-interactive mode. Run 'npx yatfa #{agent_type}' to attach, or stop the container first."
|
|
72
|
+
puts " docker stop #{container_name}"
|
|
73
|
+
exit 0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
default_choice = stale ? "R" : "A"
|
|
77
|
+
choice = prompt(" [A]ttach / [R]estart / [C]ancel? [#{default_choice == 'R' ? 'a/R/c' : 'A/r/c'}]: ", default: default_choice, options: %w[A R C])
|
|
78
|
+
|
|
79
|
+
case choice
|
|
80
|
+
when "A"
|
|
81
|
+
puts ""
|
|
82
|
+
attach(agent_type, config, agent_configs)
|
|
83
|
+
when "R"
|
|
84
|
+
puts ""
|
|
85
|
+
puts "🔄 Stopping existing container..."
|
|
86
|
+
system("docker", "stop", container_name, err: File::NULL, out: File::NULL)
|
|
87
|
+
system("docker", "rm", "-f", container_name, err: File::NULL, out: File::NULL)
|
|
88
|
+
return true # Continue with start
|
|
89
|
+
when "C"
|
|
90
|
+
puts " Cancelled."
|
|
91
|
+
exit 0
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Banner file is no longer generated on the host.
|
|
96
|
+
# System prompt is generated inside the container by setup-agent.rb
|
|
97
|
+
# using AGENT_TYPE and REPOSITORIES_CONTEXT env vars.
|
|
98
|
+
|
|
99
|
+
# Handle stopped container: prompt user for action
|
|
100
|
+
def handle_stopped_container(agent_type, container_name, config, agent_configs)
|
|
101
|
+
puts "⚠️ #{agent_type} agent container exists but is stopped (#{container_name})"
|
|
102
|
+
puts ""
|
|
103
|
+
|
|
104
|
+
if non_interactive?
|
|
105
|
+
puts " Non-interactive mode: starting existing container."
|
|
106
|
+
system("docker", "start", container_name, out: File::NULL)
|
|
107
|
+
puts "✅ Agent started in background"
|
|
108
|
+
prompt_to_attach(agent_type, container_name, config, agent_configs)
|
|
109
|
+
exit 0
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
choice = prompt(" [S]tart existing / [R]ecreate / [C]ancel? [S/r/c]: ", default: "S", options: %w[S R C])
|
|
113
|
+
|
|
114
|
+
case choice
|
|
115
|
+
when "S"
|
|
116
|
+
puts ""
|
|
117
|
+
puts "🔄 Starting existing container..."
|
|
118
|
+
system("docker", "start", container_name, out: File::NULL)
|
|
119
|
+
puts "✅ Agent started in background"
|
|
120
|
+
prompt_to_attach(agent_type, container_name, config, agent_configs)
|
|
121
|
+
exit 0
|
|
122
|
+
when "R"
|
|
123
|
+
puts ""
|
|
124
|
+
puts "🔄 Removing stopped container..."
|
|
125
|
+
system("docker", "rm", "-f", container_name, err: File::NULL, out: File::NULL)
|
|
126
|
+
when "C"
|
|
127
|
+
puts " Cancelled."
|
|
128
|
+
exit 0
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Prompt user to attach after starting a new agent
|
|
133
|
+
def prompt_to_attach(agent_type, container_name, config, agent_configs)
|
|
134
|
+
if non_interactive?
|
|
135
|
+
# Non-interactive: just print helpful info
|
|
136
|
+
puts " Logs: docker logs -f #{container_name}"
|
|
137
|
+
puts " Stop: docker stop #{container_name}"
|
|
138
|
+
puts " Attach: docker exec -it #{container_name} tmux attach -t agent"
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
choice = prompt(" Agent started. Attach to session? [Y/n]: ", default: "Y", options: %w[Y N])
|
|
143
|
+
|
|
144
|
+
if choice == "Y"
|
|
145
|
+
puts ""
|
|
146
|
+
# Wait briefly for container to initialize
|
|
147
|
+
print " Waiting for agent to initialize"
|
|
148
|
+
5.times do
|
|
149
|
+
print "."
|
|
150
|
+
sleep 1
|
|
151
|
+
end
|
|
152
|
+
puts ""
|
|
153
|
+
attach(agent_type, config, agent_configs)
|
|
154
|
+
else
|
|
155
|
+
puts ""
|
|
156
|
+
puts " Agent is running in background."
|
|
157
|
+
puts " Logs: docker logs -f #{container_name}"
|
|
158
|
+
puts " Stop: docker stop #{container_name}"
|
|
159
|
+
puts " Attach: docker exec -it #{container_name} tmux attach -t agent"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
package/package.json
CHANGED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Agent bridge binary download, tmux wrapper, and helper script installation
|
|
4
|
+
# Extracted from setup-agent.rb for modularity (Round 2)
|
|
5
|
+
#
|
|
6
|
+
# Handles:
|
|
7
|
+
# - Architecture detection and multi-strategy bridge download
|
|
8
|
+
# (local override → Civo S3 presigned URL → GitHub fallback)
|
|
9
|
+
# - tmux binary download and INSIDE_TMUX=1 patching
|
|
10
|
+
# - Generic file download utility (download_file)
|
|
11
|
+
# - Helper script installation to /usr/local/bin
|
|
12
|
+
#
|
|
13
|
+
# Dependencies:
|
|
14
|
+
# - YATFA_RAW_URL constant is defined by setup-agent.rb
|
|
15
|
+
# - open-uri is loaded by setup-agent.rb before this file is required
|
|
16
|
+
#
|
|
17
|
+
# Consumers of download_file():
|
|
18
|
+
# - claude_settings.rb uses download_file() for hook installation
|
|
19
|
+
# - This file is loaded before claude_settings.rb to ensure the method exists
|
|
20
|
+
|
|
21
|
+
# Helper scripts installed into /usr/local/bin by setup_helper_scripts!
|
|
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
|
|
24
|
+
|
|
25
|
+
def download_agent_bridge!
|
|
26
|
+
# Detect architecture
|
|
27
|
+
arch = `uname -m`.strip
|
|
28
|
+
if arch == "x86_64"
|
|
29
|
+
arch = "amd64"
|
|
30
|
+
elsif arch == "aarch64" || arch == "arm64"
|
|
31
|
+
arch = "arm64"
|
|
32
|
+
else
|
|
33
|
+
puts "❌ Unsupported architecture: #{arch}"
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
target_dir = "/usr/local/bin"
|
|
38
|
+
|
|
39
|
+
# 1. Check for local override in /tmp/overrides (development mode)
|
|
40
|
+
override_dir = "/tmp/overrides"
|
|
41
|
+
if Dir.exist?(override_dir)
|
|
42
|
+
local_override = File.join(override_dir, "agent-bridge-linux-#{arch}")
|
|
43
|
+
if File.exist?(local_override)
|
|
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")
|
|
47
|
+
puts "✅ agent-bridge installed from local override"
|
|
48
|
+
return download_agent_bridge_tmux!(target_dir)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 2. Check if binaries are mounted at /tmp (dev mode)
|
|
53
|
+
if File.exist?("/tmp/agent-bridge")
|
|
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")
|
|
57
|
+
puts "✅ agent-bridge installed from mount"
|
|
58
|
+
return download_agent_bridge_tmux!(target_dir)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 3. Try Civo bucket download (production mode)
|
|
62
|
+
if ENV["CIVO_ACCESS_KEY_ID"] && ENV["CIVO_SECRET_KEY"]
|
|
63
|
+
puts "📥 Downloading agent-bridge from Civo bucket..."
|
|
64
|
+
if download_from_civo!(arch, target_dir)
|
|
65
|
+
return download_agent_bridge_tmux!(target_dir)
|
|
66
|
+
else
|
|
67
|
+
puts "⚠️ Civo download failed, falling back to GitHub..."
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 4. Fallback to GitHub raw URLs (legacy)
|
|
72
|
+
bridge_url = "#{YATFA_RAW_URL}/bin/agent-bridge-linux-#{arch}"
|
|
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")
|
|
76
|
+
|
|
77
|
+
puts "✅ agent-bridge installed to #{target_dir}"
|
|
78
|
+
download_agent_bridge_tmux!(target_dir)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def download_from_civo!(arch, target_dir)
|
|
82
|
+
require "aws-sdk-s3"
|
|
83
|
+
|
|
84
|
+
region = ENV["CIVO_REGION"] || "LON1"
|
|
85
|
+
endpoint = ENV["CIVO_ENDPOINT"] || "https://objectstore.fra1.civo.com"
|
|
86
|
+
bucket = ENV["YATFA_BINARIES_BUCKET"] || "yatfa"
|
|
87
|
+
version = ENV["AGENT_BRIDGE_VERSION"] || "latest"
|
|
88
|
+
|
|
89
|
+
binary_name = "agent-bridge-linux-#{arch}"
|
|
90
|
+
key = "agent-bridge/#{version}/#{binary_name}"
|
|
91
|
+
|
|
92
|
+
puts " Region: #{region}"
|
|
93
|
+
puts " Bucket: #{bucket}"
|
|
94
|
+
puts " Version: #{version}"
|
|
95
|
+
puts " Binary: #{binary_name}"
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
# Configure Civo S3-compatible client
|
|
99
|
+
Aws.config.update({
|
|
100
|
+
region: region,
|
|
101
|
+
endpoint: endpoint,
|
|
102
|
+
access_key_id: ENV["CIVO_ACCESS_KEY_ID"],
|
|
103
|
+
secret_access_key: ENV["CIVO_SECRET_KEY"]
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
s3 = Aws::S3::Resource.new
|
|
107
|
+
obj = s3.bucket(bucket).object(key)
|
|
108
|
+
|
|
109
|
+
# Generate presigned URL (valid for 1 hour)
|
|
110
|
+
url = obj.presigned_url(:get, expires_in: 3600)
|
|
111
|
+
|
|
112
|
+
puts "✅ Generated presigned URL"
|
|
113
|
+
|
|
114
|
+
# Download via curl (array form avoids shell interpolation of URL)
|
|
115
|
+
system("sudo", "curl", "-fsSL", url, "-o", "#{target_dir}/agent-bridge")
|
|
116
|
+
|
|
117
|
+
unless $?.success?
|
|
118
|
+
puts "❌ Failed to download agent-bridge from Civo"
|
|
119
|
+
return false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
system("sudo chmod +x #{target_dir}/agent-bridge")
|
|
123
|
+
puts "✅ Downloaded agent-bridge from Civo"
|
|
124
|
+
return true
|
|
125
|
+
|
|
126
|
+
rescue LoadError
|
|
127
|
+
puts "⚠️ aws-sdk-s3 gem not installed"
|
|
128
|
+
return false
|
|
129
|
+
rescue Aws::S3::Errors::NoSuchKey
|
|
130
|
+
puts "❌ Version #{version} not found in bucket"
|
|
131
|
+
return false
|
|
132
|
+
rescue => e
|
|
133
|
+
puts "❌ Civo download failed: #{e.message}"
|
|
134
|
+
return false
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def download_agent_bridge_tmux!(target_dir)
|
|
139
|
+
bridge_tmux_url = "#{YATFA_RAW_URL}/bin/agent-bridge-tmux"
|
|
140
|
+
|
|
141
|
+
if File.exist?("/tmp/agent-bridge-tmux")
|
|
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")
|
|
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")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Patch agent-bridge-tmux to force INSIDE_TMUX=1
|
|
151
|
+
# Note: This is now fixed in the repo, but we keep this for backward compatibility
|
|
152
|
+
# with older agent-bridge-tmux scripts if cached
|
|
153
|
+
puts "🔧 Patching agent-bridge-tmux to force INSIDE_TMUX=1..."
|
|
154
|
+
|
|
155
|
+
# Read the file (we can read /usr/local/bin files usually)
|
|
156
|
+
content = File.read("#{target_dir}/agent-bridge-tmux")
|
|
157
|
+
|
|
158
|
+
# Replace the command if not already present
|
|
159
|
+
unless content.include?("export INSIDE_TMUX=1")
|
|
160
|
+
new_content = content.gsub(
|
|
161
|
+
"&& agent-bridge\"",
|
|
162
|
+
"&& export INSIDE_TMUX=1 && agent-bridge\""
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Write to temp file
|
|
166
|
+
File.write("/tmp/agent-bridge-tmux-patched", new_content)
|
|
167
|
+
|
|
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")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
return target_dir
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def install_helper_script(bin_dir, script_name)
|
|
177
|
+
url = "#{YATFA_RAW_URL}/bin/#{script_name}"
|
|
178
|
+
begin
|
|
179
|
+
script_content = URI.open(url).read
|
|
180
|
+
puts " (from remote: #{url})"
|
|
181
|
+
rescue OpenURI::HTTPError, Errno::ENOENT => e
|
|
182
|
+
puts "⚠️ Failed to download #{script_name}: #{e.message}"
|
|
183
|
+
return false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
target = File.join(bin_dir, script_name)
|
|
187
|
+
temp_script = "/tmp/#{script_name}.temp"
|
|
188
|
+
File.write(temp_script, script_content)
|
|
189
|
+
system("sudo mv #{temp_script} #{target}")
|
|
190
|
+
system("sudo chmod +x #{target}")
|
|
191
|
+
puts "✅ Installed #{script_name} to #{bin_dir}"
|
|
192
|
+
true
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Download a file from remote URL to a destination directory
|
|
196
|
+
# @param script_name [String] filename to download
|
|
197
|
+
# @param remote_url [String] full URL to download from
|
|
198
|
+
# @param dest_dir [String] directory to save into
|
|
199
|
+
# @param executable [Boolean] whether to chmod 0755 (default: true)
|
|
200
|
+
# @param label [String] human-readable label for log messages (default: script_name)
|
|
201
|
+
# @return [String, nil] downloaded file path, or nil on failure
|
|
202
|
+
def download_file(script_name:, remote_url:, dest_dir:, executable: true, label: nil)
|
|
203
|
+
label ||= script_name
|
|
204
|
+
dest_path = File.join(dest_dir, script_name)
|
|
205
|
+
|
|
206
|
+
puts "📥 Downloading #{label}..."
|
|
207
|
+
begin
|
|
208
|
+
content = URI.open(remote_url).read
|
|
209
|
+
File.write(dest_path, content)
|
|
210
|
+
File.chmod(0755, dest_path) if executable
|
|
211
|
+
puts "✅ Installed #{label} to #{dest_path}"
|
|
212
|
+
dest_path
|
|
213
|
+
rescue OpenURI::HTTPError => e
|
|
214
|
+
puts "⚠️ Failed to download #{label}: #{e.message}"
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def setup_helper_scripts!
|
|
220
|
+
# Install helper scripts that skills use for command injection
|
|
221
|
+
bin_dir = "/usr/local/bin"
|
|
222
|
+
|
|
223
|
+
puts "🛠️ Setting up helper scripts..."
|
|
224
|
+
|
|
225
|
+
HELPER_SCRIPTS.each do |script_name|
|
|
226
|
+
install_helper_script(bin_dir, script_name)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Claude CLI settings, LLM credentials, and hook installation
|
|
4
|
+
# Extracted from setup-agent.rb for modularity
|
|
5
|
+
#
|
|
6
|
+
# Handles:
|
|
7
|
+
# - Fetching LLM credentials from the backend API (BYOK support)
|
|
8
|
+
# - Configuring ~/.claude/settings.json with env vars and hooks
|
|
9
|
+
# - Installing SessionStart/Stop hooks
|
|
10
|
+
# - Setting up the project hooks wrapper
|
|
11
|
+
#
|
|
12
|
+
# Dependencies:
|
|
13
|
+
# - download_file() is defined by agent_bridge.rb
|
|
14
|
+
# - YATFA_RAW_URL constant is defined by setup-agent.rb
|
|
15
|
+
# - Standard library requires (json, fileutils, etc.) are loaded
|
|
16
|
+
# by setup-agent.rb before this file is required
|
|
17
|
+
|
|
18
|
+
def fetch_llm_credentials!
|
|
19
|
+
rails_api_url = ENV["YATFA_API_URL"]
|
|
20
|
+
rails_api_key = ENV["YATFA_API_KEY"]
|
|
21
|
+
|
|
22
|
+
unless rails_api_url && !rails_api_url.empty? && rails_api_key && !rails_api_key.empty?
|
|
23
|
+
puts "❌ YATFA_API_URL or YATFA_API_KEY not set"
|
|
24
|
+
return nil
|
|
25
|
+
end
|
|
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
|
+
begin
|
|
39
|
+
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
|
|
49
|
+
else
|
|
50
|
+
puts "❌ Failed to fetch LLM credentials: #{response.code} #{response.message}"
|
|
51
|
+
puts " Response: #{response.body}"
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
55
|
+
puts "❌ Timeout fetching LLM credentials: #{e.message}"
|
|
56
|
+
nil
|
|
57
|
+
rescue => e
|
|
58
|
+
puts "❌ Error fetching LLM credentials: #{e.message}"
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def setup_claude_settings!
|
|
64
|
+
settings_dir = File.expand_path("~/.claude")
|
|
65
|
+
FileUtils.mkdir_p(settings_dir)
|
|
66
|
+
home_settings_json = File.join(settings_dir, "settings.json")
|
|
67
|
+
|
|
68
|
+
puts "🔧 Configuring ~/.claude/settings.json..."
|
|
69
|
+
|
|
70
|
+
settings_config = {}
|
|
71
|
+
|
|
72
|
+
# Fetch LLM credentials from Rails backend (BYOK support)
|
|
73
|
+
llm_credentials = fetch_llm_credentials!
|
|
74
|
+
|
|
75
|
+
if llm_credentials
|
|
76
|
+
puts "✅ Fetched LLM credentials from backend (source: #{llm_credentials['source']})"
|
|
77
|
+
|
|
78
|
+
# Configure Claude CLI with LLM provider environment variables
|
|
79
|
+
settings_config["env"] ||= {}
|
|
80
|
+
settings_config["env"]["ANTHROPIC_AUTH_TOKEN"] = llm_credentials["api_key"]
|
|
81
|
+
|
|
82
|
+
if llm_credentials["base_url"] && !llm_credentials["base_url"].empty?
|
|
83
|
+
settings_config["env"]["ANTHROPIC_BASE_URL"] = llm_credentials["base_url"]
|
|
84
|
+
puts " Custom base URL: #{llm_credentials['base_url']}"
|
|
85
|
+
else
|
|
86
|
+
settings_config["env"].delete("ANTHROPIC_BASE_URL") if settings_config["env"]["ANTHROPIC_BASE_URL"]
|
|
87
|
+
puts " Using default Anthropic base URL"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Configure model selection if specified
|
|
91
|
+
# Each model tier can be configured independently
|
|
92
|
+
%w[haiku sonnet opus].each do |tier|
|
|
93
|
+
key = "model_#{tier}"
|
|
94
|
+
if llm_credentials[key] && !llm_credentials[key].empty?
|
|
95
|
+
settings_config["env"]["ANTHROPIC_DEFAULT_#{tier.upcase}_MODEL"] = llm_credentials[key]
|
|
96
|
+
puts " #{tier.capitalize} model: #{llm_credentials[key]}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Configure context window if specified (value is in k-units, append 'k' suffix)
|
|
101
|
+
if llm_credentials["context_window"] && !llm_credentials["context_window"].to_s.empty?
|
|
102
|
+
context_window_value = llm_credentials["context_window"].to_i
|
|
103
|
+
if context_window_value > 0
|
|
104
|
+
settings_config["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "#{context_window_value}000"
|
|
105
|
+
puts " Context window: #{context_window_value}k tokens"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
unless %w[haiku sonnet opus].any? { |tier| llm_credentials["model_#{tier}"] }
|
|
110
|
+
puts " Using default Anthropic models"
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
puts "❌ No LLM credentials found in backend!"
|
|
114
|
+
puts " Please configure LLM API key for this agent or its project in the Yatfa dashboard."
|
|
115
|
+
puts " Agent will fail to start without credentials."
|
|
116
|
+
exit 1
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# No settings loaded from host - agent is fully self-contained
|
|
120
|
+
|
|
121
|
+
# Configure hooks
|
|
122
|
+
settings_config["hooks"] ||= {}
|
|
123
|
+
hooks_dir = File.join(settings_dir, "hooks")
|
|
124
|
+
FileUtils.mkdir_p(hooks_dir)
|
|
125
|
+
|
|
126
|
+
# SessionStart hook: skill knowledge injection
|
|
127
|
+
install_hook!(
|
|
128
|
+
settings_config, hooks_dir,
|
|
129
|
+
hook_type: "SessionStart",
|
|
130
|
+
script_name: "inject-skill-knowledge.rb",
|
|
131
|
+
match_substring: "inject-skill-knowledge.rb",
|
|
132
|
+
command_prefix: "ruby"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Install sessionstart-input.sh for repository context injection
|
|
136
|
+
download_file(
|
|
137
|
+
script_name: "sessionstart-input.sh",
|
|
138
|
+
remote_url: "#{YATFA_RAW_URL}/hooks/sessionstart-input.sh",
|
|
139
|
+
dest_dir: hooks_dir,
|
|
140
|
+
label: "SessionStart input hook"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Add agent-specific settings
|
|
144
|
+
settings_config["skipDangerousModePermissionPrompt"] = true
|
|
145
|
+
|
|
146
|
+
# Stop hook: session-end learning capture
|
|
147
|
+
install_hook!(
|
|
148
|
+
settings_config, hooks_dir,
|
|
149
|
+
hook_type: "Stop",
|
|
150
|
+
script_name: "sessionend-output.sh",
|
|
151
|
+
match_substring: "sessionend-output.sh",
|
|
152
|
+
command_prefix: "bash"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Download and register project hooks wrapper
|
|
156
|
+
# The wrapper fetches user-defined hooks from the API at runtime (with caching),
|
|
157
|
+
# executes them, and reports results back for observability.
|
|
158
|
+
setup_project_hooks_wrapper!(settings_config, hooks_dir)
|
|
159
|
+
|
|
160
|
+
File.write(home_settings_json, JSON.pretty_generate(settings_config))
|
|
161
|
+
puts "✅ ~/.claude/settings.json configured with skill hooks"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Install a Claude settings hook by downloading the script and registering it.
|
|
165
|
+
#
|
|
166
|
+
# Handles the common pattern: ensure hook array → remove old entry → download → append.
|
|
167
|
+
#
|
|
168
|
+
# @param settings_config [Hash] the settings.json hash being built
|
|
169
|
+
# @param hooks_dir [String] directory to download hook scripts into
|
|
170
|
+
# @param hook_type [String] Claude hook type (e.g. "SessionStart", "Stop")
|
|
171
|
+
# @param script_name [String] filename of the hook script to download
|
|
172
|
+
# @param match_substring [String] substring to match when removing old hook entries
|
|
173
|
+
# @param command_prefix [String] command prefix ("ruby" or "bash")
|
|
174
|
+
def install_hook!(settings_config, hooks_dir, hook_type:, script_name:, match_substring:, command_prefix:)
|
|
175
|
+
settings_config["hooks"][hook_type] ||= []
|
|
176
|
+
# Remove old hook entry if present (idempotent re-runs)
|
|
177
|
+
settings_config["hooks"][hook_type].reject! do |h|
|
|
178
|
+
h["hooks"]&.first&.dig("command")&.include?(match_substring)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
dest_path = download_file(
|
|
182
|
+
script_name: script_name,
|
|
183
|
+
remote_url: "#{YATFA_RAW_URL}/hooks/#{script_name}",
|
|
184
|
+
dest_dir: hooks_dir,
|
|
185
|
+
label: "#{hook_type} hook"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if dest_path
|
|
189
|
+
settings_config["hooks"][hook_type] << {
|
|
190
|
+
"hooks" => [{ "type" => "command", "command" => "#{command_prefix} \"#{dest_path}\"" }]
|
|
191
|
+
}
|
|
192
|
+
else
|
|
193
|
+
puts "⚠️ Skipping #{hook_type} hook configuration (download failed)"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Download and register the project hooks wrapper as a SessionStart hook.
|
|
198
|
+
# The wrapper handles fetching user-defined hooks from the API at runtime,
|
|
199
|
+
# executing them, and reporting results. Hook content is managed by users
|
|
200
|
+
# via the Yatfa config UI and stored as ProjectConfig records.
|
|
201
|
+
def setup_project_hooks_wrapper!(settings_config, hooks_dir)
|
|
202
|
+
wrapper_path = download_file(
|
|
203
|
+
script_name: "project-hooks-wrapper.rb",
|
|
204
|
+
remote_url: "#{YATFA_RAW_URL}/hooks/project-hooks-wrapper.rb",
|
|
205
|
+
dest_dir: hooks_dir,
|
|
206
|
+
label: "Project hooks wrapper"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if wrapper_path
|
|
210
|
+
settings_config["hooks"]["SessionStart"] << {
|
|
211
|
+
"hooks" => [{ "type" => "command", "command" => "ruby \"#{wrapper_path}\"" }]
|
|
212
|
+
}
|
|
213
|
+
puts "✅ Registered project hooks wrapper"
|
|
214
|
+
else
|
|
215
|
+
puts "⚠️ Skipping project hooks wrapper (download failed)"
|
|
216
|
+
end
|
|
217
|
+
end
|