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