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.
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skill
4
+ # Decides which folders a command installs to, lists, or updates: the
5
+ # folders of the agents named with +--agent+, the folders named with
6
+ # +--dir+, or else the ones the project or home folder shows signs of.
7
+ class Targets
8
+ # One folder skills are installed into, and the agents that read it.
9
+ Target = Struct.new(:path, :label, :agents, keyword_init: true)
10
+
11
+ # Files and folders in a project that show it uses an agent, beyond the
12
+ # agent's own skills folder.
13
+ PROJECT_SIGNS = {
14
+ 'claude-code' => %w[.claude CLAUDE.md],
15
+ 'codex' => %w[.codex AGENTS.md],
16
+ 'cursor' => %w[.cursor .cursorrules],
17
+ 'gemini-cli' => %w[.gemini GEMINI.md],
18
+ 'github-copilot' => %w[.github/copilot-instructions.md],
19
+ 'windsurf' => %w[.windsurf .windsurfrules]
20
+ }.freeze
21
+
22
+ # Folders in the home folder that show an agent is installed, for agents
23
+ # whose global skills folder is shared with others.
24
+ GLOBAL_SIGNS = {
25
+ 'codex' => ['CODEX_HOME', '~/.codex'],
26
+ 'cline' => [nil, '~/.cline'],
27
+ 'zed' => [nil, '~/.config/zed'],
28
+ 'warp' => [nil, '~/.warp']
29
+ }.freeze
30
+
31
+ SHARED_GLOBAL_DIRS = %w[~/.agents/skills {config}/agents/skills].freeze
32
+ FALLBACK = 'universal'
33
+
34
+ attr_reader :root, :home, :env
35
+
36
+ # +root+ is the project folder, ignored with +global+.
37
+ def initialize(root:, home:, env:, global: false)
38
+ @root = File.expand_path(root)
39
+ @home = File.expand_path(home)
40
+ @env = env
41
+ @global = global
42
+ end
43
+
44
+ def global?
45
+ @global
46
+ end
47
+
48
+ # Returns the Targets for +agents+ and +dirs+, or the detected ones when
49
+ # both are empty. Folders several agents share are returned once.
50
+ def resolve(agents: [], dirs: [])
51
+ chosen = agents_named(agents)
52
+ list = chosen.empty? && dirs.empty? ? detected : chosen.filter_map { |agent| target_for(agent, strict: true) }
53
+ list += dirs.map { |dir| Target.new(path: File.expand_path(dir, base), label: dir, agents: []) }
54
+ merge(list)
55
+ end
56
+
57
+ # Returns the Targets of every agent whose folder exists, for listing.
58
+ def existing
59
+ merge(Agents.all.filter_map { |agent| target_for(agent) }.select { |target| File.directory?(target.path) })
60
+ end
61
+
62
+ # Returns the agents the project or home folder shows signs of, or the
63
+ # shared +.agents/skills+ folder when it shows none.
64
+ def detected_agents
65
+ found = Agents.all.select { |agent| global? ? installed?(agent) : used_in_project?(agent) }
66
+ found.empty? ? [Agents.fetch(FALLBACK)] : found
67
+ end
68
+
69
+ # Returns +path+ as the lockfile records it: relative to the project, or
70
+ # starting with +~+ in the home folder.
71
+ def display(path)
72
+ expanded = File.expand_path(path)
73
+ if !global? && inside?(expanded, root)
74
+ expanded == root ? '.' : expanded.delete_prefix("#{root}/")
75
+ elsif inside?(expanded, home)
76
+ expanded == home ? '~' : "~/#{expanded.delete_prefix("#{home}/")}"
77
+ else
78
+ expanded
79
+ end
80
+ end
81
+
82
+ # Returns the absolute path of a folder the lockfile recorded.
83
+ def expand(path)
84
+ File.expand_path(path.to_s.sub(%r{\A~(?=/|\z)}, home), base)
85
+ end
86
+
87
+ # Returns the Boundary skills install inside: the project, or with
88
+ # +global+ the home folder and any global agent folder the environment
89
+ # moved elsewhere.
90
+ def boundary
91
+ @boundary ||= if global?
92
+ folders = Agents.all.filter_map { |agent| Agents.global_path(agent, env: env, home: home) }
93
+ Boundary.new([home, *folders], name: 'your home folder')
94
+ else
95
+ Boundary.new([root], name: 'the project')
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def base
102
+ global? ? home : root
103
+ end
104
+
105
+ def agents_named(names)
106
+ return Agents.all.select { |agent| global? ? agent.global? : true } if names.include?('*')
107
+
108
+ names.map { |name| Agents.fetch(name) }.uniq
109
+ end
110
+
111
+ def detected
112
+ detected_agents.filter_map { |agent| target_for(agent) }
113
+ end
114
+
115
+ def target_for(agent, strict: false)
116
+ path = global? ? Agents.global_path(agent, env: env, home: home) : File.join(root, agent.project_dir)
117
+ if path.nil?
118
+ raise UsageError, "#{agent.name} has no global skills folder; install it in a project" if strict
119
+
120
+ return
121
+ end
122
+ Target.new(path: path, label: display(path), agents: [agent.name])
123
+ end
124
+
125
+ def merge(targets)
126
+ targets.group_by(&:path).map do |path, group|
127
+ Target.new(path: path, label: display(path), agents: group.flat_map(&:agents).uniq)
128
+ end
129
+ end
130
+
131
+ def used_in_project?(agent)
132
+ return ruby_llm? if agent.key == 'rubyllm'
133
+ return File.directory?(File.join(root, agent.project_dir)) if agent.key == FALLBACK
134
+
135
+ (PROJECT_SIGNS.fetch(agent.key, []) + own_signs(agent)).any? { |sign| File.exist?(File.join(root, sign)) }
136
+ end
137
+
138
+ # An agent's own folder shows it is used, unless other tools share it
139
+ # (+.agents+) or it is too common a name to mean anything (+skills+).
140
+ def own_signs(agent)
141
+ dir = agent.project_dir
142
+ return [] if dir.start_with?('.agents/') || !dir.include?('/')
143
+
144
+ dir.start_with?('.') ? [dir.split('/').first] : [dir]
145
+ end
146
+
147
+ def ruby_llm?
148
+ return true if File.directory?(File.join(root, 'app/skills'))
149
+
150
+ %w[Gemfile.lock gems.locked Gemfile gems.rb].any? do |file|
151
+ path = File.join(root, file)
152
+ File.file?(path) && File.read(path, 256 * 1024).match?(/^\s*(?:gem\s+["']ruby_llm["']|ruby_llm \()/)
153
+ end
154
+ end
155
+
156
+ def installed?(agent)
157
+ return false unless agent.global?
158
+
159
+ if GLOBAL_SIGNS.key?(agent.key)
160
+ variable, default = GLOBAL_SIGNS[agent.key]
161
+ dir = variable && !env[variable].to_s.empty? ? env[variable] : default
162
+ return File.directory?(expand(dir))
163
+ end
164
+ return false if SHARED_GLOBAL_DIRS.include?(agent.global_dir)
165
+
166
+ File.directory?(File.dirname(Agents.global_path(agent, env: env, home: home)))
167
+ end
168
+
169
+ def inside?(path, folder)
170
+ path == folder || path.start_with?("#{folder}/")
171
+ end
172
+ end
173
+ end
data/lib/skill/tree.rb ADDED
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'find'
5
+
6
+ module Skill
7
+ # The files of one skill folder: their paths, contents' SHA-256, and
8
+ # whether they are executable. Comparing two Trees shows what an update
9
+ # changes; a Tree's digest shows whether an installed skill was edited.
10
+ class Tree
11
+ Entry = Struct.new(:sha256, :bytes, :executable, keyword_init: true)
12
+ Diff = Struct.new(:added, :changed, :removed, keyword_init: true) do
13
+ def empty?
14
+ added.empty? && changed.empty? && removed.empty?
15
+ end
16
+ end
17
+
18
+ attr_reader :root, :entries
19
+
20
+ # Reads the folder at +root+. Raises UnsafeError when it holds a link,
21
+ # anything other than files and folders, or more than +limits+ allow.
22
+ def self.read(root, limits: Limits.new)
23
+ new(root, scan(root.to_s, limits))
24
+ end
25
+
26
+ def self.scan(root, limits)
27
+ raise UnsafeError, "#{root} is a link" if File.symlink?(root)
28
+
29
+ entries = {}
30
+ total = 0
31
+ Find.find(root) do |path|
32
+ next if path == root
33
+
34
+ relative = path.delete_prefix("#{root}/")
35
+ stat = File.lstat(path)
36
+ raise UnsafeError, "#{relative} in #{File.basename(root)} is a link; skills may not hold links" if stat.symlink?
37
+ next if stat.directory?
38
+ raise UnsafeError, "#{relative} in #{File.basename(root)} is not a regular file" unless stat.file?
39
+
40
+ total += stat.size
41
+ too_large!(root, limits) if entries.size >= limits.skill_files || total > limits.skill_bytes
42
+
43
+ entries[relative] = Entry.new(sha256: Digest::SHA256.file(path).hexdigest, bytes: stat.size,
44
+ executable: stat.mode.anybits?(0o111)).freeze
45
+ end
46
+ entries.sort.to_h.freeze
47
+ end
48
+
49
+ def self.too_large!(root, limits)
50
+ raise UnsafeError,
51
+ "#{File.basename(root)} is larger than #{limits.skill_files} files or #{limits.skill_bytes} bytes. " \
52
+ 'Set SKILL_MAX_SKILL_FILES or SKILL_MAX_SKILL_BYTES higher for a source you trust'
53
+ end
54
+ private_class_method :scan, :too_large!
55
+
56
+ def initialize(root, entries)
57
+ @root = root.to_s
58
+ @entries = entries
59
+ end
60
+
61
+ # Returns the digest of the files and their modes, as
62
+ # "sha256-<hex>". +executable+ false ignores modes, for a copy that
63
+ # dropped them.
64
+ def digest(executable: true)
65
+ lines = entries.map { |path, entry| "#{path}\0#{executable && entry.executable ? 'x' : '-'}\0#{entry.sha256}\n" }
66
+ "sha256-#{Digest::SHA256.hexdigest(lines.join)}"
67
+ end
68
+
69
+ # Returns the paths of executable files.
70
+ def executables
71
+ entries.select { |_path, entry| entry.executable }.keys
72
+ end
73
+
74
+ # Returns the size of every file together.
75
+ def bytes
76
+ entries.values.sum(&:bytes)
77
+ end
78
+
79
+ # Returns what changes from +before+ to this Tree. Mode changes count
80
+ # only when +executable+ is true.
81
+ def diff_from(before, executable: true)
82
+ old = before ? before.entries : {}
83
+ changed = entries.select do |path, entry|
84
+ previous = old[path]
85
+ previous && (previous.sha256 != entry.sha256 || (executable && previous.executable != entry.executable))
86
+ end
87
+ Diff.new(added: (entries.keys - old.keys), changed: changed.keys, removed: (old.keys - entries.keys))
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skill
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skill
4
+ # Where a command works: a project (found from the current folder) or the
5
+ # home folder, with its Targets and its Lockfile. Knows whether a folder
6
+ # it would replace or delete is still what it installed.
7
+ class Workspace
8
+ ROOT_MARKERS = %w[skills.lock Gemfile gems.rb .git package.json].freeze
9
+ AGENT_MARKERS = %w[.agents .claude .codex .cursor .gemini .windsurf AGENTS.md CLAUDE.md GEMINI.md].freeze
10
+
11
+ attr_reader :root, :targets, :lock
12
+
13
+ # Opens the project around +cwd+, or the home folder with +global+.
14
+ # With +global+ +nil+, uses the home folder when +cwd+ is in no
15
+ # project, the way a person running it there expects.
16
+ def self.open(cwd:, home:, env:, global:, limits:)
17
+ project = project_root(cwd, home)
18
+ global = project.nil? if global.nil?
19
+ root = global ? home : project || cwd
20
+ lock = global ? Lockfile.global_path(env: env, home: home) : File.join(root, Lockfile::NAME)
21
+ new(root: root, targets: Targets.new(root: root, home: home, env: env, global: global),
22
+ lock: Lockfile.new(lock), limits: limits)
23
+ end
24
+
25
+ # Returns the nearest folder from +cwd+ up holding a lockfile, a
26
+ # Gemfile, a git repository, or a package.json, else the nearest
27
+ # holding an agent's folder or instructions (+.claude+, +AGENTS.md+...),
28
+ # else +nil+. The home folder, where agents keep their global folders,
29
+ # is never a project, and the search stops there.
30
+ def self.project_root(cwd, home)
31
+ agent_folder = nil
32
+ directory = cwd
33
+ until directory == home
34
+ return directory if ROOT_MARKERS.any? { |marker| File.exist?(File.join(directory, marker)) }
35
+
36
+ agent_folder ||= directory if AGENT_MARKERS.any? { |marker| File.exist?(File.join(directory, marker)) }
37
+ parent = File.dirname(directory)
38
+ break if parent == directory
39
+
40
+ directory = parent
41
+ end
42
+ agent_folder
43
+ end
44
+
45
+ # Returns whether this is the home folder rather than a project.
46
+ def global?
47
+ targets.global?
48
+ end
49
+
50
+ def initialize(root:, targets:, lock:, limits:)
51
+ @root = root
52
+ @targets = targets
53
+ @lock = lock
54
+ @limits = limits
55
+ end
56
+
57
+ # Returns +path+ as people read it: relative to the project, or from +~+.
58
+ def display(path)
59
+ targets.display(path)
60
+ end
61
+
62
+ # Returns how the lockfile records +source+: +owner/repo+, or a path
63
+ # starting with +./+, +../+, +~/+, or +/+.
64
+ def source_label(source)
65
+ return source.name if source.github?
66
+
67
+ path = display(source.path)
68
+ path.start_with?('/', '~', '.') ? path : "./#{path}"
69
+ end
70
+
71
+ # Returns the Source a lockfile +entry+ came from.
72
+ def source_of(entry)
73
+ Source.parse(entry['source'], root: root, ref: entry['ref'])
74
+ end
75
+
76
+ # Returns the absolute target folders a lockfile +entry+ records,
77
+ # without checking where they lead.
78
+ def recorded_folders(entry)
79
+ Array(entry['targets']).map { |path| targets.expand(path) }
80
+ end
81
+
82
+ # Returns the absolute target folders a lockfile +entry+ records. Raises
83
+ # UnsafeError when one leads outside the project, or with +global+
84
+ # outside the home folder.
85
+ def folders(entry)
86
+ recorded_folders(entry).each do |folder|
87
+ next if targets.boundary.include?(folder)
88
+
89
+ raise UnsafeError, "#{display(lock.path)} installs into #{display(folder)}, outside " \
90
+ "#{targets.boundary.name}. Fix or delete that entry"
91
+ end
92
+ end
93
+
94
+ # Returns the target folders for +agents+ and +dirs+, or the detected
95
+ # ones. Raises UnsafeError when one leads outside the project, or with
96
+ # +global+ outside the home folder.
97
+ def resolve(agents: [], dirs: [])
98
+ targets.resolve(agents: agents, dirs: dirs).map(&:path).each do |folder|
99
+ next if targets.boundary.include?(folder)
100
+
101
+ raise UnsafeError, "#{display(folder)} leads outside #{targets.boundary.name}. Skills install only inside it"
102
+ end
103
+ end
104
+
105
+ # Returns whether the skill folder at +path+ still holds what +entry+
106
+ # recorded.
107
+ def unchanged?(path, entry)
108
+ return false unless entry && File.directory?(path)
109
+
110
+ Tree.read(path, limits: @limits).digest(executable: entry['executable'] ? true : false) == entry['digest']
111
+ rescue UnsafeError
112
+ false
113
+ end
114
+
115
+ # Raises ConflictError when the skill +name+ in +folder+ was edited
116
+ # since it was installed, unless +force+.
117
+ def ensure_unedited(name, folder, force)
118
+ destination = Installer.destination(folder, name)
119
+ return if !File.exist?(destination) || force || unchanged?(destination, lock[name])
120
+
121
+ raise ConflictError, "#{display(destination)} was edited since it was installed. Pass --force to replace it"
122
+ end
123
+
124
+ # Raises ConflictError when installing +name+ into +folder+ would
125
+ # replace something this command did not install, or edited since.
126
+ def ensure_replaceable(name, folder, force)
127
+ destination = Installer.destination(folder, name)
128
+ return if force || !(File.exist?(destination) || File.symlink?(destination))
129
+
130
+ entry = lock[name]
131
+ unless entry && folders(entry).include?(File.expand_path(folder))
132
+ raise ConflictError, "#{display(destination)} exists and was not installed by skill. Pass --force to replace it"
133
+ end
134
+
135
+ ensure_unedited(name, folder, false)
136
+ end
137
+
138
+ # Returns the state of an installed skill: ok, edited, missing, partly
139
+ # missing, or outside the project (or home folder) when the lockfile
140
+ # points elsewhere.
141
+ def status(name, entry)
142
+ boundary = targets.boundary
143
+ return "outside #{boundary.name}" unless recorded_folders(entry).all? { |folder| boundary.include?(folder) }
144
+
145
+ paths = folders(entry).map { |folder| Installer.destination(folder, name) }
146
+ present = paths.select { |path| File.directory?(path) }
147
+ return 'missing' if present.empty?
148
+ return 'partly missing' if present.size < paths.size
149
+
150
+ paths.all? { |path| unchanged?(path, entry) } ? 'ok' : 'edited'
151
+ end
152
+
153
+ # Returns one line saying where a lockfile +entry+ came from.
154
+ def self.describe(entry)
155
+ text = entry['source'].dup
156
+ text << "##{entry['ref']}" if entry['ref']
157
+ text << " (#{entry['ref_type']} at #{entry['commit'][0, 12]})" if entry['commit']
158
+ text
159
+ end
160
+ end
161
+ end
data/lib/skill.rb ADDED
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'skill/version'
4
+
5
+ # Installs Agent Skills, folders holding a +SKILL.md+, from GitHub
6
+ # repositories, plugin marketplaces, and local directories into the folders
7
+ # RubyLLM and coding agents read them from.
8
+ #
9
+ # The command line is the main interface:
10
+ #
11
+ # gem exec skill add anthropics/skills@pdf
12
+ #
13
+ # Everything loads on first use, so the command starts fast.
14
+ module Skill
15
+ # Raised for every problem the command reports to the person running it.
16
+ class Error < StandardError; end
17
+
18
+ # Raised when a command line is malformed.
19
+ class UsageError < Error; end
20
+
21
+ # Raised when a +SKILL.md+ breaks the Agent Skills specification.
22
+ class InvalidSkillError < Error; end
23
+
24
+ # Raised when a repository, ref, or skill does not exist.
25
+ class NotFoundError < Error; end
26
+
27
+ # Raised when an archive or a skill folder holds something unsafe: a path
28
+ # leaving its folder, a link, or more data than the limits allow.
29
+ class UnsafeError < Error; end
30
+
31
+ # Raised when installing would overwrite something the command does not own.
32
+ class ConflictError < Error; end
33
+
34
+ # Raised when a download fails.
35
+ class NetworkError < Error; end
36
+
37
+ # Raised when a download is larger than its limit.
38
+ class TooLargeError < NetworkError; end
39
+
40
+ autoload :Agents, 'skill/agents'
41
+ autoload :Archive, 'skill/archive'
42
+ autoload :Boundary, 'skill/boundary'
43
+ autoload :CLI, 'skill/cli'
44
+ autoload :Commands, 'skill/commands'
45
+ autoload :Directory, 'skill/directory'
46
+ autoload :Discovery, 'skill/discovery'
47
+ autoload :Fetcher, 'skill/fetcher'
48
+ autoload :GitHub, 'skill/github'
49
+ autoload :HTTP, 'skill/http'
50
+ autoload :Installer, 'skill/installer'
51
+ autoload :Limits, 'skill/limits'
52
+ autoload :Lockfile, 'skill/lockfile'
53
+ autoload :Manifest, 'skill/manifest'
54
+ autoload :Session, 'skill/session'
55
+ autoload :Source, 'skill/source'
56
+ autoload :Style, 'skill/style'
57
+ autoload :Targets, 'skill/targets'
58
+ autoload :Tree, 'skill/tree'
59
+ autoload :Workspace, 'skill/workspace'
60
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: skill
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Carmine Paolino
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Installs Agent Skills, folders holding a SKILL.md, from GitHub repositories,
13
+ plugin marketplaces, and local folders into RubyLLM's app/skills and the folders
14
+ Claude Code, Codex, Cursor, and other agents read. Records each skill's source and
15
+ commit in skills.lock, shows what changes before an update, and refuses unsafe archives.
16
+ Run it with `gem exec skill add owner/repo`.
17
+ email:
18
+ - carmine@paolino.me
19
+ executables:
20
+ - skill
21
+ extensions: []
22
+ extra_rdoc_files: []
23
+ files:
24
+ - CHANGELOG.md
25
+ - LICENSE
26
+ - README.md
27
+ - exe/skill
28
+ - lib/skill.rb
29
+ - lib/skill/agents.rb
30
+ - lib/skill/archive.rb
31
+ - lib/skill/boundary.rb
32
+ - lib/skill/cli.rb
33
+ - lib/skill/commands.rb
34
+ - lib/skill/commands/add.rb
35
+ - lib/skill/commands/agents.rb
36
+ - lib/skill/commands/command.rb
37
+ - lib/skill/commands/find.rb
38
+ - lib/skill/commands/list.rb
39
+ - lib/skill/commands/remove.rb
40
+ - lib/skill/commands/selection.rb
41
+ - lib/skill/commands/update.rb
42
+ - lib/skill/directory.rb
43
+ - lib/skill/discovery.rb
44
+ - lib/skill/fetcher.rb
45
+ - lib/skill/github.rb
46
+ - lib/skill/http.rb
47
+ - lib/skill/installer.rb
48
+ - lib/skill/limits.rb
49
+ - lib/skill/lockfile.rb
50
+ - lib/skill/manifest.rb
51
+ - lib/skill/session.rb
52
+ - lib/skill/source.rb
53
+ - lib/skill/style.rb
54
+ - lib/skill/targets.rb
55
+ - lib/skill/tree.rb
56
+ - lib/skill/version.rb
57
+ - lib/skill/workspace.rb
58
+ homepage: https://github.com/crmne/skill
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ homepage_uri: https://github.com/crmne/skill
63
+ source_code_uri: https://github.com/crmne/skill
64
+ changelog_uri: https://github.com/crmne/skill/blob/main/CHANGELOG.md
65
+ bug_tracker_uri: https://github.com/crmne/skill/issues
66
+ rubygems_mfa_required: 'true'
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '3.1'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 4.0.20
82
+ specification_version: 4
83
+ summary: Install Agent Skills into RubyLLM apps and coding agents.
84
+ test_files: []