class ZipSkillInstaller

Or the archive may contain multiple skill directories (each with a SKILL.md).
scripts/
SKILL.md
my-skill/
The zip archive is expected to contain a skill directory at its root, e.g.:
Usage: ruby install_from_zip.rb <zip_url>
Install a skill from a remote zip archive URL.

def discover_and_install_skills(extracted_dir)

Layout B — skill in subdirectory: extracted/my-skill/SKILL.md
Layout A — SKILL.md at zip root (flat): extracted/SKILL.md
Supports two zip layouts:
Walk the extracted directory and install every skill (directory containing SKILL.md).
def discover_and_install_skills(extracted_dir)
ut A: SKILL.md directly under the extraction root.
e.exist?(File.join(extracted_dir, 'SKILL.md'))
all_skill(extracted_dir, skill_name: @skill_name)
rn
ut B: one or more skill subdirectories each containing SKILL.md.
dirs = Dir.glob(File.join(extracted_dir, '*/SKILL.md')).map { |f| File.dirname(f) }
ll_dirs.empty?
e "No SKILL.md found in the zip archive. " \
  "Make sure the package contains a valid skill directory."
dirs.each { |dir| install_skill(dir) }

def download_zip(tmpdir)

Download the zip file to tmpdir and return its local path.
def download_zip(tmpdir)
⬇️  Downloading skill package..."
   #{@zip_source}"
th = File.join(tmpdir, 'skill.zip')
URI.parse(@zip_source)
ow redirects up to 5 times (ActiveStorage often redirects).
directs = 5
t_uri = uri
directs.times do
:HTTP.start(current_uri.host, current_uri.port,
            use_ssl: current_uri.scheme == 'https',
            open_timeout: 15, read_timeout: 60) do |http|
quest = Net::HTTP::Get.new(current_uri.request_uri)
tp.request(request) do |response|
case response.code.to_i
when 200
  File.open(zip_path, 'wb') { |f| response.read_body { |chunk| f.write(chunk) } }
  return zip_path
when 301, 302, 303, 307, 308
  location = response['location']
  raise "Redirect loop or missing Location header" if location.nil? || location == current_uri.to_s
  current_uri = URI.parse(location)
else
  raise "HTTP #{response.code} while downloading #{@zip_source}"
end
d
"Too many redirects downloading #{@zip_source}"

def extract_description(skill_file)

Parse the description field from SKILL.md YAML frontmatter.
def extract_description(skill_file)
 "No description" unless File.exist?(skill_file)
t = File.read(skill_file)
tent =~ /\A---\s*\n(.*?)\n---/m
tmatter = $1
rn $1.strip if frontmatter =~ /^description:\s*(.+)$/
scription"
tandardError
scription"

def extract_zip(zip_path, tmpdir)

Extract the zip archive into /extracted/.
def extract_zip(zip_path, tmpdir)
📂 Extracting package..."
ted_dir = File.join(tmpdir, 'extracted')
ils.mkdir_p(extracted_dir)
er the 'unzip' system command; fall back to Ruby's built-in zip support via ZipFile.
tem('which', 'unzip', out: File::NULL, err: File::NULL)
lt = system('unzip', '-q', zip_path, '-d', extracted_dir)
e "unzip failed (exit code #{$?.exitstatus})" unless result
tempt to use the 'zip' gem if available, otherwise raise a clear error.
n
quire 'zip'
p::File.open(zip_path) do |zip|
zip.each do |entry|
  dest = File.join(extracted_dir, entry.name)
  FileUtils.mkdir_p(File.dirname(dest))
  entry.extract(dest)
end
d
ue LoadError
ise "Cannot extract zip: 'unzip' command not found and 'zip' gem is not installed.\n" \
    "Install unzip (e.g. brew install unzip) and try again."

def infer_skill_name(source)

e.g. "ui-ux-pro-max-1.0.0.zip" → "ui-ux-pro-max"
Works for both URLs and local paths.
Infer a skill name from the zip filename, stripping version suffixes.
def infer_skill_name(source)
me = if source.start_with?('http')
       File.basename(URI.parse(source).path, '.zip') rescue File.basename(source, '.zip')
     else
       File.basename(source, '.zip')
     end
p trailing version segment like "-1.0.0" or "-2.3"
me.sub(/-\d+(\.\d+)+$/, '')

def initialize(zip_source, skill_name: nil, target_dir: nil)

def initialize(zip_source, skill_name: nil, target_dir: nil)
  @zip_source = zip_source
  @local_path = local_zip_path?(zip_source)
  # skill_name can be provided explicitly (e.g. slug from the store API).
  # If not provided, we try to infer it from the filename in the URL/path, e.g.
  # "ui-ux-pro-max-1.0.0.zip" → "ui-ux-pro-max".
  @skill_name = skill_name || infer_skill_name(zip_source)
  @target_dir = target_dir || File.join(Dir.home, '.clacky', 'skills')
  @installed_skills = []
  @errors = []
end

def install

Main installation entry point.
def install
  if @local_path
    # Install directly from a local zip file — no download needed.
    # Expand tilde in path (e.g. ~/Downloads/skill.zip)
    expanded = File.expand_path(@zip_source)
    raise ArgumentError, "File not found: #{@zip_source}" unless File.exist?(expanded)
    raise ArgumentError, "Not a zip file: #{@zip_source}" unless expanded.end_with?('.zip')
    Dir.mktmpdir('clacky-zip-') do |tmpdir|
      extract_zip(expanded, tmpdir)
      extracted_dir = File.join(tmpdir, 'extracted')
      discover_and_install_skills(extracted_dir)
    end
  else
    # Install from a remote URL.
    unless valid_zip_url?
      raise ArgumentError, "Invalid zip source: #{@zip_source}\nProvide an http(s) URL ending with .zip, or an absolute path to a local zip file."
    end
    Dir.mktmpdir('clacky-zip-') do |tmpdir|
      zip_path = download_zip(tmpdir)
      extract_zip(zip_path, tmpdir)
      extracted_dir = File.join(tmpdir, 'extracted')
      discover_and_install_skills(extracted_dir)
    end
  end
  report_results
rescue ArgumentError => e
  puts "❌ #{e.message}"
  exit 1
rescue StandardError => e
  puts "❌ Installation failed: #{e.message}"
  exit 1
end

def install_skill(skill_src_dir, skill_name: nil)

skill_name overrides the directory basename (used for Layout A flat zips).
Copy a single skill directory into the target skills folder.
def install_skill(skill_src_dir, skill_name: nil)
      = skill_name || File.basename(skill_src_dir)
_path = File.join(@target_dir, name)
e.exist?(target_path)
 "♻️  Skill '#{name}' already exists — overwriting..."
Utils.rm_rf(target_path)
ils.mkdir_p(target_path)
 contents of skill_src_dir into target_path (not the dir itself).
ils.cp_r(Dir.glob("#{skill_src_dir}/*"), target_path)
ption = extract_description(File.join(target_path, 'SKILL.md'))
lled_skills << { name: name, path: target_path, description: description }
tandardError => e
s << "Failed to install '#{name}': #{e.message}"

def local_zip_path?(source)

Return true if the source looks like a local file path (absolute or relative ending in .zip).
def local_zip_path?(source)
.start_with?('/') || source.start_with?('~') || source.start_with?('./') ||
rce.end_with?('.zip') && !source.start_with?('http'))

def report_results

Print a human-readable summary of what was installed.
def report_results
\n" + "=" * 60
stalled_skills.empty?
 " No skills were installed."
errors.any?
ts "\nErrors:"
rrors.each { |e| puts "    #{e}" }
 1
 Installation complete!"
\nInstalled #{@installed_skills.size} skill(s):\n\n"
lled_skills.each do |skill|
 "   ✓ #{skill[:name]}"
 "     #{skill[:description]}"
 "     → #{skill[:path]}"

rors.any?
 "⚠️  Warnings:"
ors.each { |e| puts "   • #{e}" }

You can now use these skills with /skill-name"
=" * 60

def valid_zip_url?

def valid_zip_url?
ource.match?(ZIP_URL_PATTERN)