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.
@@ -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
@@ -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