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}

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.



28
29
30
31
32
33
# File 'lib/rubino/skills/installer.rb', line 28

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.



26
27
28
# File 'lib/rubino/skills/installer.rb', line 26

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.



36
37
38
# File 'lib/rubino/skills/installer.rb', line 36

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.



59
60
61
62
63
64
65
# File 'lib/rubino/skills/installer.rb', line 59

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.



45
46
47
48
49
50
51
52
53
# File 'lib/rubino/skills/installer.rb', line 45

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.



69
70
71
72
73
74
75
76
77
78
79
# File 'lib/rubino/skills/installer.rb', line 69

def install(entries, checkout:, source:, commit:)
  FileUtils.mkdir_p(@skills_dir)
  data = sources
  entries.each do |entry|
    dest = File.join(@skills_dir, entry[:name])
    FileUtils.rm_rf(dest)
    FileUtils.cp_r(File.join(checkout, entry[:path]), dest)
    data[entry[:name]] = { "source" => source, "path" => entry[:path], "commit" => commit }
  end
  write_sources(data)
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.



105
106
107
108
109
110
111
112
113
# File 'lib/rubino/skills/installer.rb', line 105

def remove(name) # rubocop:disable Naming/PredicateMethod -- "did I remove anything", a mutator reporting what it did
  data = sources
  return false unless data.key?(name)

  FileUtils.rm_rf(File.join(@skills_dir, name))
  data.delete(name)
  write_sources(data)
  true
end

#sourcesObject

The provenance ledger (empty hash when absent or unparseable).



116
117
118
119
120
121
# File 'lib/rubino/skills/installer.rb', line 116

def sources
  path = File.join(@skills_dir, SOURCES_FILE)
  File.file?(path) ? JSON.parse(File.read(path)) : {}
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).



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/rubino/skills/installer.rb', line 85

def update(names = [])
  data = sources
  names = data.keys if names.empty?
  results = {}
  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) }
      write_sources(data)
      true
    end
    group.each { |name| results[name] = :failed } unless fetched
  end
  results
end