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 +4 -4
- data/lib/antigravity/agent.rb +290 -22
- data/lib/antigravity/colors.rb +43 -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 +335 -0
- data/lib/antigravity/emojis.rb +4 -0
- data/lib/antigravity/errors.rb +27 -0
- data/lib/antigravity/guards.rb +82 -22
- data/lib/antigravity/hooks.rb +12 -0
- data/lib/antigravity/lifecycle_logger.rb +145 -0
- data/lib/antigravity/message.rb +19 -5
- data/lib/antigravity/policy/constants.rb +154 -0
- data/lib/antigravity/policy.rb +272 -0
- data/lib/antigravity/protocol.rb +193 -0
- data/lib/antigravity/skill.rb +53 -5
- data/lib/antigravity/skill_resolver.rb +140 -0
- data/lib/antigravity/tool.rb +33 -6
- data/lib/antigravity/tool_runner.rb +59 -0
- data/lib/antigravity.rb +11 -0
- metadata +16 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: effdabdb0588788c4b168749a2578e2e1f07aa877a54bfd80fe1969d42f497d2
|
|
4
|
+
data.tar.gz: 3debe5e4295f8c2e2bcd4c37ec7ea52ea11a77d034370320d4064b52aa08cc29
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4123cbc798ddbfaa82ea0f854e89adb122491d9a99b52f501c15350401e660109be3ca778b6142720f0259256992dbbb73d4d2371d8bdefcda266bd4d98591cf
|
|
7
|
+
data.tar.gz: d80349e13c564c74bb81c5c8ba14887f39af36507d03b3c6a5883ed3a885359570210d10b9fc1f8cc3e8a0c193408de176f3316129a58e1d6303352c5527ed01
|
data/lib/antigravity/agent.rb
CHANGED
|
@@ -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,
|
|
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 =
|
|
13
|
-
@
|
|
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:
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
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
|