lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb



#!/usr/bin/env ruby
# frozen_string_literal: true

require 'fileutils'
require 'tmpdir'
require 'uri'
require 'net/http'
require 'find'

# Install a skill from a remote zip archive URL.
# Usage: ruby install_from_zip.rb <zip_url>
#
# The zip archive is expected to contain a skill directory at its root, e.g.:
#   my-skill/
#     SKILL.md
#     scripts/
#
# Or the archive may contain multiple skill directories (each with a SKILL.md).
class ZipSkillInstaller
  ZIP_URL_PATTERN = %r{^https?://.+\.zip(\?.*)?$}i

  def initialize(zip_source, skill_name: nil, target_dir: nil, skip_if_exists: false)
    @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')
    # When true, existing skill directories are preserved and the install for
    # that specific skill is skipped (recorded in @skipped_skills).
    # Default false keeps the legacy "overwrite" behaviour for `install`.
    @skip_if_exists   = skip_if_exists
    # Suppresses user-facing puts for programmatic callers (set by `perform`).
    @silent           = false
    @installed_skills = []
    @skipped_skills   = []
    @errors           = []
  end

  # Programmatic entry point for library-style callers (e.g. onboard pre-install).
  #
  # Unlike `install`, this method:
  #   - does NOT print user-facing output
  #   - does NOT call `exit` on failure (raises instead)
  #   - returns a result hash: { installed: [...], skipped: [...], errors: [...] }
  #
  # The caller is responsible for rendering feedback and deciding whether any
  # error is fatal.
  def perform
    @silent = true
    do_install
    { installed: @installed_skills, skipped: @skipped_skills, errors: @errors }
  end

  # Main installation entry point (CLI). Prints progress, prints a final
  # report, and calls `exit` on failure. Use `perform` for programmatic use.
  def install
    do_install
    report_results
  rescue ArgumentError => e
    puts "Error: #{e.message}"
    exit 1
  rescue StandardError => e
    puts "Error: Installation failed: #{e.message}"
    exit 1
  end

  # Shared core used by both `install` (CLI) and `perform` (library).
  # Raises on invalid input; the caller decides how to surface errors.
  private def do_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)
        discover_and_install_skills(File.join(tmpdir, 'extracted'))
      end
    else
      # Install from a remote URL.
      unless valid_zip_url?
        raise ArgumentError, "Invalid zip source: #{@zip_source}\n" \
                             "Provide 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)
        discover_and_install_skills(File.join(tmpdir, 'extracted'))
      end
    end
  end

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

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

  private def valid_zip_url?
    @zip_source.match?(ZIP_URL_PATTERN)
  end

  # Download the zip file to tmpdir and return its local path.
  private def download_zip(tmpdir)
    unless @silent
      puts "Downloading skill package..."
      puts "   #{@zip_source}"
    end

    zip_path = File.join(tmpdir, 'skill.zip')
    uri = URI.parse(@zip_source)

    # Follow redirects up to 5 times (ActiveStorage often redirects).
    max_redirects = 5
    current_uri = uri

    max_redirects.times do
      Net::HTTP.start(current_uri.host, current_uri.port,
                      use_ssl: current_uri.scheme == 'https',
                      open_timeout: 15, read_timeout: 60) do |http|
        request = Net::HTTP::Get.new(current_uri.request_uri)
        http.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
        end
      end
    end

    raise "Too many redirects downloading #{@zip_source}"
  end

  # Extract the zip archive into <tmpdir>/extracted/.
  private def extract_zip(zip_path, tmpdir)
    puts "Extracting package..." unless @silent
    extracted_dir = File.join(tmpdir, 'extracted')
    FileUtils.mkdir_p(extracted_dir)

    # Prefer the 'unzip' system command; fall back to Ruby's built-in zip support via ZipFile.
    if system('which', 'unzip', out: File::NULL, err: File::NULL)
      result = system('unzip', '-q', zip_path, '-d', extracted_dir)
      raise "unzip failed (exit code #{$?.exitstatus})" unless result
    else
      # Attempt to use the 'zip' gem if available, otherwise raise a clear error.
      begin
        require 'zip'
        Zip::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
        end
      rescue LoadError
        raise "Cannot extract zip: 'unzip' command not found and 'zip' gem is not installed.\n" \
              "Install unzip (e.g. brew install unzip) and try again."
      end
    end
  end

  # Walk the extracted directory and install every skill (directory containing SKILL.md).
  # Supports two zip layouts:
  #   Layout A — SKILL.md at zip root (flat): extracted/SKILL.md
  #   Layout B — skill in subdirectory:       extracted/my-skill/SKILL.md
  private def discover_and_install_skills(extracted_dir)
    # Layout A: SKILL.md directly under the extraction root.
    if File.exist?(File.join(extracted_dir, 'SKILL.md'))
      install_skill(extracted_dir, skill_name: @skill_name)
      return
    end

    # Layout B: one or more skill subdirectories each containing SKILL.md.
    skill_dirs = Dir.glob(File.join(extracted_dir, '*/SKILL.md')).map { |f| File.dirname(f) }

    if skill_dirs.empty?
      raise "No SKILL.md found in the zip archive. " \
            "Make sure the package contains a valid skill directory."
    end

    skill_dirs.each { |dir| install_skill(dir) }
  end

  # Copy a single skill directory into the target skills folder.
  # skill_name overrides the directory basename (used for Layout A flat zips).
  private def install_skill(skill_src_dir, skill_name: nil)
    name        = skill_name || File.basename(skill_src_dir)
    target_path = File.join(@target_dir, name)

    if File.exist?(target_path)
      if @skip_if_exists
        @skipped_skills << { name: name, path: target_path, reason: 'already exists' }
        return
      end
      puts "Skill '#{name}' already exists — overwriting..." unless @silent
      FileUtils.rm_rf(target_path)
    end

    FileUtils.mkdir_p(target_path)
    # Copy contents of skill_src_dir into target_path (not the dir itself).
    FileUtils.cp_r(Dir.glob("#{skill_src_dir}/*"), target_path)

    description = extract_description(File.join(target_path, 'SKILL.md'))
    @installed_skills << { name: name, path: target_path, description: description }
  rescue StandardError => e
    @errors << "Failed to install '#{name}': #{e.message}"
  end

  # Parse the description field from SKILL.md YAML frontmatter.
  private def extract_description(skill_file)
    return "No description" unless File.exist?(skill_file)

    content = File.read(skill_file)
    if content =~ /\A---\s*\n(.*?)\n---/m
      frontmatter = $1
      return $1.strip if frontmatter =~ /^description:\s*(.+)$/
    end

    "No description"
  rescue StandardError
    "No description"
  end

  # Print a human-readable summary of what was installed.
  private def report_results
    puts "\n" + "=" * 60

    if @installed_skills.empty?
      puts "No skills were installed."
      if @errors.any?
        puts "\nErrors:"
        @errors.each { |e| puts "   • #{e}" }
      end
      exit 1
    end

    puts "Installation complete!"
    puts "\nInstalled #{@installed_skills.size} skill(s):\n\n"
    @installed_skills.each do |skill|
      puts "   ✓ #{skill[:name]}"
      puts "     #{skill[:description]}"
      puts "     → #{skill[:path]}"
      puts
    end

    if @errors.any?
      puts "Warnings:"
      @errors.each { |e| puts "   • #{e}" }
      puts
    end

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

# ── Entry point ────────────────────────────────────────────────────────────────
if __FILE__ == $0
  if ARGV.empty?
    puts "Usage: ruby install_from_zip.rb <zip_url_or_path> [skill_name]"
    puts "\nExamples:"
    puts "  ruby install_from_zip.rb https://example.com/my-skill-1.0.0.zip"
    puts "  ruby install_from_zip.rb https://example.com/my-skill-1.0.0.zip my-skill"
    puts "  ruby install_from_zip.rb /path/to/my-skill.zip"
    puts "  ruby install_from_zip.rb ~/Downloads/my-skill-1.0.0.zip my-skill"
    exit 1
  end

  ZipSkillInstaller.new(ARGV[0], skill_name: ARGV[1]).install
end