antigravity-sdk 0.2.0 → 0.4.2
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 +4 -4
- data/lib/antigravity/agent.rb +235 -24
- data/lib/antigravity/base.rb +18 -0
- data/lib/antigravity/config.rb +12 -1
- data/lib/antigravity/connection/binary_fetcher.rb +163 -0
- data/lib/antigravity/connection/local_connection.rb +184 -0
- data/lib/antigravity/connection/websocket_client.rb +159 -0
- data/lib/antigravity/conversation.rb +302 -0
- data/lib/antigravity/emojis.rb +46 -17
- data/lib/antigravity/errors.rb +27 -0
- data/lib/antigravity/guards.rb +82 -22
- data/lib/antigravity/hooks.rb +12 -0
- data/lib/antigravity/message.rb +20 -7
- data/lib/antigravity/protocol.rb +193 -0
- data/lib/antigravity/sidecar.rb +7 -5
- data/lib/antigravity/skill.rb +54 -7
- data/lib/antigravity/skill_resolver.rb +140 -0
- data/lib/antigravity/tool.rb +35 -11
- data/lib/antigravity/tool_runner.rb +59 -0
- data/lib/antigravity.rb +9 -0
- metadata +13 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2a5cad1edda5eb5efaa58d7d5104ba433f8469a923bb5f862b9f1bcb4219021
|
|
4
|
+
data.tar.gz: 75c9924910ef8aab407444abfecfa0f7e989473d8be251691e4e07c9d04499aa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac637d9792051869f94b4a8ebc716fd604b2db18886e19b105190a9636c7e7bce418aeece52a360b139b1c534e560605b0dc578fab10b3e06ac913dc454663eb
|
|
7
|
+
data.tar.gz: c8b6e4bee541853abd1f993d5423cc3426c830a24aba58f6790ec30e093279163b83e258a824811380ac5a603cba749dd7a099dee5ad4a2160fe25a7373c6e51
|
data/lib/antigravity/agent.rb
CHANGED
|
@@ -1,31 +1,125 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Antigravity
|
|
4
|
-
class Agent
|
|
5
|
-
include Emojifiable
|
|
4
|
+
class Agent < Base
|
|
6
5
|
|
|
7
6
|
attr_accessor :model, :system_instruction, :api_key
|
|
8
|
-
attr_reader :tools, :skills, :hooks, :sidecars, :client, :logger_guard
|
|
7
|
+
attr_reader :tools, :skills, :hooks, :sidecars, :client, :logger_guard,
|
|
8
|
+
:workspace, :connection, :conversation
|
|
9
9
|
|
|
10
|
-
def initialize(model: nil,
|
|
10
|
+
def initialize(model: nil, system_instruction: nil, tools: [],
|
|
11
|
+
skills: [], workspace: nil, auto_logger: true, log_file: nil, &block)
|
|
11
12
|
@model = model || Antigravity.config.default_model
|
|
12
13
|
@api_key = Antigravity.config.api_key
|
|
13
|
-
@system_instruction =
|
|
14
|
-
@
|
|
14
|
+
@system_instruction = system_instruction
|
|
15
|
+
@workspace = workspace ? File.expand_path(workspace) : nil
|
|
16
|
+
@tools = tools.dup
|
|
15
17
|
@skills = []
|
|
16
18
|
@sidecars = []
|
|
17
19
|
@hooks = Hooks.new
|
|
18
20
|
@client = Client.new
|
|
19
21
|
@logger_guard = nil
|
|
22
|
+
@connection = nil
|
|
23
|
+
@conversation = nil
|
|
24
|
+
@connected = false
|
|
25
|
+
|
|
26
|
+
# Register pre-provided tools into the tool runner
|
|
27
|
+
@tool_runner = ToolRunner.new
|
|
28
|
+
@tools.each { |t| @tool_runner.register(t) }
|
|
29
|
+
|
|
30
|
+
# Load skills provided at construction (local paths or GitHub URLs)
|
|
31
|
+
add_skills(skills) unless Array(skills).empty?
|
|
20
32
|
|
|
21
33
|
# Automagic Logger attachment unless disabled via ENV["ANTIGRAVITY_LOGGER"]=false or auto_logger: false
|
|
22
34
|
if auto_logger && logger_enabled?
|
|
23
|
-
attach_logger
|
|
35
|
+
attach_logger(log_file)
|
|
24
36
|
end
|
|
25
37
|
|
|
26
38
|
yield(self) if block_given?
|
|
27
39
|
end
|
|
28
40
|
|
|
41
|
+
# --- Class Methods ---
|
|
42
|
+
|
|
43
|
+
# Block form: opens connection, yields agent, auto-closes.
|
|
44
|
+
def self.open(**kwargs, &block)
|
|
45
|
+
agent = new(**kwargs)
|
|
46
|
+
agent.connect!
|
|
47
|
+
begin
|
|
48
|
+
block.call(agent)
|
|
49
|
+
ensure
|
|
50
|
+
agent.close!
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# --- Connection Lifecycle ---
|
|
55
|
+
|
|
56
|
+
def connect!
|
|
57
|
+
return self if @connected
|
|
58
|
+
|
|
59
|
+
@connection = Connection::LocalConnection.new
|
|
60
|
+
@connection.connect!
|
|
61
|
+
|
|
62
|
+
@conversation = Conversation.new(
|
|
63
|
+
ws_client: @connection.ws_client,
|
|
64
|
+
tool_runner: @tool_runner,
|
|
65
|
+
hooks: @hooks
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
harness_config = build_harness_config
|
|
69
|
+
@conversation.initialize_session!(harness_config: harness_config)
|
|
70
|
+
@connected = true
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def connected?
|
|
75
|
+
@connected && @connection&.connected?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def close!
|
|
79
|
+
@connected = false
|
|
80
|
+
@connection&.disconnect!
|
|
81
|
+
@connection = nil
|
|
82
|
+
@conversation = nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- Chat ---
|
|
86
|
+
|
|
87
|
+
def prompt(message, timeout: Antigravity.config.timeout_llm, &block)
|
|
88
|
+
emit_sidecar_event(:prompt_started, prompt: message)
|
|
89
|
+
hooks.run_pre_prompt(message)
|
|
90
|
+
|
|
91
|
+
if @connected && @conversation
|
|
92
|
+
response = @conversation.chat(message, timeout: timeout, &block)
|
|
93
|
+
else
|
|
94
|
+
# Legacy mock-client path (unit tests, pre-connection)
|
|
95
|
+
response = client.send_turn(self, message, &block)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
hooks.run_post_response(response)
|
|
99
|
+
emit_sidecar_event(:turn_completed, response: response.content, model: model)
|
|
100
|
+
|
|
101
|
+
response
|
|
102
|
+
end
|
|
103
|
+
alias ask prompt
|
|
104
|
+
|
|
105
|
+
# --- Metadata Accessors (mirrors Python SDK) ---
|
|
106
|
+
|
|
107
|
+
def conversation_id
|
|
108
|
+
@conversation&.conversation_id
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def turn_count
|
|
112
|
+
@conversation&.turn_count || 0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def session_summary
|
|
116
|
+
return {} unless @conversation
|
|
117
|
+
|
|
118
|
+
@conversation.session_summary(model: @model)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# --- Tool Registration ---
|
|
122
|
+
|
|
29
123
|
def register_tool(tool_or_name = nil, description: "", &block)
|
|
30
124
|
if block_given? && tool_or_name
|
|
31
125
|
tool = Tool::Dynamic.new(tool_or_name, description: description, &block)
|
|
@@ -35,6 +129,7 @@ module Antigravity
|
|
|
35
129
|
raise ArgumentError, "Invalid tool definition"
|
|
36
130
|
end
|
|
37
131
|
@tools << tool
|
|
132
|
+
@tool_runner.register(tool) if @tool_runner
|
|
38
133
|
tool
|
|
39
134
|
end
|
|
40
135
|
|
|
@@ -43,18 +138,61 @@ module Antigravity
|
|
|
43
138
|
sidecar
|
|
44
139
|
end
|
|
45
140
|
|
|
46
|
-
def attach_logger(log_target = nil, level:
|
|
141
|
+
def attach_logger(log_target = nil, level: :info, silent_notice: false)
|
|
47
142
|
@logger_guard = Guards::AgentLogger.new(log_target, level: level, silent_notice: silent_notice)
|
|
48
143
|
@logger_guard.attach_to(self)
|
|
49
144
|
@logger_guard
|
|
50
145
|
end
|
|
51
146
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
147
|
+
# Add a single skill by path or GitHub URL.
|
|
148
|
+
# Raises if the path resolves to multiple skills (use add_skills instead).
|
|
149
|
+
# @param path_or_url [String] local path or GitHub URL
|
|
150
|
+
# @param skill_name [String, nil] optional specific skill name within a repo
|
|
151
|
+
# @return [Skill] the loaded skill
|
|
152
|
+
def add_skill(path_or_url, skill_name: nil)
|
|
153
|
+
target = skill_name ? "#{path_or_url.to_s.chomp('/')}/#{skill_name}" : path_or_url.to_s
|
|
154
|
+
paths = SkillResolver.resolve(target)
|
|
155
|
+
if paths.size > 1
|
|
156
|
+
raise ArgumentError,
|
|
157
|
+
"add_skill resolved to #{paths.size} skills. Use add_skills instead, " \
|
|
158
|
+
"or specify skill_name: to pick one."
|
|
159
|
+
end
|
|
160
|
+
raise ArgumentError, "No skill found at #{target}" if paths.empty?
|
|
161
|
+
|
|
162
|
+
load_single_skill(paths.first)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Add one or more skills by path or GitHub URL.
|
|
166
|
+
# Accepts a single string or an array. Each entry is resolved (may expand to multiple).
|
|
167
|
+
# @param paths_or_urls [String, Array<String>] local paths or GitHub URLs
|
|
168
|
+
# @return [Array<Skill>] all loaded skills
|
|
169
|
+
def add_skills(paths_or_urls)
|
|
170
|
+
Array(paths_or_urls).flat_map do |p|
|
|
171
|
+
SkillResolver.resolve(p).map { |skill_path| load_single_skill(skill_path) }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Create and add an inline skill (no file needed).
|
|
176
|
+
# @param name [String] skill name
|
|
177
|
+
# @param description [String] what the skill does
|
|
178
|
+
# @param instructions [String] the skill body (markdown)
|
|
179
|
+
# @return [Skill] the inline skill
|
|
180
|
+
def add_inline_skill(name:, description:, instructions:)
|
|
181
|
+
skill = Skill.inline(name: name, description: description, instructions: instructions)
|
|
182
|
+
@skills << skill unless @skills.any? { |s| s.name == skill.name }
|
|
55
183
|
skill
|
|
56
184
|
end
|
|
57
185
|
|
|
186
|
+
# List discovered skills in a container path (without loading them).
|
|
187
|
+
# @param path_or_url [String] local path or GitHub URL
|
|
188
|
+
# @return [Array<String>] skill directory paths
|
|
189
|
+
def self.list_skills(path_or_url)
|
|
190
|
+
SkillResolver.resolve(path_or_url)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Legacy alias
|
|
194
|
+
alias_method :load_skill, :add_skill
|
|
195
|
+
|
|
58
196
|
def before_prompt(&block)
|
|
59
197
|
hooks.before_prompt(&block)
|
|
60
198
|
end
|
|
@@ -76,19 +214,6 @@ module Antigravity
|
|
|
76
214
|
@sidecars.each { |sidecar| sidecar.emit(event_type, payload) }
|
|
77
215
|
end
|
|
78
216
|
|
|
79
|
-
def prompt(message, &block)
|
|
80
|
-
emit_sidecar_event(:prompt_started, prompt: message)
|
|
81
|
-
hooks.run_pre_prompt(message)
|
|
82
|
-
|
|
83
|
-
response = client.send_turn(self, message, &block)
|
|
84
|
-
|
|
85
|
-
hooks.run_post_response(response)
|
|
86
|
-
emit_sidecar_event(:turn_completed, response: response.content, model: model)
|
|
87
|
-
|
|
88
|
-
response
|
|
89
|
-
end
|
|
90
|
-
alias ask prompt
|
|
91
|
-
|
|
92
217
|
private
|
|
93
218
|
|
|
94
219
|
def logger_enabled?
|
|
@@ -97,5 +222,91 @@ module Antigravity
|
|
|
97
222
|
|
|
98
223
|
true
|
|
99
224
|
end
|
|
225
|
+
|
|
226
|
+
def build_harness_config
|
|
227
|
+
api_key = ENV.fetch('GEMINI_API_KEY') {
|
|
228
|
+
raise ConfigError, 'GEMINI_API_KEY environment variable is required'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
config = {
|
|
232
|
+
config: {
|
|
233
|
+
models: [
|
|
234
|
+
{
|
|
235
|
+
name: @model,
|
|
236
|
+
geminiApiEndpoint: {
|
|
237
|
+
apiKey: api_key
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
# Add workspaces if specified
|
|
245
|
+
if @workspace
|
|
246
|
+
expanded = File.expand_path(@workspace)
|
|
247
|
+
$stderr.puts "\u231B Indexing workspace: #{expanded} — this may take a moment..."
|
|
248
|
+
config[:config][:workspaces] = [
|
|
249
|
+
{
|
|
250
|
+
filesystemWorkspace: {
|
|
251
|
+
directory: expanded
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Add system instructions if specified (protobuf: SystemInstructions.custom.part[])
|
|
258
|
+
effective_instructions = @system_instruction || ''
|
|
259
|
+
|
|
260
|
+
# Auto-append workspace tool hints — models (esp. flash-lite) won't use tools unless told
|
|
261
|
+
if @workspace && !effective_instructions.match?(/list_dir|view_file|tools/i)
|
|
262
|
+
tool_hint = 'You have access to the workspace filesystem. Use the available tools (list_dir, view_file, grep_search) to explore it.'
|
|
263
|
+
effective_instructions = effective_instructions.empty? ? tool_hint : "#{effective_instructions}\n#{tool_hint}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
unless effective_instructions.empty?
|
|
267
|
+
config[:config][:systemInstructions] = {
|
|
268
|
+
custom: {
|
|
269
|
+
part: [{ text: effective_instructions }]
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Add custom tools
|
|
275
|
+
if @tool_runner && !@tool_runner.empty?
|
|
276
|
+
config[:config][:tools] = @tool_runner.to_harness_tools
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Enable harness-side built-in tools by default (list_dir, view_file, grep_search, etc.)
|
|
280
|
+
config[:config][:harnessSideTools] = {
|
|
281
|
+
listDir: { enabled: true },
|
|
282
|
+
viewFile: { enabled: true },
|
|
283
|
+
grepSearch: { enabled: true },
|
|
284
|
+
find: { enabled: true },
|
|
285
|
+
writeToFile: { enabled: true },
|
|
286
|
+
fileEdit: { enabled: true },
|
|
287
|
+
readUrlContent: { enabled: true },
|
|
288
|
+
searchWeb: { enabled: true }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# Wire skills paths for harness (proto field: skills_paths)
|
|
292
|
+
unless @skills.empty?
|
|
293
|
+
skill_paths = @skills.select(&:path).map(&:path)
|
|
294
|
+
config[:config][:skillsPaths] = skill_paths unless skill_paths.empty?
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# App data dir
|
|
298
|
+
config[:config][:appDataDir] = File.expand_path('~/.gemini/antigravity')
|
|
299
|
+
|
|
300
|
+
config
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def load_single_skill(skill_path)
|
|
304
|
+
# Dedup by path
|
|
305
|
+
return @skills.find { |s| s.path == skill_path } if @skills.any? { |s| s.path == skill_path }
|
|
306
|
+
|
|
307
|
+
skill = Skill.load(skill_path)
|
|
308
|
+
@skills << skill
|
|
309
|
+
skill
|
|
310
|
+
end
|
|
100
311
|
end
|
|
101
312
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Antigravity
|
|
4
|
+
# Base class for all Antigravity domain objects.
|
|
5
|
+
# Subclasses automagically get .emoji / #emoji via Emojifiable.
|
|
6
|
+
#
|
|
7
|
+
# ⚠️ Keep this class SUPER thin!
|
|
8
|
+
# Every SDK class inherits from it, so any weight here
|
|
9
|
+
# is carried by Agent, Tool, Skill, Message, Sidecar::Runner, etc.
|
|
10
|
+
class Base
|
|
11
|
+
include Emojifiable
|
|
12
|
+
|
|
13
|
+
def self.inherited(subclass)
|
|
14
|
+
super
|
|
15
|
+
subclass.include(Emojifiable) unless subclass < Emojifiable
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/antigravity/config.rb
CHANGED
|
@@ -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
|
|
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
|