Class: RubynCode::Skills::PackManager

Inherits:
Object
  • Object
show all
Defined in:
lib/rubyn_code/skills/pack_manager.rb

Overview

Manages local installation, removal, and updates of skill packs with ETag caching and offline fallback support.

Installed packs live under ~/.rubyn-code/skill-packs/<pack-name>/. A manifest.json in each pack directory records metadata for listing, version tracking, and ETag-based conditional updates.

Constant Summary collapse

PACKS_DIR =
File.join(Config::Defaults::HOME_DIR, 'skill-packs')
MANIFEST_FILE =
'manifest.json'
ETAG_CACHE_FILE =
'.etags.json'
SAFE_NAME_RE =
/\A[a-zA-Z0-9_-]+\z/

Instance Method Summary collapse

Constructor Details

#initialize(packs_dir: PACKS_DIR) ⇒ PackManager

Returns a new instance of PackManager.



20
21
22
# File 'lib/rubyn_code/skills/pack_manager.rb', line 20

def initialize(packs_dir: PACKS_DIR)
  @packs_dir = packs_dir
end

Instance Method Details

#all_pack_dirsArray<String>

Return all installed pack directories (for skill loader integration).

Returns:

  • (Array<String>)


133
134
135
# File 'lib/rubyn_code/skills/pack_manager.rb', line 133

def all_pack_dirs
  installed.map { |pack| pack_path(pack[:name]) }.select { |d| File.directory?(d) }
end

#install(pack_data, etag: nil) ⇒ Hash

Install a pack from registry response data.

Parameters:

  • pack_data (Hash)

    from RegistryClient#fetch_pack Expected keys: :name, :description, :version, :files Each file: { filename: “name.md”, content: “…” }

  • etag (String, nil) (defaults to: nil)

    ETag from registry response for cache tracking

Returns:

  • (Hash)

    installed pack metadata

Raises:

  • (ArgumentError)


31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/rubyn_code/skills/pack_manager.rb', line 31

def install(pack_data, etag: nil)
  name = fetch_key(pack_data, :name)
  raise ArgumentError, 'Pack data must include a name' if name.nil? || name.empty?

  validate_name!(name)
  pack_dir = pack_path(name)
  FileUtils.mkdir_p(pack_dir)

  write_files(pack_dir, pack_data)
  write_manifest(pack_dir, pack_data, etag: etag)
  store_etag(name, etag) if etag

  manifest(name)
end

#installedArray<Hash>

List all installed packs.

Returns:

  • (Array<Hash>)

    each with :name, :description, :version, :installed_at



102
103
104
105
106
107
108
109
110
# File 'lib/rubyn_code/skills/pack_manager.rb', line 102

def installed
  return [] unless File.directory?(@packs_dir)

  Dir.children(@packs_dir)
     .select { |d| File.directory?(File.join(@packs_dir, d)) }
     .reject { |d| d.start_with?('.') }
     .filter_map { |d| manifest(d) }
     .sort_by { |m| m[:name] }
end

#installed?(name) ⇒ Boolean

Check if a pack is installed.

Parameters:

  • name (String)

Returns:

  • (Boolean)


116
117
118
119
# File 'lib/rubyn_code/skills/pack_manager.rb', line 116

def installed?(name)
  manifest_path = File.join(@packs_dir, name.to_s, MANIFEST_FILE)
  File.exist?(manifest_path)
end

#pack_skills_dir(name) ⇒ String?

Return the skills directory for a pack (for catalog integration).

Parameters:

  • name (String)

Returns:

  • (String, nil)

    path to pack directory or nil



125
126
127
128
# File 'lib/rubyn_code/skills/pack_manager.rb', line 125

def pack_skills_dir(name)
  dir = pack_path(name)
  File.directory?(dir) ? dir : nil
end

#remove(name) ⇒ Boolean

Remove an installed pack with path traversal protection.

Parameters:

  • name (String)

    pack name

Returns:

  • (Boolean)

    true if removed, false if not found



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/rubyn_code/skills/pack_manager.rb', line 82

def remove(name)
  validate_name!(name)
  pack_dir = pack_path(name)
  return false unless File.directory?(pack_dir)

  # Verify the resolved path is within packs_dir to prevent traversal
  real_pack = File.realpath(pack_dir)
  real_base = File.realpath(@packs_dir)
  unless real_pack.start_with?("#{real_base}/")
    raise ArgumentError, "Pack directory is outside the skill-packs directory"
  end

  FileUtils.rm_rf(pack_dir)
  remove_etag(name)
  true
end

#update(name, registry) ⇒ Symbol

Update a single installed pack using ETag-based conditional fetch. Returns :updated, :up_to_date, or :not_installed.

Parameters:

  • name (String)

    pack name

  • registry (RegistryClient)

    registry client to fetch from

Returns:

  • (Symbol)

    update result



52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/rubyn_code/skills/pack_manager.rb', line 52

def update(name, registry)
  validate_name!(name)
  return :not_installed unless installed?(name)

  cached_etag = load_etag(name)
  result = registry.fetch_pack(name, etag: cached_etag)

  return :up_to_date if result[:not_modified]

  install(result[:data], etag: result[:etag])
  :updated
end

#update_all(registry) ⇒ Hash<String, Symbol>

Update all installed packs. Returns a hash of { name => status }.

Parameters:

Returns:

  • (Hash<String, Symbol>)


69
70
71
72
73
74
75
76
# File 'lib/rubyn_code/skills/pack_manager.rb', line 69

def update_all(registry)
  installed.each_with_object({}) do |pack, results|
    name = pack[:name]
    results[name] = update(name, registry)
  rescue RegistryError => e
    results[name] = :"error: #{e.message}"
  end
end