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
|
@@ -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,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: []
|