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 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)
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)
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)
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)
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
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)
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)
def local_zip_path?(source) .start_with?('/') || source.start_with?('~') || source.start_with?('./') || rce.end_with?('.zip') && !source.start_with?('http'))
def report_results
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)