Class: Rubino::Skills::Installer

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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_dirObject (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

#sourcesObject

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