antigravity-sdk 0.3.0 → 0.5.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 665ab001fa484b0bc5ba97c2e7feb960712b0f8e5070ed521f0caf5ad0f16a58
4
- data.tar.gz: 3f556321b1083daadea10792632ac216ba168abb26708fc5cc3a81824301d460
3
+ metadata.gz: effdabdb0588788c4b168749a2578e2e1f07aa877a54bfd80fe1969d42f497d2
4
+ data.tar.gz: 3debe5e4295f8c2e2bcd4c37ec7ea52ea11a77d034370320d4064b52aa08cc29
5
5
  SHA512:
6
- metadata.gz: d60ff58a66fec4ed17a85ad51015568e743363f313eb1a27b9e6512a767ae5a7abca062796ae0bc19edbe59545202828415b693f6551b77a22d8a1ff5cca99ea
7
- data.tar.gz: 9e09ed8b23e59d29f644d10f421b73d70f357c7d9fd601ba67ba429df8c2814768d0264084e5e6439cd9161d2f8c13313f6890bcd11faf6f66f7e43e3ea2578f
6
+ metadata.gz: 4123cbc798ddbfaa82ea0f854e89adb122491d9a99b52f501c15350401e660109be3ca778b6142720f0259256992dbbb73d4d2371d8bdefcda266bd4d98591cf
7
+ data.tar.gz: d80349e13c564c74bb81c5c8ba14887f39af36507d03b3c6a5883ed3a885359570210d10b9fc1f8cc3e8a0c193408de176f3316129a58e1d6303352c5527ed01
@@ -4,27 +4,164 @@ module Antigravity
4
4
  class Agent < Base
5
5
 
6
6
  attr_accessor :model, :system_instruction, :api_key
7
- attr_reader :tools, :skills, :hooks, :sidecars, :client, :logger_guard
7
+ attr_reader :tools, :skills, :hooks, :sidecars, :client, :logger_guard,
8
+ :workspace, :connection, :conversation
8
9
 
9
- def initialize(model: nil, auto_logger: true, &block)
10
+ def initialize(model: nil, system_instruction: nil, tools: [],
11
+ skills: [], policies: [], policy: nil, workspace: nil, auto_logger: true, log_file: nil, &block)
10
12
  @model = model || Antigravity.config.default_model
11
13
  @api_key = Antigravity.config.api_key
12
- @system_instruction = nil
13
- @tools = []
14
+ @system_instruction = system_instruction
15
+ @workspace = workspace ? File.expand_path(workspace) : nil
16
+ @tools = tools.dup
14
17
  @skills = []
18
+ @policies = []
15
19
  @sidecars = []
16
20
  @hooks = Hooks.new
17
21
  @client = Client.new
18
22
  @logger_guard = nil
23
+ @connection = nil
24
+ @conversation = nil
25
+ @connected = false
26
+
27
+ # Register pre-provided tools into the tool runner
28
+ @tool_runner = ToolRunner.new
29
+ @tools.each { |t| @tool_runner.register(t) }
30
+
31
+ # Load skills provided at construction (local paths or GitHub URLs)
32
+ add_skills(skills) unless Array(skills).empty?
33
+
34
+ # Resolve policy: sugar (symbol → preset, Policy object → use directly)
35
+ if policy
36
+ resolved = policy.is_a?(Symbol) ? Policy.preset(policy) : policy
37
+ enforce(resolved)
38
+ end
39
+
40
+ # Load policies
41
+ policies.each { |p| enforce(p) }
19
42
 
20
43
  # Automagic Logger attachment unless disabled via ENV["ANTIGRAVITY_LOGGER"]=false or auto_logger: false
21
44
  if auto_logger && logger_enabled?
22
- attach_logger
45
+ attach_logger(log_file)
23
46
  end
24
47
 
25
48
  yield(self) if block_given?
26
49
  end
27
50
 
51
+ def enforce(policy)
52
+ @policies << policy
53
+ before_tool_call do |tool_name, args|
54
+ policy.evaluate(tool_name, args)
55
+ end
56
+ end
57
+
58
+ # --- Class Methods ---
59
+
60
+ # Block form: opens connection, yields agent, auto-closes.
61
+ def self.open(**kwargs, &block)
62
+ agent = new(**kwargs)
63
+ agent.connect!
64
+ begin
65
+ block.call(agent)
66
+ ensure
67
+ agent.close!
68
+ end
69
+ end
70
+
71
+ # --- Connection Lifecycle ---
72
+
73
+ def connect!
74
+ return self if @connected
75
+
76
+ # Auto-attach lifecycle logger if enabled via env
77
+ if lifecycle_logger_enabled? && !@lifecycle_attached
78
+ LifecycleLogger.attach!(self, verbose: ENV['ANTIGRAVITY_LIFECYCLE_VERBOSE'] == '1')
79
+ @lifecycle_attached = true
80
+ end
81
+
82
+ @connection = Connection::LocalConnection.new
83
+ @connection.connect!
84
+
85
+ @conversation = Conversation.new(
86
+ ws_client: @connection.ws_client,
87
+ tool_runner: @tool_runner,
88
+ hooks: @hooks
89
+ )
90
+
91
+ harness_config = build_harness_config
92
+ @conversation.initialize_session!(harness_config: harness_config)
93
+ @connected = true
94
+
95
+ # Emit session_start hook
96
+ hooks.emit(:session_start, {
97
+ model: @model,
98
+ conversation_id: conversation_id,
99
+ workspace: @workspace,
100
+ skills_count: @skills.length,
101
+ tools_count: @tools.length,
102
+ })
103
+
104
+ self
105
+ end
106
+
107
+ def connected?
108
+ @connected && @connection&.connected?
109
+ end
110
+
111
+ def close!
112
+ # Emit session_end hook before teardown
113
+ if @connected
114
+ hooks.emit(:session_end, {
115
+ turn_count: turn_count,
116
+ conversation_id: conversation_id,
117
+ })
118
+ end
119
+
120
+ @connected = false
121
+ @connection&.disconnect!
122
+ @connection = nil
123
+ @conversation = nil
124
+ end
125
+
126
+ # --- Chat ---
127
+
128
+ def prompt(message, timeout: Antigravity.config.timeout_llm, &block)
129
+ connect! unless @connected
130
+ emit_sidecar_event(:prompt_started, prompt: message)
131
+ hooks.run_pre_prompt(message)
132
+
133
+ if @connected && @conversation
134
+ response = @conversation.chat(message, timeout: timeout, &block)
135
+ else
136
+ # Legacy mock-client path (unit tests, pre-connection)
137
+ response = client.send_turn(self, message, &block)
138
+ end
139
+
140
+ hooks.run_post_response(response)
141
+ emit_sidecar_event(:turn_completed, response: response.content, model: model)
142
+
143
+ response
144
+ end
145
+ alias ask prompt
146
+
147
+ # --- Metadata Accessors (mirrors Python SDK) ---
148
+
149
+ def conversation_id
150
+ @conversation&.conversation_id
151
+ end
152
+
153
+ def turn_count
154
+ @conversation&.turn_count || 0
155
+ end
156
+
157
+ def session_summary
158
+ return {} unless @conversation
159
+
160
+ @conversation.session_summary(model: @model)
161
+ end
162
+
163
+ # --- Tool Registration ---
164
+
28
165
  def register_tool(tool_or_name = nil, description: "", &block)
29
166
  if block_given? && tool_or_name
30
167
  tool = Tool::Dynamic.new(tool_or_name, description: description, &block)
@@ -34,6 +171,7 @@ module Antigravity
34
171
  raise ArgumentError, "Invalid tool definition"
35
172
  end
36
173
  @tools << tool
174
+ @tool_runner.register(tool) if @tool_runner
37
175
  tool
38
176
  end
39
177
 
@@ -42,18 +180,61 @@ module Antigravity
42
180
  sidecar
43
181
  end
44
182
 
45
- def attach_logger(log_target = nil, level: ::Logger::INFO, silent_notice: false)
183
+ def attach_logger(log_target = nil, level: :info, silent_notice: false)
46
184
  @logger_guard = Guards::AgentLogger.new(log_target, level: level, silent_notice: silent_notice)
47
185
  @logger_guard.attach_to(self)
48
186
  @logger_guard
49
187
  end
50
188
 
51
- def load_skill(skill_path)
52
- skill = Skill.load(skill_path)
53
- @skills << skill
189
+ # Add a single skill by path or GitHub URL.
190
+ # Raises if the path resolves to multiple skills (use add_skills instead).
191
+ # @param path_or_url [String] local path or GitHub URL
192
+ # @param skill_name [String, nil] optional specific skill name within a repo
193
+ # @return [Skill] the loaded skill
194
+ def add_skill(path_or_url, skill_name: nil)
195
+ target = skill_name ? "#{path_or_url.to_s.chomp('/')}/#{skill_name}" : path_or_url.to_s
196
+ paths = SkillResolver.resolve(target)
197
+ if paths.size > 1
198
+ raise ArgumentError,
199
+ "add_skill resolved to #{paths.size} skills. Use add_skills instead, " \
200
+ "or specify skill_name: to pick one."
201
+ end
202
+ raise ArgumentError, "No skill found at #{target}" if paths.empty?
203
+
204
+ load_single_skill(paths.first)
205
+ end
206
+
207
+ # Add one or more skills by path or GitHub URL.
208
+ # Accepts a single string or an array. Each entry is resolved (may expand to multiple).
209
+ # @param paths_or_urls [String, Array<String>] local paths or GitHub URLs
210
+ # @return [Array<Skill>] all loaded skills
211
+ def add_skills(paths_or_urls)
212
+ Array(paths_or_urls).flat_map do |p|
213
+ SkillResolver.resolve(p).map { |skill_path| load_single_skill(skill_path) }
214
+ end
215
+ end
216
+
217
+ # Create and add an inline skill (no file needed).
218
+ # @param name [String] skill name
219
+ # @param description [String] what the skill does
220
+ # @param instructions [String] the skill body (markdown)
221
+ # @return [Skill] the inline skill
222
+ def add_inline_skill(name:, description:, instructions:)
223
+ skill = Skill.inline(name: name, description: description, instructions: instructions)
224
+ @skills << skill unless @skills.any? { |s| s.name == skill.name }
54
225
  skill
55
226
  end
56
227
 
228
+ # List discovered skills in a container path (without loading them).
229
+ # @param path_or_url [String] local path or GitHub URL
230
+ # @return [Array<String>] skill directory paths
231
+ def self.list_skills(path_or_url)
232
+ SkillResolver.resolve(path_or_url)
233
+ end
234
+
235
+ # Legacy alias
236
+ alias_method :load_skill, :add_skill
237
+
57
238
  def before_prompt(&block)
58
239
  hooks.before_prompt(&block)
59
240
  end
@@ -75,19 +256,6 @@ module Antigravity
75
256
  @sidecars.each { |sidecar| sidecar.emit(event_type, payload) }
76
257
  end
77
258
 
78
- def prompt(message, &block)
79
- emit_sidecar_event(:prompt_started, prompt: message)
80
- hooks.run_pre_prompt(message)
81
-
82
- response = client.send_turn(self, message, &block)
83
-
84
- hooks.run_post_response(response)
85
- emit_sidecar_event(:turn_completed, response: response.content, model: model)
86
-
87
- response
88
- end
89
- alias ask prompt
90
-
91
259
  private
92
260
 
93
261
  def logger_enabled?
@@ -96,5 +264,105 @@ module Antigravity
96
264
 
97
265
  true
98
266
  end
267
+
268
+ def lifecycle_logger_enabled?
269
+ # Explicit opt-in
270
+ return true if ENV['ANTIGRAVITY_LIFECYCLE'] == '1'
271
+ # Rails/Rack test or development
272
+ return true if %w[test development].include?(ENV['RAILS_ENV']&.downcase)
273
+ return true if %w[test development].include?(ENV['RACK_ENV']&.downcase)
274
+ # Explicit opt-out
275
+ false
276
+ end
277
+
278
+ def build_harness_config
279
+ api_key = ENV.fetch('GEMINI_API_KEY') {
280
+ raise ConfigError, 'GEMINI_API_KEY environment variable is required'
281
+ }
282
+
283
+ config = {
284
+ config: {
285
+ models: [
286
+ {
287
+ name: @model,
288
+ geminiApiEndpoint: {
289
+ apiKey: api_key
290
+ }
291
+ }
292
+ ]
293
+ }
294
+ }
295
+
296
+ # Add workspaces if specified
297
+ if @workspace
298
+ expanded = File.expand_path(@workspace)
299
+ $stderr.puts "\u231B Indexing workspace: #{expanded} — this may take a moment..."
300
+ config[:config][:workspaces] = [
301
+ {
302
+ filesystemWorkspace: {
303
+ directory: expanded
304
+ }
305
+ }
306
+ ]
307
+ end
308
+
309
+ # Add system instructions if specified (protobuf: SystemInstructions.custom.part[])
310
+ effective_instructions = @system_instruction || ''
311
+
312
+ # Auto-append workspace tool hints — models (esp. flash-lite) won't use tools unless told
313
+ if @workspace && !effective_instructions.match?(/list_dir|view_file|tools/i)
314
+ tool_hint = 'You have access to the workspace filesystem. Use the available tools (list_dir, view_file, grep_search) to explore it.'
315
+ effective_instructions = effective_instructions.empty? ? tool_hint : "#{effective_instructions}\n#{tool_hint}"
316
+ end
317
+
318
+ unless effective_instructions.empty?
319
+ config[:config][:systemInstructions] = {
320
+ custom: {
321
+ part: [{ text: effective_instructions }]
322
+ }
323
+ }
324
+ end
325
+
326
+ # Add custom tools
327
+ if @tool_runner && !@tool_runner.empty?
328
+ config[:config][:tools] = @tool_runner.to_harness_tools
329
+ end
330
+
331
+ # Enable harness-side built-in tools by default (list_dir, view_file, grep_search, etc.)
332
+ config[:config][:harnessSideTools] = {
333
+ listDir: { enabled: true },
334
+ viewFile: { enabled: true },
335
+ grepSearch: { enabled: true },
336
+ find: { enabled: true },
337
+ writeToFile: { enabled: true },
338
+ fileEdit: { enabled: true },
339
+ readUrlContent: { enabled: true },
340
+ searchWeb: { enabled: true }
341
+ }
342
+
343
+ # Wire skills paths for harness (proto field: skills_paths)
344
+ unless @skills.empty?
345
+ skill_paths = @skills.select(&:path).map(&:path)
346
+ config[:config][:skillsPaths] = skill_paths unless skill_paths.empty?
347
+ end
348
+
349
+ # App data dir
350
+ config[:config][:appDataDir] = File.expand_path('~/.gemini/antigravity')
351
+
352
+ config
353
+ end
354
+
355
+ def load_single_skill(skill_path)
356
+ # Dedup by path
357
+ return @skills.find { |s| s.path == skill_path } if @skills.any? { |s| s.path == skill_path }
358
+
359
+ skill = Skill.load(skill_path)
360
+ @skills << skill
361
+ skill
362
+ end
363
+
364
+ def lifecycle_logger_enabled?
365
+ ENV['ANTIGRAVITY_LIFECYCLE'] == '1'
366
+ end
99
367
  end
100
368
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lightweight ANSI color helpers for terminal output.
4
+ # No external dependencies. Safe for piped/non-TTY output.
5
+ module Antigravity
6
+ module Colors
7
+ CODES = {
8
+ reset: "\e[0m",
9
+ bold: "\e[1m",
10
+ dim: "\e[2m",
11
+ italic: "\e[3m",
12
+ # Foreground
13
+ gray: "\e[90m",
14
+ red: "\e[31m",
15
+ green: "\e[32m",
16
+ yellow: "\e[33m",
17
+ blue: "\e[34m",
18
+ magenta: "\e[35m",
19
+ cyan: "\e[36m",
20
+ white: "\e[37m",
21
+ # Bright
22
+ bright_green: "\e[92m",
23
+ bright_yellow: "\e[93m",
24
+ bright_cyan: "\e[96m",
25
+ }.freeze
26
+
27
+ def self.colorize(text, *styles)
28
+ return text.to_s unless $stdout.tty?
29
+ prefix = styles.map { |s| CODES[s] || "" }.join
30
+ "#{prefix}#{text}#{CODES[:reset]}"
31
+ end
32
+
33
+ def self.gray(text) = colorize(text, :gray)
34
+ def self.dim(text) = colorize(text, :dim)
35
+ def self.green(text) = colorize(text, :green)
36
+ def self.yellow(text) = colorize(text, :yellow)
37
+ def self.red(text) = colorize(text, :red)
38
+ def self.cyan(text) = colorize(text, :cyan)
39
+ def self.blue(text) = colorize(text, :blue)
40
+ def self.magenta(text) = colorize(text, :magenta)
41
+ def self.bold(text) = colorize(text, :bold)
42
+ end
43
+ end
@@ -4,11 +4,22 @@ module Antigravity
4
4
  class Config
5
5
  attr_accessor :api_key, :default_model, :harness_path, :log_level
6
6
 
7
+ # Timeouts (seconds) — aggressive for dev, override via ENV for prod.
8
+ # ANTIGRAVITY_TIMEOUT_LLM — per-message wait for LLM response (default: 20s)
9
+ # ANTIGRAVITY_TIMEOUT_WS — WebSocket connect/handshake (default: 3s)
10
+ # ANTIGRAVITY_TIMEOUT_HANDSHAKE — stdio binary handshake (default: 5s)
11
+ attr_accessor :timeout_llm, :timeout_ws, :timeout_handshake
12
+
7
13
  def initialize
8
14
  @api_key = ENV["GEMINI_API_KEY"]
9
- @default_model = "gemini-flash-latest"
15
+ @default_model = ENV["GEMINI_MODEL"] || ENV["ANTIGRAVITY_MODEL"] || "gemini-3.6-flash"
10
16
  @harness_path = ENV["ANTIGRAVITY_HARNESS_PATH"] || File.expand_path("~/.antigravity/bin/localharness")
11
17
  @log_level = :info
18
+
19
+ # Timeouts: aggressive for dev, relax via ENV for production
20
+ @timeout_llm = (ENV["ANTIGRAVITY_TIMEOUT_LLM"] || 40).to_i
21
+ @timeout_ws = (ENV["ANTIGRAVITY_TIMEOUT_WS"] || 3).to_i
22
+ @timeout_handshake = (ENV["ANTIGRAVITY_TIMEOUT_HANDSHAKE"] || 5).to_i
12
23
  end
13
24
 
14
25
  def api_key?
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+ require 'net/http'
7
+ require 'json'
8
+ require 'uri'
9
+
10
+ module Antigravity
11
+ module Connection
12
+ # Downloads and extracts the localharness binary from the official
13
+ # google-antigravity PyPI wheel. Used when the binary isn't already
14
+ # installed via Antigravity.app or a manual setup.
15
+ module BinaryFetcher
16
+ PYPI_PACKAGE = 'google-antigravity'
17
+ INSTALL_DIR = File.expand_path('~/.antigravity/bin')
18
+ BINARY_NAME = 'localharness'
19
+
20
+ # Platform mapping: Ruby's RUBY_PLATFORM -> PyPI wheel platform tag
21
+ PLATFORM_MAP = {
22
+ /darwin.*arm/i => 'macosx_11_0_arm64',
23
+ /darwin.*x86/i => 'macosx_10_15_x86_64',
24
+ /darwin/i => 'macosx_11_0_arm64', # default macOS = ARM
25
+ /linux.*x86_64/i => 'manylinux2014_x86_64',
26
+ /linux.*aarch/i => 'manylinux2014_aarch64',
27
+ }.freeze
28
+
29
+ class << self
30
+ # Fetch and install the binary. Returns the installed path.
31
+ # @param quiet [Boolean] suppress progress output
32
+ # @return [String] path to the installed binary
33
+ def fetch!(quiet: false)
34
+ platform = detect_platform
35
+ say("🔍 Detecting platform: #{platform}", quiet: quiet)
36
+
37
+ # Step 1: Find the wheel URL from PyPI
38
+ say("📡 Querying PyPI for #{PYPI_PACKAGE}...", quiet: quiet)
39
+ wheel_url = find_wheel_url(platform)
40
+
41
+ # Step 2: Download the wheel
42
+ say("⏳ Downloading binary... this will take some time (~50MB wheel)", quiet: quiet)
43
+ wheel_path = download_wheel(wheel_url, quiet: quiet)
44
+
45
+ # Step 3: Extract the binary
46
+ say("📦 Extracting localharness binary...", quiet: quiet)
47
+ binary_path = extract_binary(wheel_path)
48
+
49
+ # Step 4: Make executable
50
+ FileUtils.chmod(0o755, binary_path)
51
+ say("✅ Installed localharness at #{binary_path}", quiet: quiet)
52
+
53
+ binary_path
54
+ ensure
55
+ # Clean up temp wheel
56
+ FileUtils.rm_f(wheel_path) if wheel_path && File.exist?(wheel_path.to_s)
57
+ end
58
+
59
+ # Check if the binary is already installed via fetch
60
+ def installed?
61
+ path = File.join(INSTALL_DIR, BINARY_NAME)
62
+ File.executable?(path)
63
+ end
64
+
65
+ def installed_path
66
+ File.join(INSTALL_DIR, BINARY_NAME)
67
+ end
68
+
69
+ private
70
+
71
+ def detect_platform
72
+ PLATFORM_MAP.each do |pattern, tag|
73
+ return tag if RUBY_PLATFORM.match?(pattern)
74
+ end
75
+ raise HarnessNotFoundError,
76
+ "Unsupported platform: #{RUBY_PLATFORM}. Cannot auto-download localharness."
77
+ end
78
+
79
+ def find_wheel_url(platform)
80
+ uri = URI("https://pypi.org/pypi/#{PYPI_PACKAGE}/json")
81
+ response = Net::HTTP.get(uri)
82
+ data = JSON.parse(response)
83
+
84
+ # Find latest version's wheel for our platform
85
+ urls = data['urls'] || []
86
+ wheel = urls.find { |u| u['filename']&.include?(platform) && u['filename']&.end_with?('.whl') }
87
+
88
+ unless wheel
89
+ # Try all versions for the platform
90
+ versions = data['releases']&.keys&.sort_by { |v| Gem::Version.new(v) rescue v }&.reverse
91
+ versions&.each do |ver|
92
+ files = data.dig('releases', ver) || []
93
+ wheel = files.find { |u| u['filename']&.include?(platform) && u['filename']&.end_with?('.whl') }
94
+ break if wheel
95
+ end
96
+ end
97
+
98
+ raise HarnessNotFoundError,
99
+ "No wheel found for platform #{platform} on PyPI. Install Antigravity.app manually." unless wheel
100
+
101
+ wheel['url']
102
+ end
103
+
104
+ def download_wheel(url, quiet: false)
105
+ dest = File.join(Dir.tmpdir, "antigravity-wheel-#{$$}.whl")
106
+ uri = URI(url)
107
+
108
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
109
+ request = Net::HTTP::Get.new(uri)
110
+ http.request(request) do |response|
111
+ total = response['content-length']&.to_i
112
+ downloaded = 0
113
+
114
+ File.open(dest, 'wb') do |f|
115
+ response.read_body do |chunk|
116
+ f.write(chunk)
117
+ downloaded += chunk.bytesize
118
+ if total && total > 0 && !quiet
119
+ pct = (downloaded * 100.0 / total).round(1)
120
+ print "\r ⏳ #{pct}% (#{(downloaded / 1024.0 / 1024).round(1)} MB / #{(total / 1024.0 / 1024).round(1)} MB)"
121
+ end
122
+ end
123
+ end
124
+ puts unless quiet
125
+ end
126
+ end
127
+
128
+ dest
129
+ end
130
+
131
+ def extract_binary(wheel_path)
132
+ FileUtils.mkdir_p(INSTALL_DIR)
133
+
134
+ # Wheels are just ZIP files. Extract the binary.
135
+ # Look for: google/antigravity/bin/localharness (or language_server)
136
+ extract_dir = Dir.mktmpdir('agy-extract-')
137
+ system('unzip', '-q', '-o', wheel_path, '-d', extract_dir)
138
+
139
+ # Search for the binary inside (prefer localharness over language_server)
140
+ candidates = Dir.glob("#{extract_dir}/**/localharness") +
141
+ Dir.glob("#{extract_dir}/**/language_server")
142
+
143
+ binary = candidates.find { |f| File.file?(f) && !File.directory?(f) }
144
+
145
+ unless binary
146
+ FileUtils.rm_rf(extract_dir)
147
+ raise HarnessNotFoundError,
148
+ "Could not find localharness binary inside the wheel. Contents: #{Dir.glob("#{extract_dir}/**/*").join(', ')}"
149
+ end
150
+
151
+ dest_path = File.join(INSTALL_DIR, BINARY_NAME)
152
+ FileUtils.cp(binary, dest_path)
153
+ FileUtils.rm_rf(extract_dir)
154
+ dest_path
155
+ end
156
+
157
+ def say(msg, quiet: false)
158
+ $stderr.puts msg unless quiet
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end