Class: Rubino::Skills::Installer
- Inherits:
-
Object
- Object
- Rubino::Skills::Installer
- Defined in:
- lib/rubino/skills/installer.rb
Overview
Installs skills from git repositories into the user skills dir (#4) —the ‘rubino skills install/update/remove` backend. There is no marketplace and nothing is vendored in the gem: a source is just a repo (GitHub `owner/repo` shorthand or any git URL), shallow-cloned to a tmpdir, scanned for the registry’s own ‘<name>/SKILL.md` layout, and the selected skill dirs are copied into `~/.rubino/skills` where the existing Registry discovers them like any hand-written skill.
Provenance is recorded per installed skill in ‘<skills-dir>/.sources.json` (`name → path, commit`) so `update` can re-fetch from the recorded source and `remove` knows which dirs this mechanism owns. The dotfile name keeps it out of the registry’s ‘*.md` / `*/SKILL.md` globs.
Constant Summary collapse
- SOURCES_FILE =
".sources.json"- GITHUB_SHORTHAND =
GitHub shorthand: bare ‘owner/repo` (one slash, no scheme/host).
%r{\A[\w.-]+/[\w.-]+\z}- NAME_ALLOWLIST =
The only shape a skill ‘name:` may take before it becomes a directory under the skills root: lowercase alphanumerics in hyphen-separated segments (Claude Code’s skill-name grammar). This is the CWE-22 allowlist defense — same class as the Zed CVE-2026-27800 / Anthropic EscapeRoute CVE-2025-53110 traversal bugs: it admits no path separator, no ‘..`, no dot, no NUL, no leading/trailing hyphen, no absolute path, nothing but `[a-z0-9-]`.
/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/- NAME_MAX_LEN =
64
Instance Attribute Summary collapse
-
#skills_dir ⇒ Object
readonly
Returns the value of attribute skills_dir.
Class Method Summary collapse
-
.url_for(source) ⇒ Object
‘owner/repo` → the GitHub URL; anything else is passed to git verbatim.
Instance Method Summary collapse
-
#discover(checkout) ⇒ Object
Skills discoverable in a checkout, as ‘path:, description:` hashes (path = skill dir relative to the repo root).
-
#fetch(source) ⇒ Object
Shallow-clones
sourceand yields (checkout_dir, head_sha); the tmp checkout is deleted when the block returns. -
#initialize(skills_dir: nil) ⇒ Installer
constructor
A new instance of Installer.
-
#install(entries, checkout:, source:, commit:) ⇒ Object
Copies the discover-entries into the skills dir (replacing any prior copy of the same name) and records their provenance.
-
#remove(name) ⇒ Object
Deletes the skill dir + provenance entry.
-
#sources ⇒ Object
The provenance ledger (empty hash when absent or unparseable).
-
#update(names = []) ⇒ Object
Re-fetches
names(default: every recorded skill) from their recorded sources, one clone per distinct source.
Constructor Details
#initialize(skills_dir: nil) ⇒ Installer
Returns a new instance of Installer.
38 39 40 41 42 43 |
# File 'lib/rubino/skills/installer.rb', line 38 def initialize(skills_dir: nil) # The same resolved home the registry's "~/.rubino/skills" entry # expands to (RUBINO_HOME → else ~/.rubino), so an install is # discovered without any config change. @skills_dir = skills_dir || File.join(Config::Loader.default_home_path, "skills") end |
Instance Attribute Details
#skills_dir ⇒ Object (readonly)
Returns the value of attribute skills_dir.
36 37 38 |
# File 'lib/rubino/skills/installer.rb', line 36 def skills_dir @skills_dir end |
Class Method Details
.url_for(source) ⇒ Object
‘owner/repo` → the GitHub URL; anything else is passed to git verbatim.
46 47 48 |
# File 'lib/rubino/skills/installer.rb', line 46 def self.url_for(source) GITHUB_SHORTHAND.match?(source.to_s) ? "https://github.com/#{source}" : source.to_s end |
Instance Method Details
#discover(checkout) ⇒ Object
Skills discoverable in a checkout, as ‘path:, description:` hashes (path = skill dir relative to the repo root). Recursive (`**/` + the registry’s DIR_GLOB) so catalog repos that nest skills under a grouping dir are found too.
69 70 71 72 73 74 75 |
# File 'lib/rubino/skills/installer.rb', line 69 def discover(checkout) Dir.glob(File.join("**", Registry::DIR_GLOB), base: checkout).sort.map do |rel| dir = File.dirname(rel) skill = Skill.new(path: File.join(checkout, rel)) { name: skill.name, path: dir, description: skill.description.to_s } end end |
#fetch(source) ⇒ Object
Shallow-clones source and yields (checkout_dir, head_sha); the tmp checkout is deleted when the block returns. Returns the block’s value, or nil when the clone fails (unknown repo, no network — git’s own stderr is left visible as the diagnostic). The ONE network touchpoint, so specs stub this method and never shell out.
55 56 57 58 59 60 61 62 63 |
# File 'lib/rubino/skills/installer.rb', line 55 def fetch(source) Dir.mktmpdir("rubino-skills") do |dir| return nil unless system("git", "clone", "--depth", "1", "--quiet", self.class.url_for(source), dir, out: File::NULL) sha = IO.popen(["git", "-C", dir, "rev-parse", "HEAD"], &:read).strip yield dir, sha end end |
#install(entries, checkout:, source:, commit:) ⇒ Object
Copies the discover-entries into the skills dir (replacing any prior copy of the same name) and records their provenance. Entries whose name isn’t a safe single path segment (a hostile repo can put ‘name: ../../EVIL` in its frontmatter) are skipped — never written, never recorded — so an install can’t write or delete anything outside the skills dir (SKILL-1).
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/rubino/skills/installer.rb', line 83 def install(entries, checkout:, source:, commit:) FileUtils.mkdir_p(@skills_dir) # Read-modify-write the ledger under an exclusive lock so N parallel # installs don't lose updates (each reading the same base and the last # writer clobbering the rest → orphaned, unremovable skills). The file # copies stay inside the locked region: each install owns a distinct # name (its own dest dir), so they don't collide, and keeping them under # the lock means the ledger and the on-disk dirs can't diverge. update_sources do |data| entries.each do |entry| name = entry[:name] dest = safe_dest(name) or next FileUtils.rm_rf(dest) FileUtils.cp_r(File.join(checkout, entry[:path]), dest) data[name] = { "source" => source, "path" => entry[:path], "commit" => commit } end end end |
#remove(name) ⇒ Object
Deletes the skill dir + provenance entry. Returns false (nothing touched) for a skill without a provenance entry — this mechanism only removes what it installed.
131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/rubino/skills/installer.rb', line 131 def remove(name) removed = false update_sources do |data| next unless data.key?(name) # Confine the delete to the skills dir even if a pre-fix ledger recorded # a traversal key (defense in depth — install now refuses such names). dest = safe_dest(name) FileUtils.rm_rf(dest) if dest data.delete(name) removed = true end removed end |
#sources ⇒ Object
The provenance ledger (empty hash when absent or unparseable). Reads under a shared lock so it can’t observe a writer’s intermediate state.
148 149 150 151 152 153 |
# File 'lib/rubino/skills/installer.rb', line 148 def sources raw = Util::AtomicFile.read_shared(sources_path) raw ? JSON.parse(raw) : {} rescue JSON::ParserError {} end |
#update(names = []) ⇒ Object
Re-fetches names (default: every recorded skill) from their recorded sources, one clone per distinct source. Returns name → :updated / :up_to_date / :failed (clone failed, or the skill’s recorded path no longer holds a SKILL.md) / :unknown (no provenance entry).
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/rubino/skills/installer.rb', line 107 def update(names = []) results = {} # One locked read-modify-write for the whole update so it can't race a # concurrent install/remove/update (lost-update → orphaned entries). The # network clones run inside the lock; updates are infrequent and this # keeps the ledger consistent with what was re-fetched. update_sources do |data| names = data.keys if names.empty? names.group_by { |name| data.dig(name, "source") }.each do |source, group| next group.each { |name| results[name] = :unknown } if source.nil? fetched = fetch(source) do |checkout, sha| group.each { |name| results[name] = update_one(name, data[name], checkout, sha) } true end group.each { |name| results[name] = :failed } unless fetched end end results end |