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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yatfa",
3
- "version": "1.0.88",
3
+ "version": "1.0.90",
4
4
  "description": "YATFA - Yet Another Tool For Agents",
5
5
  "bin": "./bin/run-yatfa-agent.rb",
6
6
  "files": [
@@ -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