skill 0.1.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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE +21 -0
- data/README.md +280 -0
- data/exe/skill +7 -0
- data/lib/skill/agents.rb +148 -0
- data/lib/skill/archive.rb +143 -0
- data/lib/skill/boundary.rb +42 -0
- data/lib/skill/cli.rb +90 -0
- data/lib/skill/commands/add.rb +139 -0
- data/lib/skill/commands/agents.rb +19 -0
- data/lib/skill/commands/command.rb +84 -0
- data/lib/skill/commands/find.rb +51 -0
- data/lib/skill/commands/list.rb +73 -0
- data/lib/skill/commands/remove.rb +59 -0
- data/lib/skill/commands/selection.rb +87 -0
- data/lib/skill/commands/update.rb +175 -0
- data/lib/skill/commands.rb +15 -0
- data/lib/skill/directory.rb +59 -0
- data/lib/skill/discovery.rb +166 -0
- data/lib/skill/fetcher.rb +62 -0
- data/lib/skill/github.rb +158 -0
- data/lib/skill/http.rb +76 -0
- data/lib/skill/installer.rb +92 -0
- data/lib/skill/limits.rb +40 -0
- data/lib/skill/lockfile.rb +112 -0
- data/lib/skill/manifest.rb +103 -0
- data/lib/skill/session.rb +88 -0
- data/lib/skill/source.rb +137 -0
- data/lib/skill/style.rb +28 -0
- data/lib/skill/targets.rb +173 -0
- data/lib/skill/tree.rb +90 -0
- data/lib/skill/version.rb +5 -0
- data/lib/skill/workspace.rb +161 -0
- data/lib/skill.rb +60 -0
- metadata +84 -0
data/lib/skill/http.rb
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Skill
|
|
8
|
+
# A small HTTPS client on Net::HTTP: follows redirects, stops reading at a
|
|
9
|
+
# byte limit, and sends credentials only to the host they were given for.
|
|
10
|
+
# Net::HTTP reads proxies from +https_proxy+ and +no_proxy+.
|
|
11
|
+
class HTTP
|
|
12
|
+
Response = Struct.new(:status, :body, :headers, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
MAX_REDIRECTS = 5
|
|
15
|
+
|
|
16
|
+
def initialize(user_agent: "skill/#{VERSION} (+https://github.com/crmne/skill)")
|
|
17
|
+
@user_agent = user_agent
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Fetches +url+ and returns a Response whose body is a String, or, when
|
|
21
|
+
# +io+ is given, writes the body to +io+. Raises NetworkError when the
|
|
22
|
+
# body is larger than +limit+ bytes or the request fails, and leaves
|
|
23
|
+
# statuses other than redirects to the caller. Only a 200 body goes to
|
|
24
|
+
# +io+; any other is returned as a String.
|
|
25
|
+
def get(url, limit:, headers: {}, io: nil)
|
|
26
|
+
uri = URI(url)
|
|
27
|
+
credentials_host = uri.host
|
|
28
|
+
MAX_REDIRECTS.succ.times do
|
|
29
|
+
raise NetworkError, "Refusing to download over plain HTTP: #{redacted(uri)}" unless uri.scheme == 'https'
|
|
30
|
+
|
|
31
|
+
sent = uri.host == credentials_host ? headers : headers.reject { |key, _| key.casecmp?('authorization') }
|
|
32
|
+
response = request(uri, sent, limit, io)
|
|
33
|
+
return response unless (300..399).cover?(response.status) && response.headers['location']
|
|
34
|
+
|
|
35
|
+
uri = URI.join(uri.to_s, response.headers['location'])
|
|
36
|
+
end
|
|
37
|
+
raise NetworkError, "Too many redirects from #{redacted(URI(url))}"
|
|
38
|
+
rescue URI::InvalidURIError => e
|
|
39
|
+
raise NetworkError, "Invalid address: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def request(uri, headers, limit, io)
|
|
45
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60) do |http|
|
|
46
|
+
get = Net::HTTP::Get.new(uri)
|
|
47
|
+
get['User-Agent'] = @user_agent
|
|
48
|
+
headers.each { |key, value| get[key] = value }
|
|
49
|
+
http.request(get) { |response| return read(response, limit, io) }
|
|
50
|
+
end
|
|
51
|
+
rescue SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
|
|
52
|
+
raise NetworkError, "Could not reach #{uri.host}: #{e.class}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def read(response, limit, io)
|
|
56
|
+
status = response.code.to_i
|
|
57
|
+
headers = { 'location' => response['location'], 'content-type' => response['content-type'] }
|
|
58
|
+
return Response.new(status: status, body: '', headers: headers) if (300..399).cover?(status)
|
|
59
|
+
|
|
60
|
+
io = nil unless status == 200
|
|
61
|
+
body = io ? nil : String.new(encoding: Encoding::BINARY)
|
|
62
|
+
size = 0
|
|
63
|
+
response.read_body do |chunk|
|
|
64
|
+
size += chunk.bytesize
|
|
65
|
+
raise TooLargeError, "#{response.uri&.host} sent more than #{limit} bytes" if size > limit
|
|
66
|
+
|
|
67
|
+
io ? io.write(chunk) : body << chunk
|
|
68
|
+
end
|
|
69
|
+
Response.new(status: status, body: body, headers: headers)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def redacted(uri)
|
|
73
|
+
"#{uri.scheme}://#{uri.host}#{uri.path}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Skill
|
|
7
|
+
# Copies skill folders into target folders. Each copy is built beside its
|
|
8
|
+
# target, under a hidden name, and swapped in with renames once every copy
|
|
9
|
+
# of every skill is ready, so a failure leaves the installed skills as
|
|
10
|
+
# they were.
|
|
11
|
+
class Installer
|
|
12
|
+
# One skill to copy: the name it installs as, its Tree, the folders it
|
|
13
|
+
# goes into, and whether its files keep their executable bits. +entry+
|
|
14
|
+
# and +labels+ are for the caller's bookkeeping.
|
|
15
|
+
Copy = Struct.new(:name, :tree, :targets, :executable, :entry, :labels, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
# Installs every Copy in +copies+ into each of its target folders.
|
|
18
|
+
def install(copies)
|
|
19
|
+
staged = {}
|
|
20
|
+
copies.each do |copy|
|
|
21
|
+
copy.targets.each do |target|
|
|
22
|
+
FileUtils.mkdir_p(target)
|
|
23
|
+
staging = hidden(target, copy.name)
|
|
24
|
+
staged[staging] = destination(target, copy.name)
|
|
25
|
+
stage(copy, staging)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
swapped = {}
|
|
29
|
+
staged.each { |staging, final| swapped[final] = swap(staging, final) }
|
|
30
|
+
swapped.each_value { |backup| FileUtils.rm_rf(backup) if backup }
|
|
31
|
+
rescue StandardError
|
|
32
|
+
swapped&.reverse_each { |final, backup| restore(final, backup) }
|
|
33
|
+
staged&.each_key { |staging| FileUtils.rm_rf(staging) }
|
|
34
|
+
raise
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Deletes the skill +name+ from each folder in +targets+.
|
|
38
|
+
def remove(name, targets)
|
|
39
|
+
targets.each do |target|
|
|
40
|
+
final = destination(target, name)
|
|
41
|
+
next unless File.exist?(final) || File.symlink?(final)
|
|
42
|
+
|
|
43
|
+
trash = hidden(target, "#{name}-removed")
|
|
44
|
+
File.rename(final, trash)
|
|
45
|
+
FileUtils.rm_rf(trash)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns the folder +name+ installs to inside +target+.
|
|
50
|
+
def self.destination(target, name)
|
|
51
|
+
raise UnsafeError, "#{name.inspect} is not a skill name" unless Manifest.valid_name?(name)
|
|
52
|
+
|
|
53
|
+
File.join(File.expand_path(target), name)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def destination(target, name)
|
|
59
|
+
self.class.destination(target, name)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stage(copy, staging)
|
|
63
|
+
Dir.mkdir(staging, 0o755)
|
|
64
|
+
copy.tree.entries.each do |relative, entry|
|
|
65
|
+
source = File.join(copy.tree.root, relative)
|
|
66
|
+
file = File.join(staging, relative)
|
|
67
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
68
|
+
FileUtils.cp(source, file)
|
|
69
|
+
File.chmod(copy.executable && entry.executable ? 0o755 : 0o644, file)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def swap(staging, final)
|
|
74
|
+
backup = nil
|
|
75
|
+
if File.exist?(final) || File.symlink?(final)
|
|
76
|
+
backup = hidden(File.dirname(final), "#{File.basename(final)}-previous")
|
|
77
|
+
File.rename(final, backup)
|
|
78
|
+
end
|
|
79
|
+
File.rename(staging, final)
|
|
80
|
+
backup
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def restore(final, backup)
|
|
84
|
+
FileUtils.rm_rf(final)
|
|
85
|
+
File.rename(backup, final) if backup && File.exist?(backup)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def hidden(target, name)
|
|
89
|
+
File.join(target, ".skill-#{name}-#{SecureRandom.hex(4)}")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/skill/limits.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skill
|
|
4
|
+
# How much the command downloads, extracts, and installs at most. Each
|
|
5
|
+
# limit can be raised with an environment variable, for sources you trust.
|
|
6
|
+
#
|
|
7
|
+
# GitHub serves a whole commit as one archive and skills are found in it,
|
|
8
|
+
# so the download and extraction limits fit popular repositories, whose
|
|
9
|
+
# tests and recordings run to hundreds of megabytes. Both stream to disk,
|
|
10
|
+
# never into memory. The per-skill limits stay tight, since only the
|
|
11
|
+
# chosen skills' folders are installed.
|
|
12
|
+
class Limits
|
|
13
|
+
DEFAULTS = {
|
|
14
|
+
download_bytes: ['SKILL_MAX_DOWNLOAD_BYTES', 512 * 1024 * 1024],
|
|
15
|
+
extract_bytes: ['SKILL_MAX_EXTRACT_BYTES', 2 * 1024 * 1024 * 1024],
|
|
16
|
+
extract_files: ['SKILL_MAX_EXTRACT_FILES', 100_000],
|
|
17
|
+
skill_bytes: ['SKILL_MAX_SKILL_BYTES', 16 * 1024 * 1024],
|
|
18
|
+
skill_files: ['SKILL_MAX_SKILL_FILES', 2_000]
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
attr_reader(*DEFAULTS.keys)
|
|
22
|
+
|
|
23
|
+
# Returns +message+ followed by how to raise the +key+ limit, so a refusal
|
|
24
|
+
# says what to do about it.
|
|
25
|
+
def self.exceeded(key, message)
|
|
26
|
+
"#{message}. Set #{DEFAULTS.fetch(key).first} higher for a source you trust"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(env = ENV, **overrides)
|
|
30
|
+
DEFAULTS.each do |key, (variable, default)|
|
|
31
|
+
value = overrides.fetch(key) { env[variable] ? Integer(env[variable], 10) : default }
|
|
32
|
+
raise UsageError, "#{variable} must be a positive number" unless value.positive?
|
|
33
|
+
|
|
34
|
+
instance_variable_set(:"@#{key}", value)
|
|
35
|
+
end
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
raise UsageError, 'SKILL_MAX_* variables must be whole numbers of bytes or files'
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Skill
|
|
7
|
+
# +skills.lock+: what each installed skill came from, so +update+ can
|
|
8
|
+
# fetch it again and +remove+ knows what it owns. Entries are sorted by
|
|
9
|
+
# name and carry no timestamps, so two branches adding different skills
|
|
10
|
+
# merge cleanly. A project's lockfile sits at its root, to be committed;
|
|
11
|
+
# the global one in +$XDG_STATE_HOME/skill+ (+~/.local/state/skill+).
|
|
12
|
+
#
|
|
13
|
+
# {
|
|
14
|
+
# "version": 1,
|
|
15
|
+
# "skills": {
|
|
16
|
+
# "pdf": {
|
|
17
|
+
# "source": "anthropics/skills",
|
|
18
|
+
# "ref": null,
|
|
19
|
+
# "ref_type": "branch",
|
|
20
|
+
# "commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
|
|
21
|
+
# "path": "skills/pdf",
|
|
22
|
+
# "plugin": "document-skills",
|
|
23
|
+
# "digest": "sha256-...",
|
|
24
|
+
# "executable": false,
|
|
25
|
+
# "targets": [".claude/skills", "app/skills"]
|
|
26
|
+
# }
|
|
27
|
+
# }
|
|
28
|
+
# }
|
|
29
|
+
class Lockfile
|
|
30
|
+
NAME = 'skills.lock'
|
|
31
|
+
VERSION = 1
|
|
32
|
+
KEYS = %w[source ref ref_type commit path plugin digest executable targets].freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :path, :skills
|
|
35
|
+
|
|
36
|
+
# Returns the global lockfile's path.
|
|
37
|
+
def self.global_path(env:, home:)
|
|
38
|
+
state = env['XDG_STATE_HOME'].to_s.strip
|
|
39
|
+
File.join(state.empty? ? File.join(home, '.local/state') : state, 'skill', NAME)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(path)
|
|
43
|
+
@path = path
|
|
44
|
+
@skills = File.exist?(path) ? parse(File.read(path, encoding: 'UTF-8')) : {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def [](name)
|
|
48
|
+
skills[name]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def include?(name)
|
|
52
|
+
skills.key?(name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def names
|
|
56
|
+
skills.keys
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Records +entry+, a Hash with the keys of KEYS, for the skill +name+.
|
|
60
|
+
def []=(name, entry)
|
|
61
|
+
clean = entry.transform_keys(&:to_s).slice(*KEYS)
|
|
62
|
+
clean['targets'] = Array(clean['targets']).uniq.sort
|
|
63
|
+
clean.delete('plugin') if clean['plugin'].nil?
|
|
64
|
+
skills[name] = clean
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def delete(name)
|
|
68
|
+
skills.delete(name)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Writes the lockfile, or deletes it when it records nothing.
|
|
72
|
+
def save
|
|
73
|
+
if skills.empty?
|
|
74
|
+
FileUtils.rm_f(path)
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
79
|
+
sorted = skills.sort.to_h { |name, entry| [name, ordered(entry)] }
|
|
80
|
+
temporary = "#{path}.#{Process.pid}.tmp"
|
|
81
|
+
File.write(temporary, "#{JSON.pretty_generate('version' => VERSION, 'skills' => sorted)}\n")
|
|
82
|
+
File.rename(temporary, path)
|
|
83
|
+
ensure
|
|
84
|
+
FileUtils.rm_f(temporary) if temporary
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def ordered(entry)
|
|
90
|
+
KEYS.select { |key| entry.key?(key) }.to_h { |key| [key, entry[key]] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse(text)
|
|
94
|
+
data = JSON.parse(text)
|
|
95
|
+
unless data.is_a?(Hash) && data['skills'].is_a?(Hash)
|
|
96
|
+
raise Error, "#{path} is not a skills lockfile. Fix or delete it"
|
|
97
|
+
end
|
|
98
|
+
raise Error, "#{path} was written by a newer skill. Run `gem update skill`" if data['version'].to_i > VERSION
|
|
99
|
+
|
|
100
|
+
data['skills'].each do |name, entry|
|
|
101
|
+
raise Error, "#{path} records an invalid skill name, #{name.inspect}" unless Manifest.valid_name?(name)
|
|
102
|
+
unless entry.is_a?(Hash) && entry['source'].is_a?(String)
|
|
103
|
+
raise Error,
|
|
104
|
+
"#{path} records #{name} without a source"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
data['skills']
|
|
108
|
+
rescue JSON::ParserError
|
|
109
|
+
raise Error, "#{path} is not valid JSON. Resolve any merge conflict in it, or delete it and add the skills again"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module Skill
|
|
7
|
+
# A skill's +SKILL.md+, checked against the Agent Skills specification
|
|
8
|
+
# (https://agentskills.io/specification) the way RubyLLM checks it: YAML
|
|
9
|
+
# front matter with a +name+ of lowercase letters, digits, and single
|
|
10
|
+
# hyphens, at most 64 characters, and a +description+ of at most 1,024.
|
|
11
|
+
class Manifest
|
|
12
|
+
FILE = 'SKILL.md'
|
|
13
|
+
MAX_NAME = 64
|
|
14
|
+
MAX_DESCRIPTION = 1024
|
|
15
|
+
MAX_COMPATIBILITY = 500
|
|
16
|
+
MAX_BYTES = 1024 * 1024
|
|
17
|
+
FRONT_MATTER = /\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\z)/m
|
|
18
|
+
NAME = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
|
|
19
|
+
|
|
20
|
+
attr_reader :name, :description, :metadata, :path
|
|
21
|
+
|
|
22
|
+
# Reads the +SKILL.md+ in the folder at +path+. Raises InvalidSkillError
|
|
23
|
+
# when it is missing or breaks the specification.
|
|
24
|
+
def self.load(path)
|
|
25
|
+
new(path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns whether +name+ is a valid skill name.
|
|
29
|
+
def self.valid_name?(name)
|
|
30
|
+
name.is_a?(String) && name.length <= MAX_NAME && name.match?(NAME)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(path)
|
|
34
|
+
@path = path.to_s
|
|
35
|
+
file = File.join(@path, FILE)
|
|
36
|
+
invalid('is missing') unless File.file?(file) && !File.symlink?(file)
|
|
37
|
+
invalid("is larger than #{MAX_BYTES} bytes") if File.size(file) > MAX_BYTES
|
|
38
|
+
|
|
39
|
+
assign(front_matter(File.read(file, encoding: 'UTF-8')))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns whether the front matter marks the skill internal, which hides
|
|
43
|
+
# it unless it is asked for by name.
|
|
44
|
+
def internal?
|
|
45
|
+
%w[true 1 yes].include?(metadata['internal'].to_s.downcase)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def front_matter(content)
|
|
51
|
+
content = content.delete_prefix("\u{FEFF}")
|
|
52
|
+
invalid('is not valid UTF-8') unless content.valid_encoding?
|
|
53
|
+
match = FRONT_MATTER.match(content)
|
|
54
|
+
invalid('must start with YAML front matter between --- lines') unless match
|
|
55
|
+
|
|
56
|
+
data = YAML.safe_load(match[1], permitted_classes: [Date, Time], aliases: false)
|
|
57
|
+
invalid('front matter must be a YAML mapping') unless data.is_a?(Hash)
|
|
58
|
+
data.transform_keys(&:to_s)
|
|
59
|
+
rescue Psych::Exception => e
|
|
60
|
+
invalid("front matter is not valid YAML: #{e.message.lines.first.strip}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def assign(data)
|
|
64
|
+
@name = required(data, 'name', MAX_NAME)
|
|
65
|
+
unless @name.match?(NAME)
|
|
66
|
+
invalid("name #{@name.inspect} must be lowercase letters, digits, and single hyphens between them")
|
|
67
|
+
end
|
|
68
|
+
@description = required(data, 'description', MAX_DESCRIPTION)
|
|
69
|
+
optional(data, 'license')
|
|
70
|
+
optional(data, 'compatibility', MAX_COMPATIBILITY)
|
|
71
|
+
optional(data, 'allowed-tools')
|
|
72
|
+
@metadata = metadata_of(data['metadata'])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def required(data, key, limit)
|
|
76
|
+
value = data[key]
|
|
77
|
+
invalid("needs a #{key}") if value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
78
|
+
|
|
79
|
+
optional(data, key, limit)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def optional(data, key, limit = nil)
|
|
83
|
+
value = data[key]
|
|
84
|
+
return if value.nil?
|
|
85
|
+
|
|
86
|
+
invalid("#{key} must be a string") unless value.is_a?(String)
|
|
87
|
+
invalid("#{key} must not be empty") if value.strip.empty?
|
|
88
|
+
invalid("#{key} must be at most #{limit} characters") if limit && value.length > limit
|
|
89
|
+
value.strip
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def metadata_of(value)
|
|
93
|
+
return {}.freeze if value.nil?
|
|
94
|
+
|
|
95
|
+
invalid('metadata must be a mapping') unless value.is_a?(Hash)
|
|
96
|
+
value.to_h { |key, entry| [key.to_s, entry.to_s] }.freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def invalid(reason)
|
|
100
|
+
raise InvalidSkillError, "#{File.join(@path, FILE)} #{reason}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skill
|
|
4
|
+
# What one run of the command shares: its streams, environment, folders,
|
|
5
|
+
# and the downloads it made, which #cleanup deletes.
|
|
6
|
+
class Session
|
|
7
|
+
attr_reader :env, :cwd, :home
|
|
8
|
+
|
|
9
|
+
def initialize(out:, err:, input:, env:, cwd:, home:, http: nil)
|
|
10
|
+
@out = out
|
|
11
|
+
@err = err
|
|
12
|
+
@input = input
|
|
13
|
+
@env = env
|
|
14
|
+
@cwd = File.expand_path(cwd)
|
|
15
|
+
@home = File.expand_path(home)
|
|
16
|
+
@http = http
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def say(text = '')
|
|
20
|
+
@out.puts text
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Prints a warning on the error stream: "! " and +text+, yellow on a
|
|
24
|
+
# terminal.
|
|
25
|
+
def warn(text)
|
|
26
|
+
@err.puts err_style.yellow("! #{text}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def error(text)
|
|
30
|
+
@err.puts "#{err_style.red('skill:')} #{text}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the Style for standard output.
|
|
34
|
+
def style
|
|
35
|
+
@style ||= Style.for(@out, env)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the Style for the error stream.
|
|
39
|
+
def err_style
|
|
40
|
+
@err_style ||= Style.for(@err, env)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Asks +question+ on a terminal. Returns +false+ without asking when
|
|
44
|
+
# input is not a terminal.
|
|
45
|
+
def confirm?(question)
|
|
46
|
+
return false unless @input.respond_to?(:tty?) && @input.tty?
|
|
47
|
+
|
|
48
|
+
@out.print "#{question} [y/N] "
|
|
49
|
+
@out.flush
|
|
50
|
+
@input.gets.to_s.strip.match?(/\Ay(?:es)?\z/i)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns the Workspace for the project, or the home folder with
|
|
54
|
+
# +global+, or with +global+ +nil+ whichever +cwd+ is in.
|
|
55
|
+
def workspace(global: nil)
|
|
56
|
+
@workspace ||= Workspace.open(cwd: cwd, home: home, env: env, global: global, limits: limits)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def limits
|
|
60
|
+
@limits ||= Limits.new(env)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def http
|
|
64
|
+
@http ||= HTTP.new
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fetcher
|
|
68
|
+
@fetcher ||= begin
|
|
69
|
+
require 'tmpdir'
|
|
70
|
+
@workdir = Dir.mktmpdir('skill-')
|
|
71
|
+
Fetcher.new(github: GitHub.new(http: http, env: env, limits: limits), limits: limits, workdir: @workdir)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns whether internal skills may be installed without naming them.
|
|
76
|
+
def internal_skills?
|
|
77
|
+
%w[1 true].include?(env['INSTALL_INTERNAL_SKILLS'].to_s.downcase)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Deletes everything downloaded during the run.
|
|
81
|
+
def cleanup
|
|
82
|
+
return unless @workdir
|
|
83
|
+
|
|
84
|
+
require 'fileutils'
|
|
85
|
+
FileUtils.rm_rf(@workdir)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/skill/source.rb
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skill
|
|
4
|
+
# Where skills come from, parsed from what follows +skill add+:
|
|
5
|
+
#
|
|
6
|
+
# owner/repo every skill in the repository
|
|
7
|
+
# owner/repo@pdf one skill, by name
|
|
8
|
+
# owner/repo/skills/pdf one folder of the repository
|
|
9
|
+
# owner/repo#v1.2.0 a branch, tag, or commit
|
|
10
|
+
# owner/repo#v1.2.0@pdf both
|
|
11
|
+
# https://github.com/owner/repo/tree/main/skills/pdf
|
|
12
|
+
# ./path/to/skills a local directory
|
|
13
|
+
class Source
|
|
14
|
+
OWNER = /\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\z/
|
|
15
|
+
REPO = /\A[A-Za-z0-9._-]{1,100}\z/
|
|
16
|
+
REF = %r{\A(?!-)(?!.*\.\.)(?!.*//)[A-Za-z0-9._/+-]{1,250}(?<![./])\z}
|
|
17
|
+
GITHUB_URL = %r{\Ahttps?://(?:www\.)?github\.com/([^/]+)/([^/#?]+?)(?:\.git)?(?:/tree/([^/]+)(?:/(.+?))?)?/?\z}
|
|
18
|
+
|
|
19
|
+
attr_reader :kind, :owner, :repo, :ref, :subpath, :skills, :path
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Returns the Source +input+ names, relative paths read from +root+.
|
|
23
|
+
def parse(input, root: Dir.pwd, ref: nil)
|
|
24
|
+
text = input.to_s.strip
|
|
25
|
+
raise UsageError, 'Name a source, such as owner/repo or ./path' if text.empty?
|
|
26
|
+
return local(text, root) if local?(text)
|
|
27
|
+
|
|
28
|
+
text = text.delete_prefix('github:')
|
|
29
|
+
text, fragment = text.split('#', 2)
|
|
30
|
+
fragment_ref, fragment_skill = fragment.to_s.split('@', 2)
|
|
31
|
+
github(text, ref: ref || presence(fragment_ref), skill: presence(fragment_skill))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns whether +text+ reads as a local path.
|
|
35
|
+
def local?(text)
|
|
36
|
+
text.start_with?('./', '../', '/', '~/', '.\\', '..\\') || %w[. .. ~].include?(text) ||
|
|
37
|
+
text.match?(%r{\A[A-Za-z]:[\\/]})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns a relative path inside a repository, without empty or +.+
|
|
41
|
+
# segments. Raises UnsafeError for absolute paths and +..+.
|
|
42
|
+
def clean_path(path)
|
|
43
|
+
text = path.to_s.tr('\\', '/')
|
|
44
|
+
if text.start_with?('/') || text.match?(/\A[A-Za-z]:/)
|
|
45
|
+
raise UnsafeError,
|
|
46
|
+
"#{path.inspect} is not a relative path"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
parts = text.split('/').reject { |part| part.empty? || part == '.' }
|
|
50
|
+
raise UnsafeError, "#{path.inspect} leaves its folder" if parts.include?('..') || text.include?("\0")
|
|
51
|
+
|
|
52
|
+
parts.join('/')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def presence(value)
|
|
58
|
+
value unless value.nil? || value.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def local(text, root)
|
|
62
|
+
expanded = File.expand_path(text, root)
|
|
63
|
+
new(kind: :local, path: expanded)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def github(text, ref:, skill:)
|
|
67
|
+
if (match = GITHUB_URL.match(text))
|
|
68
|
+
owner, repo, tree_ref, subpath = match.captures
|
|
69
|
+
return build(owner, repo, ref: ref || tree_ref, subpath: subpath, skill: skill)
|
|
70
|
+
end
|
|
71
|
+
raise UsageError, "#{text.inspect} is not a GitHub repository like owner/repo" if text.include?(':')
|
|
72
|
+
|
|
73
|
+
shorthand(text, ref: ref, skill: skill)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def shorthand(text, ref:, skill:)
|
|
77
|
+
name, at_skill = text.split('@', 2)
|
|
78
|
+
owner, repo, *rest = name.split('/')
|
|
79
|
+
raise UsageError, "#{text.inspect} is not a GitHub repository like owner/repo" unless owner && repo
|
|
80
|
+
|
|
81
|
+
build(owner, repo, ref: ref, subpath: rest.join('/'), skill: skill || presence(at_skill))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build(owner, repo, ref:, subpath:, skill:)
|
|
85
|
+
unless owner.match?(OWNER) && repo.match?(REPO) && !%w[. ..].include?(repo)
|
|
86
|
+
raise UsageError, "#{owner}/#{repo} is not a GitHub repository name"
|
|
87
|
+
end
|
|
88
|
+
raise UsageError, "#{ref.inspect} is not a valid git ref" if ref && !ref.match?(REF)
|
|
89
|
+
|
|
90
|
+
skills = skill.to_s.split(',').map(&:strip).reject(&:empty?)
|
|
91
|
+
new(kind: :github, owner: owner, repo: repo.delete_suffix('.git'), ref: ref,
|
|
92
|
+
subpath: clean_path(subpath.to_s), skills: skills)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initialize(kind:, owner: nil, repo: nil, ref: nil, subpath: '', skills: [], path: nil)
|
|
97
|
+
@kind = kind
|
|
98
|
+
@owner = owner
|
|
99
|
+
@repo = repo
|
|
100
|
+
@ref = ref
|
|
101
|
+
@subpath = subpath.to_s
|
|
102
|
+
@skills = skills.freeze
|
|
103
|
+
@path = path
|
|
104
|
+
freeze
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def github?
|
|
108
|
+
kind == :github
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def local?
|
|
112
|
+
kind == :local
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns the repository as +owner/repo+, or the local path.
|
|
116
|
+
def name
|
|
117
|
+
github? ? "#{owner}/#{repo}" : path
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Returns the repository's web address, or +nil+ for a local directory.
|
|
121
|
+
def url
|
|
122
|
+
"https://github.com/#{owner}/#{repo}" if github?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Returns a copy with another ref.
|
|
126
|
+
def with_ref(ref)
|
|
127
|
+
self.class.new(kind: kind, owner: owner, repo: repo, ref: ref, subpath: subpath, skills: skills, path: path)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def to_s
|
|
131
|
+
text = name
|
|
132
|
+
text += "/#{subpath}" if github? && !subpath.empty?
|
|
133
|
+
text += "##{ref}" if ref
|
|
134
|
+
text
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/skill/style.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skill
|
|
4
|
+
# Colors text for a terminal. Off unless the stream is a terminal, and off
|
|
5
|
+
# when +NO_COLOR+ is set or +TERM+ is +dumb+, so piped output stays plain.
|
|
6
|
+
class Style
|
|
7
|
+
CODES = { bold: [1, 22], dim: [2, 22], red: [31, 39], green: [32, 39], yellow: [33, 39] }.freeze
|
|
8
|
+
|
|
9
|
+
# Returns a Style for +stream+, colored when it is a terminal and
|
|
10
|
+
# +env+ allows it.
|
|
11
|
+
def self.for(stream, env)
|
|
12
|
+
terminal = stream.respond_to?(:tty?) && stream.tty?
|
|
13
|
+
new(terminal && env['NO_COLOR'].to_s.empty? && env['TERM'] != 'dumb')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(color)
|
|
17
|
+
@color = color
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def color?
|
|
21
|
+
@color
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
CODES.each do |name, (on, off)|
|
|
25
|
+
define_method(name) { |text| @color ? "\e[#{on}m#{text}\e[#{off}m" : text.to_s }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|