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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'policy/constants'
|
|
4
|
+
|
|
5
|
+
module Antigravity
|
|
6
|
+
# ==========================================================================
|
|
7
|
+
# Antigravity::Policy — Declarative tool-access control for agents.
|
|
8
|
+
#
|
|
9
|
+
# ⚠️ ORDER DOES NOT MATTER!
|
|
10
|
+
#
|
|
11
|
+
# The DSL is declarative, like SQL — not imperative like a script.
|
|
12
|
+
# Rules are evaluated by PRECEDENCE, not by insertion order.
|
|
13
|
+
# You can write `allow` before `deny` or vice versa — same result.
|
|
14
|
+
#
|
|
15
|
+
# Precedence (highest wins):
|
|
16
|
+
# 1. Tool specificity: specific tool > wildcard (nil)
|
|
17
|
+
# 2. Condition specificity: has `when:` > no `when:`
|
|
18
|
+
# 3. Restrictiveness: deny > confirm > allow
|
|
19
|
+
#
|
|
20
|
+
# Example — these two policies behave identically:
|
|
21
|
+
#
|
|
22
|
+
# Policy.define do Policy.define do
|
|
23
|
+
# allow :run_command deny :run_command, when: cmd('rm')
|
|
24
|
+
# deny :run_command, allow :run_command
|
|
25
|
+
# when: cmd('rm') end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# In both cases, `rm` is denied (conditional deny beats unconditional
|
|
29
|
+
# allow), and everything else is allowed.
|
|
30
|
+
#
|
|
31
|
+
# See policy/constants.rb for curated command/file/tool lists.
|
|
32
|
+
# ==========================================================================
|
|
33
|
+
class Policy
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
# Rule — a single allow/deny/confirm entry in a policy.
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
class Rule
|
|
39
|
+
attr_reader :action, :tool_name, :condition, :handler
|
|
40
|
+
|
|
41
|
+
def initialize(action, tool_name = nil, condition: nil, handler: nil)
|
|
42
|
+
@action = action
|
|
43
|
+
@tool_name = tool_name
|
|
44
|
+
@condition = condition
|
|
45
|
+
@handler = handler
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def matches?(tool, args)
|
|
49
|
+
return false if @tool_name && @tool_name.to_sym != tool.to_sym
|
|
50
|
+
return false if @condition && !@condition.call(name: tool, args: args)
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Precedence order (higher = wins):
|
|
55
|
+
# 1. Tool specificity: Specific tool > Wildcard (nil)
|
|
56
|
+
# 2. Condition specificity: Has predicate > No predicate
|
|
57
|
+
# 3. Action restrictiveness: Deny > Confirm > Allow
|
|
58
|
+
def precedence
|
|
59
|
+
specificity = @tool_name ? 1 : 0
|
|
60
|
+
condition_score = @condition ? 1 : 0
|
|
61
|
+
action_score = case @action
|
|
62
|
+
when :deny then 3
|
|
63
|
+
when :confirm then 2
|
|
64
|
+
when :allow then 1
|
|
65
|
+
else 0
|
|
66
|
+
end
|
|
67
|
+
[specificity, condition_score, action_score]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# Constructor & factory methods
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def initialize(&block)
|
|
76
|
+
@rules = []
|
|
77
|
+
@confirm_handler = nil
|
|
78
|
+
instance_eval(&block) if block_given?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.define(&block)
|
|
82
|
+
new(&block)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.allow_all
|
|
86
|
+
new { allow_all }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.deny_all
|
|
90
|
+
new { deny_all }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
# Built-in presets
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
# Resolve a preset by name (symbol).
|
|
98
|
+
# @param name [Symbol] :cautious, :default, :turbo, :test, or :auto
|
|
99
|
+
# @return [Policy]
|
|
100
|
+
def self.preset(name)
|
|
101
|
+
case name.to_sym
|
|
102
|
+
when :cautious then cautious
|
|
103
|
+
when :default then default
|
|
104
|
+
when :turbo then turbo
|
|
105
|
+
when :test then test
|
|
106
|
+
when :auto then auto
|
|
107
|
+
else
|
|
108
|
+
raise ArgumentError, "Unknown preset :#{name}. Choose from: #{PRESET_NAMES.map { |n| ":#{n}" }.join(', ')}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# 🔒 Cautious — read-only free, confirm everything else, hard-deny destructive.
|
|
113
|
+
# Best for: untrusted environments, production agents.
|
|
114
|
+
# NOTE: cat/head/tail/ls NOT in safe list — they can bypass view_file deny rules.
|
|
115
|
+
def self.cautious
|
|
116
|
+
define do
|
|
117
|
+
deny_all
|
|
118
|
+
READONLY_TOOLS.each { |t| allow t }
|
|
119
|
+
allow :run_command, when: cmd(*SAFE_CMDS, *SAFE_GIT_CMDS)
|
|
120
|
+
deny :run_command, when: cmd(*CATASTROPHIC_CMDS)
|
|
121
|
+
deny :run_command, when: cmd(*RISKY_CMDS)
|
|
122
|
+
deny :run_command, when: cmd(*DESTRUCTIVE_GIT_CMDS)
|
|
123
|
+
WRITE_TOOLS.each { |t| confirm t }
|
|
124
|
+
# 📂 Sandbox dirs: always writable, even in production
|
|
125
|
+
WRITE_TOOLS.each { |t| allow t, when: path(*SANDBOX_DIRS) }
|
|
126
|
+
confirm :run_command
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ⚖️ Default — balanced: allow reads + writes, confirm dangerous shell, protect sensitive files.
|
|
131
|
+
# Best for: day-to-day development, pair programming with an agent.
|
|
132
|
+
def self.default
|
|
133
|
+
define do
|
|
134
|
+
deny_all
|
|
135
|
+
READONLY_TOOLS.each { |t| allow t }
|
|
136
|
+
WRITE_TOOLS.each { |t| allow t }
|
|
137
|
+
WRITE_TOOLS.each { |t| confirm t, when: path(*SENSITIVE_FILES) }
|
|
138
|
+
allow :run_command
|
|
139
|
+
deny :run_command, when: cmd(*CATASTROPHIC_CMDS)
|
|
140
|
+
confirm :run_command, when: cmd(*RISKY_CMDS)
|
|
141
|
+
confirm :run_command, when: cmd(*DESTRUCTIVE_GIT_CMDS)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# 🚀 Turbo — wide open with seatbelts: allow everything, only hard-deny catastrophic.
|
|
146
|
+
# Best for: trusted dev environments, rapid prototyping.
|
|
147
|
+
def self.turbo
|
|
148
|
+
define do
|
|
149
|
+
allow_all
|
|
150
|
+
deny :run_command, when: cmd(*CATASTROPHIC_CMDS)
|
|
151
|
+
confirm :run_command, when: cmd(*DESTRUCTIVE_GIT_CMDS)
|
|
152
|
+
WRITE_TOOLS.each { |t| confirm t, when: path(*SENSITIVE_FILES) }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# 🧪 Test — permissive for test runners, but sandboxed.
|
|
157
|
+
# Best for: CI, test suites, RAILS_ENV=test.
|
|
158
|
+
def self.test
|
|
159
|
+
define do
|
|
160
|
+
allow_all
|
|
161
|
+
deny :run_command, when: cmd(*CATASTROPHIC_CMDS)
|
|
162
|
+
deny :run_command, when: cmd(*DESTRUCTIVE_GIT_CMDS)
|
|
163
|
+
confirm :run_command, when: cmd(*RISKY_CMDS)
|
|
164
|
+
WRITE_TOOLS.each { |t| confirm t, when: path(*SENSITIVE_FILES) }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# 🔮 Auto — reads RAILS_ENV, RACK_ENV, or ANTIGRAVITY_ENV and picks a preset.
|
|
169
|
+
# Falls back to :default if unrecognized or unset.
|
|
170
|
+
def self.auto
|
|
171
|
+
env = ENV['ANTIGRAVITY_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV']
|
|
172
|
+
preset_name = ENV_MAP.fetch(env.to_s.downcase, :default)
|
|
173
|
+
send(preset_name)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# DSL methods
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def allow(tool_name = nil, **kwargs)
|
|
181
|
+
@rules << Rule.new(:allow, tool_name, condition: kwargs[:when])
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def deny(tool_name = nil, **kwargs)
|
|
185
|
+
@rules << Rule.new(:deny, tool_name, condition: kwargs[:when])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def confirm(tool_name = nil, **kwargs, &block)
|
|
189
|
+
@rules << Rule.new(:confirm, tool_name, condition: kwargs[:when], handler: block)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def allow_all
|
|
193
|
+
allow(nil)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def deny_all
|
|
197
|
+
deny(nil)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def on_confirm(&block)
|
|
201
|
+
@confirm_handler = block
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Predicate helpers
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def cmd(*patterns)
|
|
209
|
+
->(ctx) do
|
|
210
|
+
args = ctx[:args]
|
|
211
|
+
cmd_arg = args[:command_line] || args['command_line'] || args[:CommandLine] || args['CommandLine']
|
|
212
|
+
return false unless cmd_arg
|
|
213
|
+
|
|
214
|
+
cmd_arg = cmd_arg.to_s
|
|
215
|
+
patterns.any? { |p| cmd_arg.include?(p.to_s) }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def path(*globs)
|
|
220
|
+
->(ctx) do
|
|
221
|
+
args = ctx[:args]
|
|
222
|
+
path_arg = args[:path] || args['path'] ||
|
|
223
|
+
args[:file] || args['file'] ||
|
|
224
|
+
args[:target] || args['target'] ||
|
|
225
|
+
args[:file_path] || args['file_path'] ||
|
|
226
|
+
args[:target_file] || args['target_file']
|
|
227
|
+
return false unless path_arg
|
|
228
|
+
|
|
229
|
+
path_arg = path_arg.to_s
|
|
230
|
+
globs.any? { |g| File.fnmatch?(g.to_s, path_arg) }
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def args_match(**matchers)
|
|
235
|
+
->(ctx) do
|
|
236
|
+
args = ctx[:args]
|
|
237
|
+
matchers.any? do |k, v|
|
|
238
|
+
val = args[k.to_sym] || args[k.to_s]
|
|
239
|
+
val && v.match?(val.to_s)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
# Evaluation engine
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def evaluate(tool_name, args = {})
|
|
249
|
+
matching_rules = @rules.select { |r| r.matches?(tool_name, args) }
|
|
250
|
+
best_rule = matching_rules.max_by(&:precedence)
|
|
251
|
+
|
|
252
|
+
if best_rule
|
|
253
|
+
if best_rule.action == :confirm
|
|
254
|
+
handler = best_rule.handler || @confirm_handler
|
|
255
|
+
if handler
|
|
256
|
+
ctx = { name: tool_name, args: args }
|
|
257
|
+
result = handler.call(ctx)
|
|
258
|
+
{ status: result ? :allow : :deny }
|
|
259
|
+
else
|
|
260
|
+
{ status: :deny }
|
|
261
|
+
end
|
|
262
|
+
elsif best_rule.action == :deny
|
|
263
|
+
{ status: :deny, reason: "Denied by policy" }
|
|
264
|
+
else
|
|
265
|
+
{ status: :allow }
|
|
266
|
+
end
|
|
267
|
+
else
|
|
268
|
+
{ status: :deny } # Default to deny if no rules match
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'version'
|
|
4
|
+
|
|
5
|
+
module Antigravity
|
|
6
|
+
# Hand-rolled protobuf encoding/decoding for the 2 stdio handshake messages:
|
|
7
|
+
# InputConfig (SDK → localharness via stdin)
|
|
8
|
+
# OutputConfig (localharness → SDK via stdout)
|
|
9
|
+
#
|
|
10
|
+
# Uses raw protobuf wire format to avoid the google-protobuf gem dependency.
|
|
11
|
+
# See: https://github.com/palladius/antigravity-ruby-sdk/issues/7
|
|
12
|
+
#
|
|
13
|
+
# Wire format reference: https://protobuf.dev/programming-guides/encoding/
|
|
14
|
+
# Varint: wire type 0, tag = (field_number << 3) | 0
|
|
15
|
+
# Length-delimited: wire type 2, tag = (field_number << 3) | 2
|
|
16
|
+
class Protocol
|
|
17
|
+
# --- Encoding: InputConfig ---
|
|
18
|
+
# message InputConfig {
|
|
19
|
+
# string storage_directory = 1;
|
|
20
|
+
# uint32 port = 2; # optional, we leave 0
|
|
21
|
+
# string bind_address = 3; # default "localhost"
|
|
22
|
+
# ClientInfo client_info = 4;
|
|
23
|
+
# map<string, string> env = 5;
|
|
24
|
+
# }
|
|
25
|
+
# message ClientInfo {
|
|
26
|
+
# string language = 1;
|
|
27
|
+
# string version = 2;
|
|
28
|
+
# string language_version = 3;
|
|
29
|
+
# string os = 4;
|
|
30
|
+
# string os_version = 5;
|
|
31
|
+
# }
|
|
32
|
+
def self.encode_input_config(storage_directory:, bind_address: 'localhost', env: {})
|
|
33
|
+
buf = ''.b
|
|
34
|
+
|
|
35
|
+
# field 1: storage_directory (string)
|
|
36
|
+
buf << encode_string_field(1, storage_directory)
|
|
37
|
+
|
|
38
|
+
# field 3: bind_address (string, default "localhost")
|
|
39
|
+
buf << encode_string_field(3, bind_address)
|
|
40
|
+
|
|
41
|
+
# field 4: client_info (embedded message)
|
|
42
|
+
client_info = encode_client_info
|
|
43
|
+
buf << encode_bytes_field(4, client_info)
|
|
44
|
+
|
|
45
|
+
# field 5: env map entries (each is an embedded message with key=1, value=2)
|
|
46
|
+
env.each do |key, value|
|
|
47
|
+
entry = encode_string_field(1, key.to_s) + encode_string_field(2, value.to_s)
|
|
48
|
+
buf << encode_bytes_field(5, entry)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Length-prefix: 4-byte little-endian uint32
|
|
52
|
+
[buf.bytesize].pack('V') + buf
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- Decoding: OutputConfig ---
|
|
56
|
+
# message OutputConfig {
|
|
57
|
+
# int32 port = 1;
|
|
58
|
+
# string api_key = 2;
|
|
59
|
+
# }
|
|
60
|
+
def self.decode_output_config(data)
|
|
61
|
+
raise ProtocolError, 'Data too short for length prefix' if data.bytesize < 4
|
|
62
|
+
|
|
63
|
+
declared_len = data[0..3].unpack1('V')
|
|
64
|
+
payload = data[4..]
|
|
65
|
+
|
|
66
|
+
raise ProtocolError, "Truncated payload: expected #{declared_len}, got #{payload&.bytesize || 0}" if payload.nil? || payload.bytesize < declared_len
|
|
67
|
+
|
|
68
|
+
result = { port: 0, api_key: '' }
|
|
69
|
+
pos = 0
|
|
70
|
+
|
|
71
|
+
while pos < declared_len
|
|
72
|
+
tag_byte, new_pos = decode_varint(payload, pos)
|
|
73
|
+
pos = new_pos
|
|
74
|
+
field_number = tag_byte >> 3
|
|
75
|
+
wire_type = tag_byte & 0x07
|
|
76
|
+
|
|
77
|
+
case wire_type
|
|
78
|
+
when 0 # varint
|
|
79
|
+
value, pos = decode_varint(payload, pos)
|
|
80
|
+
result[:port] = value if field_number == 1
|
|
81
|
+
when 2 # length-delimited
|
|
82
|
+
length, pos = decode_varint(payload, pos)
|
|
83
|
+
value = payload[pos, length]
|
|
84
|
+
pos += length
|
|
85
|
+
result[:api_key] = value.force_encoding('UTF-8') if field_number == 2
|
|
86
|
+
else
|
|
87
|
+
raise ProtocolError, "Unsupported wire type #{wire_type} at position #{pos}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
result
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Read a length-prefixed frame from an IO (blocking).
|
|
95
|
+
# Returns the raw payload bytes.
|
|
96
|
+
def self.read_length_prefixed(io, timeout: 10)
|
|
97
|
+
len_bytes = read_exactly(io, 4, timeout: timeout)
|
|
98
|
+
raise ProtocolError, 'EOF reading length prefix' unless len_bytes&.bytesize == 4
|
|
99
|
+
|
|
100
|
+
payload_len = len_bytes.unpack1('V')
|
|
101
|
+
raise ProtocolError, "Unreasonable payload length: #{payload_len}" if payload_len > 1_000_000
|
|
102
|
+
|
|
103
|
+
frame = [payload_len].pack('V') + read_exactly(io, payload_len, timeout: timeout)
|
|
104
|
+
frame
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class << self
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def encode_client_info
|
|
111
|
+
buf = ''.b
|
|
112
|
+
buf << encode_string_field(1, 'ruby')
|
|
113
|
+
buf << encode_string_field(2, Antigravity::VERSION)
|
|
114
|
+
buf << encode_string_field(3, RUBY_VERSION)
|
|
115
|
+
buf << encode_string_field(4, ruby_platform_os)
|
|
116
|
+
buf << encode_string_field(5, RUBY_PLATFORM)
|
|
117
|
+
buf
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ruby_platform_os
|
|
121
|
+
case RUBY_PLATFORM
|
|
122
|
+
when /darwin/i then 'macos'
|
|
123
|
+
when /linux/i then 'linux'
|
|
124
|
+
when /win/i then 'windows'
|
|
125
|
+
else RUBY_PLATFORM
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Encode a string field: tag + varint length + UTF-8 bytes
|
|
130
|
+
def encode_string_field(field_number, value)
|
|
131
|
+
encode_bytes_field(field_number, value.to_s.encode('UTF-8').b)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Encode a length-delimited field: tag + varint length + raw bytes
|
|
135
|
+
def encode_bytes_field(field_number, bytes)
|
|
136
|
+
tag = (field_number << 3) | 2 # wire type 2 = length-delimited
|
|
137
|
+
encode_varint_bytes(tag) + encode_varint_bytes(bytes.bytesize) + bytes
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Encode an integer as varint bytes
|
|
141
|
+
def encode_varint_bytes(value)
|
|
142
|
+
bytes = []
|
|
143
|
+
loop do
|
|
144
|
+
byte = value & 0x7F
|
|
145
|
+
value >>= 7
|
|
146
|
+
byte |= 0x80 if value > 0
|
|
147
|
+
bytes << byte
|
|
148
|
+
break if value == 0
|
|
149
|
+
end
|
|
150
|
+
bytes.pack('C*')
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Decode a varint starting at position, returns [value, new_position]
|
|
154
|
+
def decode_varint(data, pos)
|
|
155
|
+
result = 0
|
|
156
|
+
shift = 0
|
|
157
|
+
loop do
|
|
158
|
+
raise ProtocolError, "Varint extends past end of data at pos #{pos}" if pos >= data.bytesize
|
|
159
|
+
|
|
160
|
+
byte = data.getbyte(pos)
|
|
161
|
+
pos += 1
|
|
162
|
+
result |= (byte & 0x7F) << shift
|
|
163
|
+
break if (byte & 0x80) == 0
|
|
164
|
+
|
|
165
|
+
shift += 7
|
|
166
|
+
raise ProtocolError, 'Varint too long' if shift >= 64
|
|
167
|
+
end
|
|
168
|
+
[result, pos]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Read exactly n bytes from IO with timeout
|
|
172
|
+
def read_exactly(io, n, timeout: 10)
|
|
173
|
+
buf = ''.b
|
|
174
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
175
|
+
while buf.bytesize < n
|
|
176
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
177
|
+
raise ProtocolError, "Timeout reading #{n} bytes from harness" if remaining <= 0
|
|
178
|
+
|
|
179
|
+
ready = IO.select([io], nil, nil, [remaining, 0.1].min)
|
|
180
|
+
if ready
|
|
181
|
+
chunk = io.read_nonblock(n - buf.bytesize, exception: false)
|
|
182
|
+
case chunk
|
|
183
|
+
when String then buf << chunk
|
|
184
|
+
when :wait_readable then next
|
|
185
|
+
when nil then raise ProtocolError, 'EOF while reading from harness'
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
buf
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
data/lib/antigravity/skill.rb
CHANGED
|
@@ -3,30 +3,78 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
|
|
5
5
|
module Antigravity
|
|
6
|
+
# Represents an Agent Skill loaded from a SKILL.md file or defined inline.
|
|
7
|
+
# Spec: https://agentskills.io/specification
|
|
8
|
+
#
|
|
9
|
+
# A skill directory must contain a SKILL.md with YAML frontmatter:
|
|
10
|
+
# ---
|
|
11
|
+
# name: my-skill
|
|
12
|
+
# description: What this skill does
|
|
13
|
+
# ---
|
|
14
|
+
# # Instructions in markdown...
|
|
6
15
|
class Skill < Base
|
|
7
16
|
|
|
8
|
-
attr_reader :name, :description, :instructions, :path
|
|
17
|
+
attr_reader :name, :description, :instructions, :path, :metadata
|
|
9
18
|
|
|
19
|
+
# Load a skill from a directory containing SKILL.md.
|
|
20
|
+
# @param path [String] path to skill directory
|
|
10
21
|
def initialize(path)
|
|
11
22
|
@path = File.expand_path(path)
|
|
23
|
+
@metadata = {}
|
|
12
24
|
parse_skill_file
|
|
13
25
|
end
|
|
14
26
|
|
|
27
|
+
# Factory: load from directory path.
|
|
15
28
|
def self.load(path)
|
|
16
29
|
new(path)
|
|
17
30
|
end
|
|
18
31
|
|
|
32
|
+
# Factory: create an inline skill (no file needed).
|
|
33
|
+
# @param name [String] skill name (lowercase, hyphenated)
|
|
34
|
+
# @param description [String] what the skill does
|
|
35
|
+
# @param instructions [String] the skill instructions (markdown)
|
|
36
|
+
# @return [Skill] an inline skill instance
|
|
37
|
+
def self.inline(name:, description:, instructions:)
|
|
38
|
+
skill = allocate
|
|
39
|
+
skill.send(:init_inline, name: name, description: description, instructions: instructions)
|
|
40
|
+
skill
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if a directory is a valid skill (contains SKILL.md).
|
|
44
|
+
# @param path [String] directory path to check
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def self.skill_dir?(path)
|
|
47
|
+
SkillResolver.skill_dir?(path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_s
|
|
51
|
+
"#<Skill name=#{@name.inspect} path=#{@path.inspect}>"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def inspect
|
|
55
|
+
"#<Antigravity::Skill name=#{@name.inspect} description=#{@description.inspect} path=#{@path.inspect}>"
|
|
56
|
+
end
|
|
57
|
+
|
|
19
58
|
private
|
|
20
59
|
|
|
60
|
+
def init_inline(name:, description:, instructions:)
|
|
61
|
+
@name = name
|
|
62
|
+
@description = description
|
|
63
|
+
@instructions = instructions
|
|
64
|
+
@path = nil # inline skills have no path
|
|
65
|
+
@metadata = {}
|
|
66
|
+
end
|
|
67
|
+
|
|
21
68
|
def parse_skill_file
|
|
22
69
|
skill_file = File.join(@path, "SKILL.md")
|
|
23
70
|
raise ArgumentError, "SKILL.md not found at #{@path}" unless File.exist?(skill_file)
|
|
24
71
|
|
|
25
|
-
content = File.read(skill_file)
|
|
72
|
+
content = File.read(skill_file, encoding: 'UTF-8')
|
|
26
73
|
if content =~ /\A(---\s*\n.*?\n?)^(---\s*$\n?)/m
|
|
27
|
-
front_matter = YAML.safe_load(Regexp.last_match(1))
|
|
28
|
-
@name = front_matter["name"]
|
|
29
|
-
@description = front_matter["description"]
|
|
74
|
+
front_matter = YAML.safe_load(Regexp.last_match(1)) || {}
|
|
75
|
+
@name = front_matter["name"] || File.basename(@path)
|
|
76
|
+
@description = front_matter["description"] || ""
|
|
77
|
+
@metadata = front_matter.fetch("metadata", {})
|
|
30
78
|
@instructions = Regexp.last_match.post_match.strip
|
|
31
79
|
else
|
|
32
80
|
@name = File.basename(@path)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Antigravity
|
|
6
|
+
# Resolves skill paths from various inputs: single skill dir, container dir,
|
|
7
|
+
# or GitHub URL. Returns expanded absolute paths to skill directories.
|
|
8
|
+
#
|
|
9
|
+
# Resolution logic:
|
|
10
|
+
# 1. Path contains SKILL.md -> single skill
|
|
11
|
+
# 2. Path has a skills/ subfolder -> discover children with SKILL.md
|
|
12
|
+
# 3. Path children have SKILL.md -> flat container discovery
|
|
13
|
+
# 4. GitHub URL -> clone/cache then resolve locally
|
|
14
|
+
#
|
|
15
|
+
# Spec compliance: https://agentskills.io/specification
|
|
16
|
+
class SkillResolver
|
|
17
|
+
GITHUB_URL_PATTERN = %r{\Ahttps?://github\.com/}i
|
|
18
|
+
CACHE_DIR = File.expand_path('~/.antigravity/cache/ruby-sdk/skills')
|
|
19
|
+
|
|
20
|
+
# Resolve a path or URL to an array of valid skill directory paths.
|
|
21
|
+
# @param path_or_url [String] local path or GitHub URL
|
|
22
|
+
# @return [Array<String>] expanded absolute paths to skill directories
|
|
23
|
+
def self.resolve(path_or_url)
|
|
24
|
+
path_or_url = path_or_url.to_s.strip
|
|
25
|
+
|
|
26
|
+
if github_url?(path_or_url)
|
|
27
|
+
resolve_github(path_or_url)
|
|
28
|
+
else
|
|
29
|
+
resolve_local(path_or_url)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Discover all skill directories inside a container path.
|
|
34
|
+
# Checks for skills/ subfolder first, then scans children directly.
|
|
35
|
+
# @param container_path [String] path to scan for skills
|
|
36
|
+
# @return [Array<String>] sorted list of skill directory paths
|
|
37
|
+
def self.discover(container_path)
|
|
38
|
+
expanded = File.expand_path(container_path)
|
|
39
|
+
return [] unless File.directory?(expanded)
|
|
40
|
+
|
|
41
|
+
# Check for skills/ subfolder first (convention from agentskills.io)
|
|
42
|
+
skills_subdir = File.join(expanded, 'skills')
|
|
43
|
+
scan_dir = File.directory?(skills_subdir) ? skills_subdir : expanded
|
|
44
|
+
|
|
45
|
+
Dir.children(scan_dir)
|
|
46
|
+
.map { |child| File.join(scan_dir, child) }
|
|
47
|
+
.select { |child_path| skill_dir?(child_path) }
|
|
48
|
+
.sort
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if a directory is a valid skill (contains SKILL.md).
|
|
52
|
+
# @param path [String] directory path to check
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def self.skill_dir?(path)
|
|
55
|
+
File.directory?(path) && File.exist?(File.join(path, 'SKILL.md'))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if a string looks like a GitHub URL.
|
|
59
|
+
# @param str [String]
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def self.github_url?(str)
|
|
62
|
+
str.match?(GITHUB_URL_PATTERN)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class << self
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def resolve_local(path)
|
|
69
|
+
expanded = File.expand_path(path)
|
|
70
|
+
raise ArgumentError, "Skill path does not exist: #{expanded}" unless File.exist?(expanded)
|
|
71
|
+
raise ArgumentError, "Skill path is not a directory: #{expanded}" unless File.directory?(expanded)
|
|
72
|
+
|
|
73
|
+
# Case 1: Direct skill dir (has SKILL.md)
|
|
74
|
+
return [expanded] if skill_dir?(expanded)
|
|
75
|
+
|
|
76
|
+
# Case 2 & 3: Container dir -- discover children
|
|
77
|
+
discovered = discover(expanded)
|
|
78
|
+
return discovered unless discovered.empty?
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "No skills found at #{expanded}. Expected SKILL.md or a skills/ subfolder."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Parse a GitHub URL into components.
|
|
84
|
+
# Supports:
|
|
85
|
+
# https://github.com/org/repo
|
|
86
|
+
# https://github.com/org/repo/tree/main/skills/my-skill
|
|
87
|
+
# https://github.com/org/repo/tree/main/skills
|
|
88
|
+
#
|
|
89
|
+
# Returns [org, repo, subpath_or_nil]
|
|
90
|
+
def parse_github_url(url)
|
|
91
|
+
# Strip trailing slash
|
|
92
|
+
url = url.chomp('/')
|
|
93
|
+
|
|
94
|
+
case url
|
|
95
|
+
when %r{github\.com/([^/]+)/([^/]+)/tree/[^/]+/(.+)}
|
|
96
|
+
# URL with tree/branch/subpath
|
|
97
|
+
[Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3)]
|
|
98
|
+
when %r{github\.com/([^/]+)/([^/]+)(?:\.git)?/?$}
|
|
99
|
+
# Bare repo URL
|
|
100
|
+
[Regexp.last_match(1), Regexp.last_match(2), nil]
|
|
101
|
+
when %r{github\.com/([^/]+)/([^/]+)/(.+)}
|
|
102
|
+
# URL with subpath but no /tree/branch/ (e.g. from skill_name:)
|
|
103
|
+
[Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3)]
|
|
104
|
+
else
|
|
105
|
+
raise ArgumentError, "Cannot parse GitHub URL: #{url}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def resolve_github(url)
|
|
110
|
+
org, repo, subpath = parse_github_url(url)
|
|
111
|
+
local_repo = clone_or_update(org, repo)
|
|
112
|
+
|
|
113
|
+
# Resolve subpath within the cloned repo
|
|
114
|
+
target = subpath ? File.join(local_repo, subpath) : local_repo
|
|
115
|
+
resolve_local(target)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Clone (or update) a GitHub repo into the cache.
|
|
119
|
+
# Returns the local path to the cloned repo.
|
|
120
|
+
def clone_or_update(org, repo)
|
|
121
|
+
cache_path = File.join(CACHE_DIR, org, repo)
|
|
122
|
+
|
|
123
|
+
if File.directory?(File.join(cache_path, '.git'))
|
|
124
|
+
# Already cloned -- pull latest
|
|
125
|
+
system('git', '-C', cache_path, 'pull', '--ff-only', '--quiet',
|
|
126
|
+
out: File::NULL, err: File::NULL)
|
|
127
|
+
else
|
|
128
|
+
# Fresh clone
|
|
129
|
+
FileUtils.mkdir_p(File.dirname(cache_path))
|
|
130
|
+
clone_url = "https://github.com/#{org}/#{repo}.git"
|
|
131
|
+
success = system('git', 'clone', '--depth=1', '--quiet', clone_url, cache_path,
|
|
132
|
+
out: File::NULL, err: File::NULL)
|
|
133
|
+
raise "Failed to clone #{clone_url}. Check the URL and your network." unless success
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
cache_path
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|