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.
@@ -2,8 +2,9 @@
2
2
 
3
3
  module Antigravity
4
4
  EMOJIS = {
5
+ antigravity: "๐Ÿ›ฐ๏ธ",
5
6
  gem: "๐Ÿ’Ž",
6
- agent: "๐Ÿ’Ž",
7
+ agent: "๐Ÿ•ต๏ธโ€โ™‚๏ธ",
7
8
  prompt: "๐Ÿ’ฌ",
8
9
  response: "๐Ÿค–",
9
10
  thinking: "๐Ÿค”",
@@ -13,35 +14,63 @@ module Antigravity
13
14
  sidecar: "๐Ÿš—",
14
15
  logger: "๐Ÿชต",
15
16
  skill: "๐Ÿ“",
17
+ check: "โœ…",
18
+ magnifying: "๐Ÿ”",
19
+ workspace: "๐Ÿ“‚",
20
+ message: "๐Ÿ’ฌ",
16
21
  guard: "๐Ÿ›ก๏ธ",
17
22
  test: "๐Ÿงช",
18
- success: "โœ…"
23
+ success: "โœ…",
24
+ unknown: "๐Ÿคท"
25
+ }.freeze
26
+
27
+ # Name fragments we recognise in the class hierarchy
28
+ EMOJI_CLASS_MAP = {
29
+ "Agent" => :agent,
30
+ "Tool" => :tool,
31
+ "Sidecar" => :sidecar,
32
+ "Runner" => :sidecar,
33
+ "Skill" => :skill,
34
+ "Message" => :message,
35
+ "Chunk" => :message,
36
+ "Guard" => :guard,
37
+ "Logger" => :logger
19
38
  }.freeze
20
39
 
21
40
  class << self
22
41
  def emoji(key)
23
- EMOJIS[key.to_sym] || "๐Ÿ’Ž"
42
+ EMOJIS[key.to_sym] || EMOJIS[:unknown]
24
43
  end
25
44
 
26
45
  def emoji_for(target)
27
- key = case target
28
- when Message, Chunk
29
- target.respond_to?(:role) && target.role == :user ? :prompt : :response
30
- when Symbol, String
31
- target
32
- else
33
- klass = target.is_a?(Class) || target.is_a?(Module) ? target : target.class
34
- matched_part = klass.name&.split("::")&.reverse&.find do |part|
35
- %w[Agent Tool Sidecar Skill Message Guard Logger].include?(part)
36
- end
37
- matched_part ? matched_part.downcase.to_sym : :gem
38
- end
39
-
46
+ key = resolve_emoji_key(target)
40
47
  emoji(key)
41
48
  end
49
+
50
+ private
51
+
52
+ def resolve_emoji_key(target)
53
+ # Message/Chunk: role-aware
54
+ if target.is_a?(Message) || (defined?(Chunk) && target.is_a?(Chunk))
55
+ return target.respond_to?(:role) && target.role == :user ? :prompt : :response
56
+ end
57
+
58
+ # Direct symbol/string lookup
59
+ return target if target.is_a?(Symbol) || target.is_a?(String)
60
+
61
+ # Class hierarchy reflection
62
+ klass = target.is_a?(Class) || target.is_a?(Module) ? target : target.class
63
+ parts = klass.name&.split("::") || []
64
+ parts.reverse_each do |part|
65
+ return EMOJI_CLASS_MAP[part] if EMOJI_CLASS_MAP.key?(part)
66
+ end
67
+
68
+ :unknown
69
+ end
42
70
  end
43
71
 
44
- # Mixin providing polymorphic .emoji class and instance methods
72
+ # Mixin providing polymorphic .emoji class and #emoji instance methods.
73
+ # Auto-included by Antigravity::Base via inherited hook.
45
74
  module Emojifiable
46
75
  def emoji
47
76
  Antigravity.emoji_for(self)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Antigravity
4
+ # Base error for all Antigravity SDK errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the localharness binary cannot be found
8
+ class HarnessNotFoundError < Error; end
9
+
10
+ # Raised when the stdio handshake with localharness fails
11
+ class HarnessHandshakeError < Error; end
12
+
13
+ # Raised when protobuf encoding/decoding fails
14
+ class ProtocolError < Error; end
15
+
16
+ # Raised when a tool callback fails
17
+ class ToolError < Error; end
18
+
19
+ # Raised when trying to execute an unregistered tool
20
+ class ToolNotFoundError < ToolError; end
21
+
22
+ # Raised when WebSocket connection fails
23
+ class ConnectionError < Error; end
24
+
25
+ # Raised when required configuration (e.g., GEMINI_API_KEY) is missing
26
+ class ConfigError < Error; end
27
+ end
@@ -1,31 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "logger"
3
+ require "json"
4
4
  require "fileutils"
5
5
 
6
6
  module Antigravity
7
7
  module Guards
8
- # Configurable opt-in logger guard supporting file loggers and Rails.logger
8
+ # Dual-output logger guard:
9
+ # 1. JSONL (log/antigravity.jsonl) โ€” structured, machine-parseable, full data
10
+ # 2. Compact log (log/antigravity.log) โ€” human-readable one-liners with byte sizes
11
+ # Falls back to Rails.logger for both if available.
9
12
  class AgentLogger
10
- attr_reader :logger, :target_description
13
+ attr_reader :target_description
11
14
 
12
- def initialize(log_target = nil, level: Logger::INFO, silent_notice: false)
13
- resolved_target = resolve_log_target(log_target)
15
+ def initialize(log_target = nil, level: :info, silent_notice: false)
16
+ resolved = resolve_log_target(log_target)
14
17
 
15
- if resolved_target.is_a?(String)
16
- dir = File.dirname(resolved_target)
18
+ if resolved.is_a?(String)
19
+ dir = File.dirname(resolved)
17
20
  FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
18
- @logger = ::Logger.new(resolved_target)
19
- @target_description = resolved_target
20
- elsif resolved_target.respond_to?(:info)
21
- @logger = resolved_target
21
+
22
+ # Fat JSONL log
23
+ @jsonl = File.open(resolved, 'a')
24
+ @jsonl.sync = true
25
+
26
+ # Skinny compact log (same dir, .log extension)
27
+ compact_path = resolved.sub(/\.jsonl$/, '.log')
28
+ @compact = File.open(compact_path, 'a')
29
+ @compact.sync = true
30
+
31
+ @rails_logger = nil
32
+ @target_description = resolved
33
+ elsif resolved.respond_to?(:info)
34
+ @jsonl = nil
35
+ @compact = nil
36
+ @rails_logger = resolved
22
37
  @target_description = "Rails.logger"
23
38
  else
24
- @logger = ::Logger.new($stdout)
39
+ @jsonl = $stdout
40
+ @compact = nil
41
+ @rails_logger = nil
25
42
  @target_description = "$stdout"
26
43
  end
27
44
 
28
- @logger.level = level if @logger.respond_to?(:level=)
45
+ @level = level
29
46
 
30
47
  unless silent_notice
31
48
  puts "#{Antigravity.emoji(:logger)} Logging to #{@target_description}"
@@ -33,25 +50,49 @@ module Antigravity
33
50
  end
34
51
 
35
52
  def before_prompt(prompt_text)
36
- @logger.info("#{Antigravity.emoji(:prompt)} [Antigravity::Prompt] User: '#{prompt_text}'")
53
+ size = prompt_text.to_s.bytesize
54
+ log_jsonl('prompt', { user_input: prompt_text })
55
+ log_compact("#{Antigravity.emoji(:prompt)} PROMPT #{size}B | #{prompt_text.to_s[0, 80]}")
37
56
  end
38
57
 
39
58
  def after_response(response)
40
- @logger.info("#{Antigravity.emoji(:response)} [Antigravity::Response] Assistant (#{response.model_id}): #{response.content.strip}")
59
+ content = response.content&.strip || ''
60
+ log_jsonl('response', {
61
+ model: response.model_id,
62
+ content: content,
63
+ tokens: response.usage[:total_token_count],
64
+ tool_calls: response.tool_calls_count,
65
+ steps: response.steps&.length
66
+ })
67
+ log_compact("#{Antigravity.emoji(:response)} RESPONSE #{content.bytesize}B | " \
68
+ "tokens=#{response.usage[:total_token_count]} " \
69
+ "tools=#{response.tool_calls_count} " \
70
+ "steps=#{response.steps&.length} " \
71
+ "model=#{response.model_id}")
41
72
  end
42
73
 
43
74
  def before_tool_call(tool_name, params)
44
- @logger.info("#{Antigravity.emoji(:tool)} [Antigravity::Tool] Executing '#{tool_name}' with params: #{params.inspect}")
75
+ params_size = params.to_s.bytesize
76
+ log_jsonl('tool_call', { tool: tool_name, params: params })
77
+ log_compact("#{Antigravity.emoji(:tool)} TOOL_CALL #{tool_name} params=#{params_size}B")
45
78
  end
46
79
 
47
80
  def after_tool_call(tool_name, params, result)
48
- prefix = result.to_s.include?("TOOL BLOCKED") ? Antigravity.emoji(:tool_blocked) : Antigravity.emoji(:tool_result)
49
- @logger.info("#{prefix} [Antigravity::Tool] Result for '#{tool_name}': #{result}")
81
+ blocked = result.to_s.include?("TOOL BLOCKED")
82
+ result_size = result.to_s.bytesize
83
+ log_jsonl('tool_result', {
84
+ tool: tool_name,
85
+ result: result.to_s[0, 500],
86
+ blocked: blocked
87
+ })
88
+ status = blocked ? 'BLOCKED' : 'OK'
89
+ log_compact("#{blocked ? Antigravity.emoji(:tool_blocked) : Antigravity.emoji(:tool_result)} TOOL_RESULT #{tool_name} #{status} result=#{result_size}B")
50
90
  result
51
91
  end
52
92
 
53
93
  def sidecar_event(event_type, payload)
54
- @logger.info("#{Antigravity.emoji(:sidecar)} [Antigravity::Sidecar] Event :#{event_type} payload: #{payload.inspect}")
94
+ log_jsonl('sidecar', { type: event_type.to_s, payload: payload })
95
+ log_compact("#{Antigravity.emoji(:sidecar)} SIDECAR :#{event_type}")
55
96
  end
56
97
 
57
98
  def attach_to(agent)
@@ -64,17 +105,36 @@ module Antigravity
64
105
 
65
106
  private
66
107
 
108
+ def ts
109
+ Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
110
+ end
111
+
112
+ def log_jsonl(event, data)
113
+ if @rails_logger
114
+ @rails_logger.info("[Antigravity] #{event}: #{data.inspect}")
115
+ elsif @jsonl
116
+ entry = { ts: ts, event: event, pid: Process.pid }.merge(data.compact)
117
+ @jsonl.puts(JSON.generate(entry))
118
+ end
119
+ end
120
+
121
+ def log_compact(line)
122
+ if @compact
123
+ @compact.puts("#{ts} #{line}")
124
+ end
125
+ end
126
+
67
127
  def resolve_log_target(target)
68
128
  return target if target
69
129
 
70
130
  if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
71
131
  Rails.logger
72
132
  elsif ENV["RAILS_ENV"] && !ENV["RAILS_ENV"].empty?
73
- "log/#{ENV['RAILS_ENV']}.log"
133
+ "log/#{ENV['RAILS_ENV']}.jsonl"
74
134
  elsif ENV["RACK_ENV"] && !ENV["RACK_ENV"].empty?
75
- "log/#{ENV['RACK_ENV']}.log"
135
+ "log/#{ENV['RACK_ENV']}.jsonl"
76
136
  else
77
- "log/antigravity.log"
137
+ "log/antigravity.jsonl"
78
138
  end
79
139
  end
80
140
  end
@@ -9,6 +9,7 @@ module Antigravity
9
9
  @post_response_hooks = []
10
10
  @pre_tool_hooks = []
11
11
  @post_tool_hooks = []
12
+ @listeners = Hash.new { |h, k| h[k] = [] }
12
13
  end
13
14
 
14
15
  def before_prompt(&block)
@@ -29,6 +30,17 @@ module Antigravity
29
30
  @post_tool_hooks << block if block_given?
30
31
  end
31
32
 
33
+ # Generic event system โ€” subscribe to any named event.
34
+ # Usage: hooks.on(:ws_message) { |msg| puts msg.keys }
35
+ def on(event, &block)
36
+ @listeners[event.to_sym] << block if block_given?
37
+ end
38
+
39
+ # Emit a named event to all subscribers.
40
+ def emit(event, *args)
41
+ @listeners[event.to_sym].each { |cb| cb.call(*args) }
42
+ end
43
+
32
44
  def run_pre_prompt(prompt_text)
33
45
  @pre_prompt_hooks.each { |hook| hook.call(prompt_text) }
34
46
  end
@@ -1,24 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Antigravity
4
- class Message
5
- include Emojifiable
4
+ # Represents a response message from the agent.
5
+ # Mirrors Python SDK's ChatResponse with metadata.
6
+ class Message < Base
7
+ attr_accessor :role, :content, :thinking, :tool_calls, :model_id, :tokens,
8
+ :steps, :tool_calls_count, :usage, :delta
6
9
 
7
- attr_accessor :role, :content, :thinking, :tool_calls, :model_id, :tokens
8
-
9
- def initialize(role: :assistant, content: "", thinking: "", tool_calls: [], model_id: nil)
10
+ def initialize(role: :assistant, content: '', thinking: '', tool_calls: [],
11
+ model_id: nil, steps: [], tool_calls_count: 0, usage: nil,
12
+ delta: false)
10
13
  @role = role
11
14
  @content = content
12
15
  @thinking = thinking
13
16
  @tool_calls = tool_calls
14
17
  @model_id = model_id
15
18
  @tokens = { input: 0, output: 0 }
19
+ @steps = steps
20
+ @tool_calls_count = tool_calls_count
21
+ @usage = usage || { prompt_token_count: 0, candidates_token_count: 0, total_token_count: 0 }
22
+ @delta = delta
23
+ end
24
+
25
+ # Is this a streaming delta (partial) or a complete response?
26
+ def delta?
27
+ @delta
16
28
  end
17
29
  end
18
30
 
31
+ # Backward-compatible alias for streaming chunks
19
32
  class Chunk < Message
20
- def initialize(role: :assistant, content: "", thinking: "", tool_calls: [], model_id: nil)
21
- super(role: role, content: content, thinking: thinking, tool_calls: tool_calls, model_id: model_id)
33
+ def initialize(**kwargs)
34
+ super(**kwargs, delta: true)
22
35
  end
23
36
  end
24
37
  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
@@ -6,9 +6,9 @@ require "fileutils"
6
6
 
7
7
  module Antigravity
8
8
  module Sidecar
9
- # Base class for non-blocking agent sidecars
10
- class Base
11
- include Emojifiable
9
+ # Abstract runner for non-blocking agent sidecars.
10
+ # Subclass and implement #process_event.
11
+ class Runner < Antigravity::Base
12
12
 
13
13
  attr_reader :name, :queue, :worker_thread
14
14
 
@@ -52,7 +52,7 @@ module Antigravity
52
52
  end
53
53
 
54
54
  # Async Audit Logger Sidecar
55
- class AuditLogger < Base
55
+ class AuditLogger < Runner
56
56
  attr_reader :log_file
57
57
 
58
58
  def initialize(log_file = "log/agent_audit.jsonl")
@@ -72,7 +72,7 @@ module Antigravity
72
72
  end
73
73
 
74
74
  # Async Vulnerability & Code Quality Scanner Sidecar
75
- class VulnerabilityScanner < Base
75
+ class VulnerabilityScanner < Runner
76
76
  attr_reader :scanned_events
77
77
 
78
78
  def initialize
@@ -90,5 +90,7 @@ module Antigravity
90
90
  @scanned_events << { tool: tool_name, params: params, verified: true }
91
91
  end
92
92
  end
93
+ # Backward-compatible alias
94
+ Base = Runner
93
95
  end
94
96
  end
@@ -3,31 +3,78 @@
3
3
  require "yaml"
4
4
 
5
5
  module Antigravity
6
- class Skill
7
- include Emojifiable
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...
15
+ class Skill < Base
8
16
 
9
- attr_reader :name, :description, :instructions, :path
17
+ attr_reader :name, :description, :instructions, :path, :metadata
10
18
 
19
+ # Load a skill from a directory containing SKILL.md.
20
+ # @param path [String] path to skill directory
11
21
  def initialize(path)
12
22
  @path = File.expand_path(path)
23
+ @metadata = {}
13
24
  parse_skill_file
14
25
  end
15
26
 
27
+ # Factory: load from directory path.
16
28
  def self.load(path)
17
29
  new(path)
18
30
  end
19
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
+
20
58
  private
21
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
+
22
68
  def parse_skill_file
23
69
  skill_file = File.join(@path, "SKILL.md")
24
70
  raise ArgumentError, "SKILL.md not found at #{@path}" unless File.exist?(skill_file)
25
71
 
26
- content = File.read(skill_file)
72
+ content = File.read(skill_file, encoding: 'UTF-8')
27
73
  if content =~ /\A(---\s*\n.*?\n?)^(---\s*$\n?)/m
28
- front_matter = YAML.safe_load(Regexp.last_match(1))
29
- @name = front_matter["name"]
30
- @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", {})
31
78
  @instructions = Regexp.last_match.post_match.strip
32
79
  else
33
80
  @name = File.basename(@path)