Console Playground

CLI

Fast command-line client for code execution and interactive sessions. 42+ languages, 30+ shells/REPLs.

Official OpenAPI Swagger Docs ↗

Quick Start — Ruby

# Download + setup
curl -O https://git.unturf.com/engineering/unturf/un-inception/-/raw/main/clients/ruby/sync/src/un.rb && chmod +x un.rb && ln -sf un.rb un
export UNSANDBOX_PUBLIC_KEY="unsb-pk-xxxx-xxxx-xxxx-xxxx"
export UNSANDBOX_SECRET_KEY="unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx"
# Run code
./un script.rb

Downloads

Install Guide →
Static Binary
Linux x86_64 (5.3MB)
un
Ruby SDK
un.rb (108.8 KB)
Download

Features

  • 42+ languages - Python, JS, Go, Rust, C++, Java...
  • Sessions - 30+ shells/REPLs, tmux persistence
  • Files - Upload files, collect artifacts
  • Services - Persistent containers with domains
  • Snapshots - Point-in-time backups
  • Images - Publish, share, transfer

Integration Quickstart ⚡

Add unsandbox superpowers to your existing Ruby app:

1
Download
curl -O https://git.unturf.com/engineering/unturf/un-inception/-/raw/main/clients/ruby/sync/src/un.rb
2
Set API Keys
# Option A: Environment variables
export UNSANDBOX_PUBLIC_KEY="unsb-pk-xxxx-xxxx-xxxx-xxxx"
export UNSANDBOX_SECRET_KEY="unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx"

# Option B: Config file (persistent)
mkdir -p ~/.unsandbox
echo "unsb-pk-xxxx-xxxx-xxxx-xxxx,unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx" > ~/.unsandbox/accounts.csv
3
Hello World
# In your Ruby app:
require_relative 'un'

result = Un.execute_code("ruby", "puts 'Hello from Ruby running on unsandbox!'")
puts result["stdout"]  # Hello from Ruby running on unsandbox!
Demo cooldown: s
stdout:

                      
JSON Response:

                      
4
Run
ruby myapp.rb
Source Code 📄 (3000 lines)
MD5: 09eb3b67d65adc5c31e070c845fbee0f SHA256: 9fd0c27dc2b00b5447e81cef27ba825cae727f361e3f2b105218f22b24473678
#!/usr/bin/env ruby
# frozen_string_literal: true

# PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
#
# unsandbox.com Ruby SDK (Synchronous)
#
# Library Usage:
#     require_relative 'un'
#
#     # Execute code synchronously
#     result = Un.execute_code("python", 'print("hello")')
#
#     # Execute asynchronously and get job_id
#     job_id = Un.execute_async("javascript", 'console.log("hello")')
#
#     # Wait for job completion with exponential backoff
#     result = Un.wait_for_job(job_id)
#
#     # List all jobs
#     jobs = Un.list_jobs
#
#     # Get supported languages (cached for 1 hour)
#     languages = Un.get_languages
#
#     # Snapshot operations
#     snapshot_id = Un.session_snapshot(session_id)
#
# CLI Usage:
#     ruby un.rb script.py                  # Execute Python script
#     ruby un.rb -s bash 'echo hello'       # Inline bash command
#     ruby un.rb session --list             # List sessions
#     ruby un.rb service --list             # List services
#     ruby un.rb snapshot --list            # List snapshots
#     ruby un.rb key                        # Check API key
#
# Authentication Priority (4-tier):
#     1. Method arguments (public_key:, secret_key:)
#     2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
#     3. Config file (~/.unsandbox/accounts.csv, line 0 by default)
#     4. Local directory (./accounts.csv, line 0 by default)
#
# Request Authentication (HMAC-SHA256):
#     Authorization: Bearer <public_key>
#     X-Timestamp: <unix_seconds>
#     X-Signature: HMAC-SHA256(secret_key, "timestamp:METHOD:path:body")
#
# Languages Cache:
#     - Cached in ~/.unsandbox/languages.json
#     - TTL: 1 hour
#     - Updated on successful API calls

require 'net/http'
require 'uri'
require 'json'
require 'openssl'
require 'fileutils'
require 'optparse'
require 'cgi'

# Unsandbox Ruby SDK module (synchronous)
module Un
  # API base URL
  API_BASE = 'https://api.unsandbox.com'

  # Polling delays in milliseconds for exponential backoff
  POLL_DELAYS_MS = [300, 450, 700, 900, 650, 1600, 2000].freeze

  # Languages cache TTL in seconds (1 hour)
  LANGUAGES_CACHE_TTL = 3600

  # HTTP request timeout in seconds
  REQUEST_TIMEOUT = 120

  # Error raised when credentials cannot be found or are invalid
  class CredentialsError < StandardError; end

  # Error raised for API request failures
  class APIError < StandardError
    attr_reader :status_code, :response_body

    # @param message [String] Error message
    # @param status_code [Integer, nil] HTTP status code
    # @param response_body [String, nil] Response body
    def initialize(message, status_code: nil, response_body: nil)
      super(message)
      @status_code = status_code
      @response_body = response_body
    end
  end

  class << self
    # Execute code synchronously (blocks until completion)
    #
    # @param language [String] Programming language (e.g., "python", "javascript", "go")
    # @param code [String] Source code to execute
    # @param public_key [String, nil] Optional API key (uses credentials resolution if not provided)
    # @param secret_key [String, nil] Optional API secret (uses credentials resolution if not provided)
    # @return [Hash] Response hash containing stdout, stderr, exit code, etc.
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.execute_code("python", 'print("hello")')
    #     puts result["stdout"]  # => "hello\n"
    def execute_code(language, code, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request(
        'POST',
        '/execute',
        pk,
        sk,
        { language: language, code: code }
      )

      # If we got a job_id, poll until completion
      job_id = response['job_id']
      status = response['status']

      if job_id && %w[pending running].include?(status)
        return wait_for_job(job_id, public_key: pk, secret_key: sk)
      end

      response
    end

    # Execute code asynchronously (returns immediately with job_id)
    #
    # @param language [String] Programming language (e.g., "python", "javascript")
    # @param code [String] Source code to execute
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [String] Job ID string
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     job_id = Un.execute_async("python", 'import time; time.sleep(10); print("done")')
    #     # Later...
    #     result = Un.wait_for_job(job_id)
    def execute_async(language, code, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request(
        'POST',
        '/execute',
        pk,
        sk,
        { language: language, code: code }
      )
      response['job_id']
    end

    # Get current status/result of a job (single poll, no waiting)
    #
    # @param job_id [String] Job ID from execute_async
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Job response hash
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     job = Un.get_job(job_id)
    #     puts job["status"]  # => "running" or "completed"
    def get_job(job_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('GET', "/jobs/#{job_id}", pk, sk)
    end

    # Wait for job completion with exponential backoff polling
    #
    # Polling delays (ms): [300, 450, 700, 900, 650, 1600, 2000, ...]
    # Cumulative: 300, 750, 1450, 2350, 3000, 4600, 6600ms+
    #
    # @param job_id [String] Job ID from execute_async
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param timeout [Integer] Maximum time to wait in seconds (default: 3600)
    # @return [Hash] Final job result when status is terminal (completed, failed, timeout, cancelled)
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails or timeout reached
    #
    # @example
    #     result = Un.wait_for_job(job_id, timeout: 60)
    #     puts result["stdout"]
    def wait_for_job(job_id, public_key: nil, secret_key: nil, timeout: 3600)
      pk, sk = resolve_credentials(public_key, secret_key)
      poll_count = 0
      start_time = Time.now

      loop do
        # Check timeout
        elapsed = Time.now - start_time
        raise APIError, "Job wait timeout after #{timeout} seconds" if elapsed > timeout

        # Sleep before polling
        delay_idx = [poll_count, POLL_DELAYS_MS.length - 1].min
        sleep(POLL_DELAYS_MS[delay_idx] / 1000.0)
        poll_count += 1

        response = get_job(job_id, public_key: pk, secret_key: sk)
        status = response['status']

        return response if %w[completed failed timeout cancelled].include?(status)

        # Still running, continue polling
      end
    end

    # Cancel a running job
    #
    # @param job_id [String] Job ID to cancel
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with cancellation confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.cancel_job(job_id)
    def cancel_job(job_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('DELETE', "/jobs/#{job_id}", pk, sk)
    end

    # List all jobs for the authenticated account
    #
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<Hash>] List of job hashes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     jobs = Un.list_jobs
    #     jobs.each { |job| puts "#{job['job_id']}: #{job['status']}" }
    def list_jobs(public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request('GET', '/jobs', pk, sk)
      response['jobs'] || []
    end

    # Get list of supported programming languages
    #
    # Results are cached for 1 hour in ~/.unsandbox/languages.json
    #
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<String>] List of language identifiers (e.g., ["python", "javascript", "go", ...])
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     languages = Un.get_languages
    #     puts languages.join(", ")
    def get_languages(public_key: nil, secret_key: nil)
      # Try cache first
      cached = load_languages_cache
      return cached if cached

      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request('GET', '/languages', pk, sk)
      languages = response['languages'] || []

      # Cache the result
      save_languages_cache(languages)
      languages
    end

    # Detect programming language from filename extension
    #
    # @param filename [String] Filename to detect language from (e.g., "script.py")
    # @return [String, nil] Language identifier (e.g., "python") or nil if unknown
    #
    # @example
    #     Un.detect_language("hello.py")   # => "python"
    #     Un.detect_language("script.js")  # => "javascript"
    #     Un.detect_language("main.go")    # => "go"
    #     Un.detect_language("unknown")    # => nil
    def detect_language(filename)
      return nil if filename.nil? || !filename.include?('.')

      ext = filename.split('.').last&.downcase
      LANGUAGE_MAP[ext]
    end

    # Create a snapshot of a session
    #
    # @param session_id [String] Session ID to snapshot
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param name [String, nil] Optional snapshot name
    # @param ephemeral [Boolean] If true, create ephemeral snapshot (default: false)
    # @return [String] Snapshot ID
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     snapshot_id = Un.session_snapshot(session_id, name: "my-snapshot")
    def session_snapshot(session_id, public_key: nil, secret_key: nil, name: nil, ephemeral: false)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = { session_id: session_id, hot: ephemeral }
      data[:name] = name if name

      response = make_request('POST', '/snapshots', pk, sk, data)
      response['snapshot_id']
    end

    # Create a snapshot of a service
    #
    # @param service_id [String] Service ID to snapshot
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param name [String, nil] Optional snapshot name
    # @return [String] Snapshot ID
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     snapshot_id = Un.service_snapshot(service_id, name: "production-backup")
    def service_snapshot(service_id, public_key: nil, secret_key: nil, name: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = { service_id: service_id, hot: false }
      data[:name] = name if name

      response = make_request('POST', '/snapshots', pk, sk, data)
      response['snapshot_id']
    end

    # List all snapshots
    #
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<Hash>] List of snapshot hashes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     snapshots = Un.list_snapshots
    #     snapshots.each { |s| puts "#{s['snapshot_id']}: #{s['name']}" }
    def list_snapshots(public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request('GET', '/snapshots', pk, sk)
      response['snapshots'] || []
    end

    # Get details of a specific snapshot
    #
    # @param snapshot_id [String] Snapshot ID to get details for
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Snapshot details hash containing:
    #   - id: Snapshot ID
    #   - name: Snapshot name
    #   - type: "session" or "service"
    #   - source_id: Original resource ID
    #   - hot: Whether snapshot preserves running state
    #   - locked: Whether snapshot is locked
    #   - created_at: Creation timestamp
    #   - size_bytes: Size in bytes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     snapshot = Un.get_snapshot(snapshot_id)
    #     puts "Snapshot: #{snapshot['name']} (#{snapshot['type']})"
    def get_snapshot(snapshot_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('GET', "/snapshots/#{snapshot_id}", pk, sk)
    end

    # Restore a snapshot
    #
    # @param snapshot_id [String] Snapshot ID to restore
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with restored resource info
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.restore_snapshot(snapshot_id)
    #     puts result["session_id"]  # or result["service_id"]
    def restore_snapshot(snapshot_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/snapshots/#{snapshot_id}/restore", pk, sk, {})
    end

    # Delete a snapshot
    #
    # @param snapshot_id [String] Snapshot ID to delete
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with deletion confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.delete_snapshot(snapshot_id)
    def delete_snapshot(snapshot_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request_with_sudo('DELETE', "/snapshots/#{snapshot_id}", pk, sk)
    end

    # Lock a snapshot to prevent deletion
    #
    # @param snapshot_id [String] Snapshot ID to lock
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with lock confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.lock_snapshot(snapshot_id)
    def lock_snapshot(snapshot_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/snapshots/#{snapshot_id}/lock", pk, sk, {})
    end

    # Unlock a snapshot to allow deletion
    #
    # @param snapshot_id [String] Snapshot ID to unlock
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with unlock confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.unlock_snapshot(snapshot_id)
    def unlock_snapshot(snapshot_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request_with_sudo('POST', "/snapshots/#{snapshot_id}/unlock", pk, sk, {})
    end

    # Clone a snapshot to create a new session or service
    #
    # @param snapshot_id [String] Snapshot ID to clone
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param name [String, nil] Optional name for the cloned resource
    # @param type [String] Type of resource to create ("session" or "service")
    # @param shell [String, nil] Optional shell for session clones
    # @param ports [Array<Integer>, nil] Optional ports for service clones
    # @return [Hash] Response hash with cloned resource info (session_id or service_id)
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example Clone to session
    #     result = Un.clone_snapshot(snapshot_id, type: "session")
    #     puts result["session_id"]
    #
    # @example Clone to service
    #     result = Un.clone_snapshot(snapshot_id, type: "service", ports: [80, 443])
    #     puts result["service_id"]
    def clone_snapshot(snapshot_id, public_key: nil, secret_key: nil, name: nil, type: 'session', shell: nil, ports: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = { type: type }
      data[:name] = name if name
      data[:shell] = shell if shell
      data[:ports] = ports if ports
      make_request('POST', "/snapshots/#{snapshot_id}/clone", pk, sk, data)
    end

    # ============================================================================
    # Image Functions (LXD Container Images)
    # ============================================================================

    # Publish an LXD container image from a session or service
    #
    # @param source_type [String] Source type ("session" or "service")
    # @param source_id [String] ID of the session or service to publish
    # @param name [String, nil] Optional name for the image
    # @param description [String, nil] Optional description for the image
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with image_id and other metadata
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example Publish from session
    #     result = Un.image_publish("session", session_id, name: "my-image")
    #     puts result["image_id"]
    #
    # @example Publish from service
    #     result = Un.image_publish("service", service_id, description: "Production snapshot")
    #     puts result["image_id"]
    def image_publish(source_type, source_id, name: nil, description: nil, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = { source_type: source_type, source_id: source_id }
      data[:name] = name if name
      data[:description] = description if description
      make_request('POST', '/images', pk, sk, data)
    end

    # List all images for the authenticated account
    #
    # @param filter_type [String, nil] Optional filter: "own", "shared", or "public"
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<Hash>] List of image hashes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example List all images
    #     images = Un.list_images
    #     images.each { |img| puts "#{img['image_id']}: #{img['name']}" }
    #
    # @example List only owned images
    #     owned = Un.list_images(filter_type: "own")
    #
    # @example List shared images
    #     shared = Un.list_images(filter_type: "shared")
    def list_images(filter_type: nil, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      path = filter_type ? "/images/#{filter_type}" : '/images'
      response = make_request('GET', path, pk, sk)
      response['images'] || []
    end

    # Get image details by ID
    #
    # @param image_id [String] Image ID to retrieve
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Image details hash
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     image = Un.get_image(image_id)
    #     puts "#{image['name']}: #{image['description']}"
    def get_image(image_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('GET', "/images/#{image_id}", pk, sk)
    end

    # Delete an image
    #
    # @param image_id [String] Image ID to delete
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with deletion confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.delete_image(image_id)
    def delete_image(image_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request_with_sudo('DELETE', "/images/#{image_id}", pk, sk)
    end

    # Lock an image to prevent deletion
    #
    # @param image_id [String] Image ID to lock
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with lock confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.lock_image(image_id)
    def lock_image(image_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/images/#{image_id}/lock", pk, sk, {})
    end

    # Unlock an image to allow deletion
    #
    # @param image_id [String] Image ID to unlock
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with unlock confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.unlock_image(image_id)
    def unlock_image(image_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request_with_sudo('POST', "/images/#{image_id}/unlock", pk, sk, {})
    end

    # Set image visibility (private, public, or shared)
    #
    # @param image_id [String] Image ID to update
    # @param visibility [String] Visibility level: "private", "public", or "shared"
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with visibility confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example Make image public
    #     Un.set_image_visibility(image_id, "public")
    #
    # @example Make image private
    #     Un.set_image_visibility(image_id, "private")
    def set_image_visibility(image_id, visibility, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/images/#{image_id}/visibility", pk, sk, { visibility: visibility })
    end

    # Grant access to an image for another API key
    #
    # @param image_id [String] Image ID to share
    # @param trusted_api_key [String] Public API key to grant access to
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with grant confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.grant_image_access(image_id, "unsb-pk-xxxxx-xxxxx-xxxxx-xxxxx")
    def grant_image_access(image_id, trusted_api_key, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/images/#{image_id}/grant", pk, sk, { trusted_api_key: trusted_api_key })
    end

    # Revoke access to an image from another API key
    #
    # @param image_id [String] Image ID to revoke access from
    # @param trusted_api_key [String] Public API key to revoke access from
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with revoke confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.revoke_image_access(image_id, "unsb-pk-xxxxx-xxxxx-xxxxx-xxxxx")
    def revoke_image_access(image_id, trusted_api_key, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/images/#{image_id}/revoke", pk, sk, { trusted_api_key: trusted_api_key })
    end

    # List API keys with access to an image
    #
    # @param image_id [String] Image ID to list trusted keys for
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<Hash>] List of trusted API key hashes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     trusted = Un.list_image_trusted(image_id)
    #     trusted.each { |t| puts t["api_key"] }
    def list_image_trusted(image_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request('GET', "/images/#{image_id}/trusted", pk, sk)
      response['trusted'] || []
    end

    # Transfer image ownership to another API key
    #
    # @param image_id [String] Image ID to transfer
    # @param to_api_key [String] Public API key to transfer ownership to
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with transfer confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.transfer_image(image_id, "unsb-pk-xxxxx-xxxxx-xxxxx-xxxxx")
    def transfer_image(image_id, to_api_key, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/images/#{image_id}/transfer", pk, sk, { to_api_key: to_api_key })
    end

    # Spawn a new service from an image
    #
    # @param image_id [String] Image ID to spawn from
    # @param name [String, nil] Optional name for the service
    # @param ports [Array<Integer>, nil] Optional ports to expose
    # @param bootstrap [String, nil] Optional bootstrap script
    # @param network_mode [String] Network mode ("zerotrust" or "semitrusted", default: "zerotrust")
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with service_id and other metadata
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example Spawn with defaults
    #     result = Un.spawn_from_image(image_id)
    #     puts result["service_id"]
    #
    # @example Spawn with custom config
    #     result = Un.spawn_from_image(image_id, name: "web", ports: [80, 443], network_mode: "semitrusted")
    #     puts result["service_id"]
    def spawn_from_image(image_id, name: nil, ports: nil, bootstrap: nil, network_mode: 'zerotrust', public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = { network_mode: network_mode }
      data[:name] = name if name
      data[:ports] = ports if ports
      data[:bootstrap] = bootstrap if bootstrap
      make_request('POST', "/images/#{image_id}/spawn", pk, sk, data)
    end

    # Clone an image to create a new image
    #
    # @param image_id [String] Image ID to clone
    # @param name [String, nil] Optional name for the cloned image
    # @param description [String, nil] Optional description for the cloned image
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with new image_id
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.clone_image(image_id, name: "my-clone", description: "Cloned from production")
    #     puts result["image_id"]
    def clone_image(image_id, name: nil, description: nil, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = {}
      data[:name] = name if name
      data[:description] = description if description
      make_request('POST', "/images/#{image_id}/clone", pk, sk, data)
    end

    # ============================================================================
    # Session Functions
    # ============================================================================

    # List all sessions for the authenticated account
    #
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<Hash>] List of session hashes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     sessions = Un.list_sessions
    #     sessions.each { |s| puts "#{s['id']}: #{s['status']}" }
    def list_sessions(public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request('GET', '/sessions', pk, sk)
      response['sessions'] || []
    end

    # Get session details by ID
    #
    # @param session_id [String] Session ID to retrieve
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Session details hash
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     session = Un.get_session(session_id)
    #     puts session["status"]
    def get_session(session_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('GET', "/sessions/#{session_id}", pk, sk)
    end

    # Create a new interactive session
    #
    # @param language [String] Shell or language for the session (e.g., "bash", "python3")
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param network_mode [String] Network mode ("zerotrust" or "semitrusted")
    # @param ttl [Integer] Time-to-live in seconds (default: 3600)
    # @param multiplexer [String, nil] Terminal multiplexer ("tmux" or "screen")
    # @param vcpu [Integer] Number of vCPUs (1-8)
    # @return [Hash] Response hash with session_id and container_name
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.create_session("bash", network_mode: "semitrusted")
    #     puts result["session_id"]
    def create_session(language, public_key: nil, secret_key: nil, network_mode: 'zerotrust', ttl: 3600, multiplexer: nil, vcpu: 1)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = {
        network_mode: network_mode,
        ttl: ttl
      }
      data[:shell] = language if language
      data[:multiplexer] = multiplexer if multiplexer
      data[:vcpu] = vcpu if vcpu > 1
      make_request('POST', '/sessions', pk, sk, data)
    end

    # Delete (terminate) a session
    #
    # @param session_id [String] Session ID to delete
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with deletion confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.delete_session(session_id)
    def delete_session(session_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('DELETE', "/sessions/#{session_id}", pk, sk)
    end

    # Freeze a session (pause execution, reduce resource usage)
    #
    # @param session_id [String] Session ID to freeze
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with freeze confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.freeze_session(session_id)
    def freeze_session(session_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/sessions/#{session_id}/freeze", pk, sk, {})
    end

    # Unfreeze a session (resume execution)
    #
    # @param session_id [String] Session ID to unfreeze
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with unfreeze confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.unfreeze_session(session_id)
    def unfreeze_session(session_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/sessions/#{session_id}/unfreeze", pk, sk, {})
    end

    # Boost a session (increase vCPU allocation)
    #
    # @param session_id [String] Session ID to boost
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with boost confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.boost_session(session_id)
    def boost_session(session_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/sessions/#{session_id}/boost", pk, sk, {})
    end

    # Unboost a session (reduce vCPU allocation)
    #
    # @param session_id [String] Session ID to unboost
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with unboost confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.unboost_session(session_id)
    def unboost_session(session_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/sessions/#{session_id}/unboost", pk, sk, {})
    end

    # Execute a shell command in an existing session
    # Note: This is for non-interactive command execution, not for WebSocket shell access
    #
    # @param session_id [String] Session ID to execute command in
    # @param command [String] Command to execute
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with command output
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.shell_session(session_id, "ls -la")
    #     puts result["stdout"]
    def shell_session(session_id, command, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/sessions/#{session_id}/shell", pk, sk, { command: command })
    end

    # ============================================================================
    # Service Functions
    # ============================================================================

    # List all services for the authenticated account
    #
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Array<Hash>] List of service hashes
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     services = Un.list_services
    #     services.each { |s| puts "#{s['id']}: #{s['name']} (#{s['state']})" }
    def list_services(public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      response = make_request('GET', '/services', pk, sk)
      response['services'] || []
    end

    # Create a new persistent service
    #
    # @param name [String] Service name
    # @param ports [Array<Integer>] Ports to expose
    # @param bootstrap [String] Bootstrap script content or URL
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param network_mode [String] Network mode ("zerotrust" or "semitrusted")
    # @param vcpu [Integer] Number of vCPUs (1-8)
    # @param custom_domains [Array<String>, nil] Custom domains for the service
    # @param service_type [String, nil] Service type for SRV records (e.g., "minecraft")
    # @param unfreeze_on_demand [Boolean] If true, service will auto-wake on HTTP request (default: false)
    # @return [Hash] Response hash with service_id
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.create_service("web", [80, 443], "apt install -y nginx && nginx")
    #     puts result["service_id"]
    def create_service(name, ports, bootstrap, public_key: nil, secret_key: nil, network_mode: 'semitrusted', vcpu: 1, custom_domains: nil, service_type: nil, unfreeze_on_demand: false)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = {
        name: name,
        ports: ports,
        bootstrap: bootstrap,
        network_mode: network_mode
      }
      data[:vcpu] = vcpu if vcpu > 1
      data[:custom_domains] = custom_domains if custom_domains
      data[:service_type] = service_type if service_type
      data[:unfreeze_on_demand] = unfreeze_on_demand if unfreeze_on_demand
      make_request('POST', '/services', pk, sk, data)
    end

    # Get service details by ID
    #
    # @param service_id [String] Service ID to retrieve
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Service details hash
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     service = Un.get_service(service_id)
    #     puts service["status"]
    def get_service(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('GET', "/services/#{service_id}", pk, sk)
    end

    # Update a service (e.g., resize vCPU)
    #
    # @param service_id [String] Service ID to update
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param vcpu [Integer, nil] New vCPU count (1-8)
    # @param name [String, nil] New service name
    # @return [Hash] Response hash with update confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.update_service(service_id, vcpu: 4)
    def update_service(service_id, public_key: nil, secret_key: nil, vcpu: nil, name: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = {}
      data[:vcpu] = vcpu if vcpu
      data[:name] = name if name
      make_request('PATCH', "/services/#{service_id}", pk, sk, data)
    end

    # Delete (destroy) a service
    #
    # @param service_id [String] Service ID to delete
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with deletion confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.delete_service(service_id)
    def delete_service(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request_with_sudo('DELETE', "/services/#{service_id}", pk, sk)
    end

    # Freeze a service (stop container, reduce resource usage)
    #
    # @param service_id [String] Service ID to freeze
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with freeze confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.freeze_service(service_id)
    def freeze_service(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/services/#{service_id}/freeze", pk, sk, {})
    end

    # Unfreeze a service (start container)
    #
    # @param service_id [String] Service ID to unfreeze
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with unfreeze confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.unfreeze_service(service_id)
    def unfreeze_service(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/services/#{service_id}/unfreeze", pk, sk, {})
    end

    # Lock a service to prevent deletion
    #
    # @param service_id [String] Service ID to lock
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with lock confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.lock_service(service_id)
    def lock_service(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/services/#{service_id}/lock", pk, sk, {})
    end

    # Unlock a service to allow deletion
    #
    # @param service_id [String] Service ID to unlock
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with unlock confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.unlock_service(service_id)
    def unlock_service(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request_with_sudo('POST', "/services/#{service_id}/unlock", pk, sk, {})
    end

    # Set unfreeze-on-demand for a service
    #
    # When enabled, a frozen service will automatically wake when it receives an HTTP request.
    #
    # @param service_id [String] Service ID to update
    # @param enabled [Boolean] Whether to enable unfreeze-on-demand
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with update confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.set_unfreeze_on_demand(service_id, true)
    def set_unfreeze_on_demand(service_id, enabled, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('PATCH', "/services/#{service_id}", pk, sk, { unfreeze_on_demand: enabled })
    end

    # Set show-freeze-page for a service
    #
    # When enabled, frozen services show a freeze page instead of 502 error.
    #
    # @param service_id [String] Service ID to update
    # @param enabled [Boolean] Whether to enable show-freeze-page
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with update confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.set_show_freeze_page(service_id, true)
    def set_show_freeze_page(service_id, enabled, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('PATCH', "/services/#{service_id}", pk, sk, { show_freeze_page: enabled })
    end

    # Get service logs (bootstrap output)
    #
    # @param service_id [String] Service ID to get logs for
    # @param all [Boolean] If true, get all logs; if false, get last 9000 lines (default: false)
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with log content
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     logs = Un.get_service_logs(service_id)
    #     puts logs["log"]
    def get_service_logs(service_id, all: false, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      path = "/services/#{service_id}/logs"
      path += '?all=true' if all
      make_request('GET', path, pk, sk)
    end

    # Get service environment vault status
    #
    # @param service_id [String] Service ID to get env status for
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with vault status (has_vault, count, updated_at)
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     status = Un.get_service_env(service_id)
    #     puts "Variables: #{status['count']}"
    def get_service_env(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('GET', "/services/#{service_id}/env", pk, sk)
    end

    # Set service environment variables (replaces existing vault)
    #
    # @param service_id [String] Service ID to set env for
    # @param env [String] Environment content in .env format (KEY=VALUE per line)
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.set_service_env(service_id, "API_KEY=secret\nDEBUG=true")
    def set_service_env(service_id, env, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      # This endpoint uses text/plain content type and PUT method
      make_request_text('PUT', "/services/#{service_id}/env", pk, sk, env)
    end

    # Delete service environment vault
    #
    # @param service_id [String] Service ID to delete env for
    # @param keys [Array<String>, nil] Specific keys to delete (nil = delete entire vault)
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with deletion confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example Delete entire vault
    #     Un.delete_service_env(service_id)
    #
    # @example Delete specific keys (if API supports it)
    #     Un.delete_service_env(service_id, keys: ["API_KEY", "DEBUG"])
    def delete_service_env(service_id, keys: nil, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      if keys
        make_request('DELETE', "/services/#{service_id}/env", pk, sk, { keys: keys })
      else
        make_request('DELETE', "/services/#{service_id}/env", pk, sk)
      end
    end

    # Export service environment vault (returns .env format)
    #
    # @param service_id [String] Service ID to export env from
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with env content
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.export_service_env(service_id)
    #     puts result["env"]  # API_KEY=secret\nDEBUG=true
    def export_service_env(service_id, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('POST', "/services/#{service_id}/env/export", pk, sk, {})
    end

    # Redeploy a service (re-run bootstrap script)
    #
    # @param service_id [String] Service ID to redeploy
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param bootstrap [String, nil] New bootstrap script (optional)
    # @return [Hash] Response hash with redeploy confirmation
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.redeploy_service(service_id)
    def redeploy_service(service_id, public_key: nil, secret_key: nil, bootstrap: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      data = {}
      data[:bootstrap] = bootstrap if bootstrap
      make_request('POST', "/services/#{service_id}/redeploy", pk, sk, data)
    end

    # Execute a command in a running service
    #
    # @param service_id [String] Service ID to execute command in
    # @param command [String] Command to execute
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @param timeout [Integer] Command timeout in milliseconds (default: 30000)
    # @return [Hash] Response hash with command output (stdout, stderr, exit_code)
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.execute_in_service(service_id, "ls -la")
    #     puts result["stdout"]
    def execute_in_service(service_id, command, public_key: nil, secret_key: nil, timeout: 30_000)
      pk, sk = resolve_credentials(public_key, secret_key)

      # Start async execution
      response = make_request('POST', "/services/#{service_id}/execute", pk, sk, {
                                command: command,
                                timeout: timeout
                              })

      job_id = response['job_id']
      return response unless job_id

      # Poll for completion
      wait_for_job(job_id, public_key: pk, secret_key: sk, timeout: (timeout / 1000) + 10)
    end

    # Resize a service's vCPU allocation
    #
    # @param service_id [String] Service ID to resize
    # @param vcpu [Integer] Number of vCPUs (1-8 typically)
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with updated service info
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.resize_service(service_id, 4)
    def resize_service(service_id, vcpu, public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      make_request('PATCH', "/services/#{service_id}", pk, sk, { vcpu: vcpu })
    end

    # ============================================================================
    # Key Validation
    # ============================================================================

    # Validate API keys
    #
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Response hash with validation result and account info
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails or keys invalid
    #
    # @example
    #     result = Un.validate_keys
    #     puts result["valid"]  # true or false
    def validate_keys(public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      # Note: This endpoint is on the portal, not API, but we use same auth
      make_request('POST', '/keys/validate', pk, sk, {})
    end

    # Generate images from text prompt using AI.
    #
    # @param prompt [String] Text description of the image to generate
    # @param model [String, nil] Model to use (optional)
    # @param size [String] Image size (default: "1024x1024")
    # @param quality [String] "standard" or "hd" (default: "standard")
    # @param n [Integer] Number of images to generate (default: 1)
    # @param public_key [String, nil] API public key
    # @param secret_key [String, nil] API secret key
    # @return [Hash] Result with :images array and :created_at
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     result = Un.image("A sunset over mountains")
    #     puts result["images"]  # Array of image data/URLs
    def image(prompt, model: nil, size: '1024x1024', quality: 'standard', n: 1,
              public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      payload = {
        prompt: prompt,
        size: size,
        quality: quality,
        n: n
      }
      payload[:model] = model if model

      make_request('POST', '/image', pk, sk, payload)
    end

    # ============================================================================
    # PaaS Logs Functions
    # ============================================================================

    # Fetch batch logs from the PaaS platform
    #
    # @param source [String] Log source - "all", "api", "portal", "pool/cammy", "pool/ai"
    # @param lines [Integer] Number of lines to fetch (1-10000)
    # @param since [String] Time window - "1m", "5m", "1h", "1d"
    # @param grep [String, nil] Optional filter pattern
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @return [Hash] Log entries
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     logs = Un.logs_fetch(source: 'api', lines: 50, since: '5m')
    def logs_fetch(source: 'all', lines: 100, since: '5m', grep: nil,
                   public_key: nil, secret_key: nil)
      pk, sk = resolve_credentials(public_key, secret_key)
      path = "/logs?source=#{source}&lines=#{lines}&since=#{since}"
      path += "&grep=#{CGI.escape(grep)}" if grep
      make_request('GET', path, pk, sk)
    end

    # Stream logs via Server-Sent Events
    #
    # Blocks until interrupted or server closes connection.
    #
    # @param source [String] Log source - "all", "api", "portal", "pool/cammy", "pool/ai"
    # @param grep [String, nil] Optional filter pattern
    # @param public_key [String, nil] Optional API key
    # @param secret_key [String, nil] Optional API secret
    # @yield [source, line] Called for each log line received
    # @raise [CredentialsError] If no credentials found
    # @raise [APIError] If API request fails
    #
    # @example
    #     Un.logs_stream(source: 'api') do |src, line|
    #       puts "[#{src}] #{line}"
    #     end
    def logs_stream(source: 'all', grep: nil, public_key: nil, secret_key: nil, &block)
      pk, sk = resolve_credentials(public_key, secret_key)
      path = "/logs/stream?source=#{source}"
      path += "&grep=#{CGI.escape(grep)}" if grep

      uri = URI("#{API_BASE}#{path}")
      timestamp = Time.now.to_i
      signature = sign_request(sk, timestamp, 'GET', path)

      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      http.read_timeout = nil

      request = Net::HTTP::Get.new(uri)
      request['Authorization'] = "Bearer #{pk}"
      request['X-Timestamp'] = timestamp.to_s
      request['X-Signature'] = signature
      request['Accept'] = 'text/event-stream'

      http.request(request) do |response|
        response.read_body do |chunk|
          chunk.split("\n").each do |line|
            next unless line.start_with?('data: ')

            data = line[6..]
            begin
              entry = JSON.parse(data)
              if block_given?
                yield entry['source'] || source, entry['line'] || data
              else
                puts "[#{entry['source'] || source}] #{entry['line'] || data}"
              end
            rescue JSON::ParserError
              if block_given?
                yield source, data
              else
                puts "[#{source}] #{data}"
              end
            end
          end
        end
      end
    end

    # ============================================================================
    # Utility Functions
    # ============================================================================

    SDK_VERSION = '4.2.0'

    # Thread-local storage for last error
    @last_error = nil

    class << self
      attr_accessor :last_error_value
    end

    # Get the SDK version string
    #
    # @return [String] Version string (e.g., "4.2.0")
    #
    # @example
    #     puts Un.version  # "4.2.0"
    def version
      SDK_VERSION
    end

    # Check if the API is healthy and responding
    #
    # @return [Boolean] true if API is healthy, false otherwise
    #
    # @example
    #     if Un.health_check
    #       puts "API is healthy"
    #     end
    def health_check
      uri = URI("#{API_BASE}/health")
      response = Net::HTTP.get_response(uri)
      response.code == '200'
    rescue StandardError => e
      Un.last_error_value = "Health check failed: #{e.message}"
      false
    end

    # Get the last error message
    #
    # @return [String, nil] Last error message or nil
    #
    # @example
    #     result = Un.health_check
    #     puts Un.last_error unless result
    def last_error
      Un.last_error_value
    end

    # Sign a message using HMAC-SHA256
    #
    # This is the underlying signing function used for request authentication.
    # Exposed for testing and debugging purposes.
    #
    # @param secret_key [String] The secret key for signing
    # @param message [String] The message to sign
    # @return [String] 64-character lowercase hex string
    #
    # @example
    #     signature = Un.hmac_sign("secret", "message")
    def hmac_sign(secret_key, message)
      OpenSSL::HMAC.hexdigest('SHA256', secret_key, message)
    end

    private

    # Language detection mapping (file extension -> language)
    LANGUAGE_MAP = {
      'py' => 'python',
      'js' => 'javascript',
      'ts' => 'typescript',
      'rb' => 'ruby',
      'php' => 'php',
      'pl' => 'perl',
      'sh' => 'bash',
      'r' => 'r',
      'lua' => 'lua',
      'go' => 'go',
      'rs' => 'rust',
      'c' => 'c',
      'cpp' => 'cpp',
      'cc' => 'cpp',
      'cxx' => 'cpp',
      'java' => 'java',
      'kt' => 'kotlin',
      'm' => 'objc',
      'cs' => 'csharp',
      'fs' => 'fsharp',
      'hs' => 'haskell',
      'ml' => 'ocaml',
      'clj' => 'clojure',
      'scm' => 'scheme',
      'ss' => 'scheme',
      'erl' => 'erlang',
      'ex' => 'elixir',
      'exs' => 'elixir',
      'jl' => 'julia',
      'd' => 'd',
      'nim' => 'nim',
      'zig' => 'zig',
      'v' => 'v',
      'cr' => 'crystal',
      'dart' => 'dart',
      'groovy' => 'groovy',
      'f90' => 'fortran',
      'f95' => 'fortran',
      'lisp' => 'commonlisp',
      'lsp' => 'commonlisp',
      'cob' => 'cobol',
      'tcl' => 'tcl',
      'raku' => 'raku',
      'pro' => 'prolog',
      'p' => 'prolog',
      '4th' => 'forth',
      'forth' => 'forth',
      'fth' => 'forth'
    }.freeze

    # Get ~/.unsandbox directory path, creating if necessary
    #
    # @return [String] Path to unsandbox config directory
    def unsandbox_dir
      dir = File.join(Dir.home, '.unsandbox')
      FileUtils.mkdir_p(dir, mode: 0o700) unless Dir.exist?(dir)
      dir
    end

    # Load credentials from CSV file (public_key,secret_key per line)
    #
    # @param csv_path [String] Path to CSV file
    # @param account_index [Integer] Account index (0-based)
    # @return [Array<String>, nil] [public_key, secret_key] or nil if not found
    def load_credentials_from_csv(csv_path, account_index = 0)
      return nil unless File.exist?(csv_path)

      current_index = 0
      File.foreach(csv_path) do |line|
        line = line.strip
        next if line.empty? || line.start_with?('#')

        if current_index == account_index
          parts = line.split(',')
          return [parts[0].strip, parts[1].strip] if parts.length >= 2
        end
        current_index += 1
      end

      nil
    rescue StandardError
      nil
    end

    # Resolve credentials from 4-tier priority system
    #
    # Priority:
    #   1. Method arguments
    #   2. Environment variables
    #   3. ~/.unsandbox/accounts.csv
    #   4. ./accounts.csv
    #
    # @param public_key [String, nil] Explicit public key
    # @param secret_key [String, nil] Explicit secret key
    # @param account_index [Integer, nil] Account index for CSV files
    # @return [Array<String>] [public_key, secret_key]
    # @raise [CredentialsError] If no credentials found
    def resolve_credentials(public_key = nil, secret_key = nil, account_index = nil)
      # Tier 1: Method arguments
      return [public_key, secret_key] if public_key && secret_key

      # Tier 2: Environment variables
      env_pk = ENV['UNSANDBOX_PUBLIC_KEY']
      env_sk = ENV['UNSANDBOX_SECRET_KEY']
      return [env_pk, env_sk] if env_pk && env_sk

      # Determine account index
      account_index ||= ENV.fetch('UNSANDBOX_ACCOUNT', '0').to_i

      # Tier 3: ~/.unsandbox/accounts.csv
      creds = load_credentials_from_csv(File.join(unsandbox_dir, 'accounts.csv'), account_index)
      return creds if creds

      # Tier 4: ./accounts.csv
      creds = load_credentials_from_csv('accounts.csv', account_index)
      return creds if creds

      raise CredentialsError, <<~MSG
        No credentials found. Please provide via:
          1. Method arguments (public_key:, secret_key:)
          2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
          3. ~/.unsandbox/accounts.csv
          4. ./accounts.csv
      MSG
    end

    # Sign a request using HMAC-SHA256
    #
    # Message format: "timestamp:METHOD:path:body"
    #
    # @param secret_key [String] Secret key for signing
    # @param timestamp [Integer] Unix timestamp
    # @param method [String] HTTP method (GET, POST, DELETE)
    # @param path [String] API path
    # @param body [String, nil] Request body (JSON string)
    # @return [String] 64-character lowercase hex signature
    def sign_request(secret_key, timestamp, method, path, body = nil)
      body_str = body || ''
      message = "#{timestamp}:#{method}:#{path}:#{body_str}"
      OpenSSL::HMAC.hexdigest('SHA256', secret_key, message)
    end

    # Make an authenticated HTTP request to the API
    #
    # @param method [String] HTTP method (GET, POST, DELETE)
    # @param path [String] API path
    # @param public_key [String] API public key
    # @param secret_key [String] API secret key
    # @param data [Hash, nil] Request body data
    # @return [Hash] Parsed JSON response
    # @raise [APIError] If request fails
    def make_request(method, path, public_key, secret_key, data = nil)
      uri = URI.parse("#{API_BASE}#{path}")
      timestamp = Time.now.to_i
      body = data ? JSON.generate(data) : ''

      signature = sign_request(secret_key, timestamp, method, path, data ? body : nil)

      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      http.open_timeout = REQUEST_TIMEOUT
      http.read_timeout = REQUEST_TIMEOUT

      headers = {
        'Authorization' => "Bearer #{public_key}",
        'X-Timestamp' => timestamp.to_s,
        'X-Signature' => signature,
        'Content-Type' => 'application/json'
      }

      response = case method
                 when 'GET'
                   http.get(uri.request_uri, headers)
                 when 'POST'
                   http.post(uri.request_uri, body, headers)
                 when 'PATCH'
                   http.patch(uri.request_uri, body, headers)
                 when 'PUT'
                   http.put(uri.request_uri, body, headers)
                 when 'DELETE'
                   req = Net::HTTP::Delete.new(uri.request_uri, headers)
                   req.body = body if data
                   http.request(req)
                 else
                   raise APIError, "Unsupported HTTP method: #{method}"
                 end

      unless response.is_a?(Net::HTTPSuccess)
        raise APIError.new(
          "API request failed: #{response.code} #{response.message}",
          status_code: response.code.to_i,
          response_body: response.body
        )
      end

      JSON.parse(response.body)
    rescue JSON::ParserError => e
      raise APIError, "Invalid JSON response: #{e.message}"
    rescue Net::OpenTimeout, Net::ReadTimeout => e
      raise APIError, "Request timeout: #{e.message}"
    rescue StandardError => e
      raise APIError, "Request failed: #{e.message}" unless e.is_a?(APIError)

      raise
    end

    # Make an authenticated HTTP request with sudo OTP challenge handling.
    #
    # If the server returns 428 (Precondition Required), prompts for OTP
    # and retries the request with X-Sudo-OTP and X-Sudo-Challenge headers.
    #
    # Used for destructive operations: service destroy/unlock, snapshot delete/unlock,
    # image delete/unlock.
    #
    # @param method [String] HTTP method (GET, POST, DELETE)
    # @param path [String] API path
    # @param public_key [String] API public key
    # @param secret_key [String] API secret key
    # @param data [Hash, nil] Request body data
    # @return [Hash] Parsed JSON response
    # @raise [APIError] If request fails
    def make_request_with_sudo(method, path, public_key, secret_key, data = nil)
      uri = URI.parse("#{API_BASE}#{path}")
      timestamp = Time.now.to_i
      body = data ? JSON.generate(data) : ''

      signature = sign_request(secret_key, timestamp, method, path, data ? body : nil)

      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      http.open_timeout = REQUEST_TIMEOUT
      http.read_timeout = REQUEST_TIMEOUT

      headers = {
        'Authorization' => "Bearer #{public_key}",
        'X-Timestamp' => timestamp.to_s,
        'X-Signature' => signature,
        'Content-Type' => 'application/json'
      }

      response = case method
                 when 'GET'
                   http.get(uri.request_uri, headers)
                 when 'POST'
                   http.post(uri.request_uri, body, headers)
                 when 'PATCH'
                   http.patch(uri.request_uri, body, headers)
                 when 'PUT'
                   http.put(uri.request_uri, body, headers)
                 when 'DELETE'
                   req = Net::HTTP::Delete.new(uri.request_uri, headers)
                   req.body = body if data
                   http.request(req)
                 else
                   raise APIError, "Unsupported HTTP method: #{method}"
                 end

      # Handle 428 sudo OTP challenge
      if response.code.to_i == 428
        challenge_id = ''
        begin
          challenge_data = JSON.parse(response.body)
          challenge_id = challenge_data['challenge_id'] || ''
        rescue JSON::ParserError
          # Ignore JSON parse errors
        end

        $stderr.puts "\e[33mConfirmation required. Check your email for a one-time code.\e[0m"
        $stderr.print 'Enter OTP: '
        otp = $stdin.gets&.strip

        raise APIError, 'Operation cancelled' if otp.nil? || otp.empty?

        # Retry with sudo headers
        retry_timestamp = Time.now.to_i
        retry_signature = sign_request(secret_key, retry_timestamp, method, path, data ? body : nil)

        retry_headers = {
          'Authorization' => "Bearer #{public_key}",
          'X-Timestamp' => retry_timestamp.to_s,
          'X-Signature' => retry_signature,
          'Content-Type' => 'application/json',
          'X-Sudo-OTP' => otp,
          'X-Sudo-Challenge' => challenge_id
        }

        response = case method
                   when 'GET'
                     http.get(uri.request_uri, retry_headers)
                   when 'POST'
                     http.post(uri.request_uri, body, retry_headers)
                   when 'PATCH'
                     http.patch(uri.request_uri, body, retry_headers)
                   when 'PUT'
                     http.put(uri.request_uri, body, retry_headers)
                   when 'DELETE'
                     req = Net::HTTP::Delete.new(uri.request_uri, retry_headers)
                     req.body = body if data
                     http.request(req)
                   else
                     raise APIError, "Unsupported HTTP method: #{method}"
                   end
      end

      unless response.is_a?(Net::HTTPSuccess)
        raise APIError.new(
          "API request failed: #{response.code} #{response.message}",
          status_code: response.code.to_i,
          response_body: response.body
        )
      end

      JSON.parse(response.body)
    rescue JSON::ParserError => e
      raise APIError, "Invalid JSON response: #{e.message}"
    rescue Net::OpenTimeout, Net::ReadTimeout => e
      raise APIError, "Request timeout: #{e.message}"
    rescue StandardError => e
      raise APIError, "Request failed: #{e.message}" unless e.is_a?(APIError)

      raise
    end

    # Make an authenticated HTTP request with text/plain content type
    #
    # @param method [String] HTTP method (PUT)
    # @param path [String] API path
    # @param public_key [String] API public key
    # @param secret_key [String] API secret key
    # @param body [String] Plain text request body
    # @return [Hash] Parsed JSON response
    # @raise [APIError] If request fails
    def make_request_text(method, path, public_key, secret_key, body)
      uri = URI.parse("#{API_BASE}#{path}")
      timestamp = Time.now.to_i

      signature = sign_request(secret_key, timestamp, method, path, body)

      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      http.open_timeout = REQUEST_TIMEOUT
      http.read_timeout = REQUEST_TIMEOUT

      headers = {
        'Authorization' => "Bearer #{public_key}",
        'X-Timestamp' => timestamp.to_s,
        'X-Signature' => signature,
        'Content-Type' => 'text/plain'
      }

      response = case method
                 when 'PUT'
                   http.put(uri.request_uri, body, headers)
                 else
                   raise APIError, "Unsupported HTTP method for text: #{method}"
                 end

      unless response.is_a?(Net::HTTPSuccess)
        raise APIError.new(
          "API request failed: #{response.code} #{response.message}",
          status_code: response.code.to_i,
          response_body: response.body
        )
      end

      JSON.parse(response.body)
    rescue JSON::ParserError => e
      raise APIError, "Invalid JSON response: #{e.message}"
    rescue Net::OpenTimeout, Net::ReadTimeout => e
      raise APIError, "Request timeout: #{e.message}"
    rescue StandardError => e
      raise APIError, "Request failed: #{e.message}" unless e.is_a?(APIError)

      raise
    end

    # Get path to languages cache file
    #
    # @return [String] Path to languages.json
    def languages_cache_path
      File.join(unsandbox_dir, 'languages.json')
    end

    # Load languages from cache if valid (< 1 hour old)
    #
    # @return [Array<String>, nil] Cached languages or nil if cache invalid/missing
    def load_languages_cache
      cache_path = languages_cache_path
      return nil unless File.exist?(cache_path)

      # Check if cache is fresh
      age_seconds = Time.now - File.mtime(cache_path)
      return nil if age_seconds >= LANGUAGES_CACHE_TTL

      data = JSON.parse(File.read(cache_path))
      data['languages']
    rescue StandardError
      nil
    end

    # Save languages to cache
    #
    # @param languages [Array<String>] Languages to cache
    # @return [void]
    def save_languages_cache(languages)
      cache_path = languages_cache_path
      File.write(cache_path, JSON.generate({
                                             languages: languages,
                                             timestamp: Time.now.to_i
                                           }))
    rescue StandardError
      # Cache failures are non-fatal
      nil
    end
  end

  # ============================================================================
  # CLI Implementation
  # ============================================================================

  # Exit codes
  EXIT_SUCCESS = 0
  EXIT_ERROR = 1
  EXIT_INVALID_ARGS = 2
  EXIT_AUTH_ERROR = 3
  EXIT_API_ERROR = 4
  EXIT_TIMEOUT = 5

  class << self
    # Main CLI entry point
    def cli_main
      # Global options
      options = {
        shell: nil,
        env: [],
        files: [],
        file_paths: [],
        public_key: nil,
        secret_key: nil,
        network: 'zerotrust',
        vcpu: 1,
        yes: false,
        artifacts: false,
        output: nil
      }

      # Check for subcommands first
      if ARGV.empty?
        cli_show_help
        exit(EXIT_INVALID_ARGS)
      end

      case ARGV[0]
      when 'session'
        ARGV.shift
        cli_session(options)
      when 'service'
        ARGV.shift
        cli_service(options)
      when 'snapshot'
        ARGV.shift
        cli_snapshot(options)
      when 'image'
        ARGV.shift
        cli_image(options)
      when 'key'
        ARGV.shift
        cli_key(options)
      when 'languages'
        ARGV.shift
        cli_languages(options)
      when '-h', '--help', 'help'
        cli_show_help
        exit(EXIT_SUCCESS)
      else
        cli_execute(options)
      end
    rescue CredentialsError => e
      $stderr.puts "Error: #{e.message}"
      exit(EXIT_AUTH_ERROR)
    rescue APIError => e
      $stderr.puts "Error: #{e.message}"
      exit(EXIT_API_ERROR)
    rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
      $stderr.puts "Error: #{e.message}"
      exit(EXIT_INVALID_ARGS)
    rescue Interrupt
      $stderr.puts "\nInterrupted"
      exit(EXIT_ERROR)
    end

    private

    # Show main help
    def cli_show_help
      puts <<~HELP
        unsandbox.com Ruby SDK - Secure Code Execution

        Usage:
          ruby un.rb [options] <source_file>      Execute code file
          ruby un.rb [options] -s LANG 'code'     Execute inline code
          ruby un.rb session [options]            Manage sessions
          ruby un.rb service [options]            Manage services
          ruby un.rb snapshot [options]           Manage snapshots
          ruby un.rb key                          Check API key
          ruby un.rb languages [--json]           List available languages

        Global Options:
          -s, --shell LANG       Language for inline code
          -e, --env KEY=VAL      Set environment variable (can repeat)
          -f, --file FILE        Add input file to /tmp/
          -F, --file-path FILE   Add input file with path preserved
          -a, --artifacts        Return compiled artifacts
          -o, --output DIR       Output directory for artifacts
          -p, --public-key KEY   API public key
          -k, --secret-key KEY   API secret key
          -n, --network MODE     Network mode: zerotrust or semitrusted
          -v, --vcpu N           vCPU count (1-8)
          -y, --yes              Skip confirmation prompts
          -h, --help             Show this help

        Examples:
          ruby un.rb script.py
          ruby un.rb -s python 'print("hello")'
          ruby un.rb -n semitrusted crawler.py
          ruby un.rb session --list
          ruby un.rb service --name web --ports 80 --bootstrap "python -m http.server 80"
      HELP
    end

    # Parse global options from ARGV
    def parse_global_options(options)
      OptionParser.new do |opts|
        opts.on('-s', '--shell LANG', 'Language for inline code') do |v|
          options[:shell] = v
        end
        opts.on('-e', '--env KEY=VAL', 'Set environment variable') do |v|
          options[:env] << v
        end
        opts.on('-f', '--file FILE', 'Add input file to /tmp/') do |v|
          options[:files] << v
        end
        opts.on('-F', '--file-path FILE', 'Add input file with path preserved') do |v|
          options[:file_paths] << v
        end
        opts.on('-a', '--artifacts', 'Return compiled artifacts') do
          options[:artifacts] = true
        end
        opts.on('-o', '--output DIR', 'Output directory for artifacts') do |v|
          options[:output] = v
        end
        opts.on('-p', '--public-key KEY', 'API public key') do |v|
          options[:public_key] = v
        end
        opts.on('-k', '--secret-key KEY', 'API secret key') do |v|
          options[:secret_key] = v
        end
        opts.on('-n', '--network MODE', 'Network mode') do |v|
          options[:network] = v
        end
        opts.on('-v', '--vcpu N', Integer, 'vCPU count (1-8)') do |v|
          options[:vcpu] = v
        end
        opts.on('-y', '--yes', 'Skip confirmation prompts') do
          options[:yes] = true
        end
        opts.on('-h', '--help', 'Show help') do
          yield if block_given?
          exit(EXIT_SUCCESS)
        end
      end
    end

    # Execute code command
    def cli_execute(options)
      parser = parse_global_options(options) do
        cli_show_help
      end
      parser.parse!(ARGV)

      if ARGV.empty?
        $stderr.puts 'Error: No source file or code provided'
        exit(EXIT_INVALID_ARGS)
      end

      code = nil
      language = nil

      if options[:shell]
        # Inline code mode: -s LANG 'code'
        language = options[:shell]
        code = ARGV.join(' ')
      else
        # File mode: script.py
        filename = ARGV[0]
        unless File.exist?(filename)
          $stderr.puts "Error: File not found: #{filename}"
          exit(EXIT_INVALID_ARGS)
        end
        code = File.read(filename)
        language = detect_language(filename)
        unless language
          $stderr.puts "Error: Cannot detect language for: #{filename}"
          $stderr.puts 'Use -s/--shell to specify language'
          exit(EXIT_INVALID_ARGS)
        end
      end

      # Execute the code
      result = execute_code(
        language,
        code,
        public_key: options[:public_key],
        secret_key: options[:secret_key]
      )

      # Output results
      cli_print_execute_result(result)
    end

    # Print execution result
    def cli_print_execute_result(result)
      puts result['stdout'] if result['stdout'] && !result['stdout'].empty?
      $stderr.puts result['stderr'] if result['stderr'] && !result['stderr'].empty?
      puts '---'
      puts "Exit code: #{result['exit_code'] || 0}"
      if result['execution_time_ms']
        puts "Execution time: #{result['execution_time_ms']}ms"
      end
    end

    # Session subcommand
    def cli_session(options)
      session_opts = {
        list: false,
        attach: nil,
        kill: nil,
        freeze: nil,
        unfreeze: nil,
        boost: nil,
        unboost: nil,
        snapshot: nil,
        snapshot_name: nil,
        hot: false,
        tmux: false,
        screen: false,
        shell: 'bash',
        audit: false
      }

      parser = parse_global_options(options) do
        cli_session_help
      end

      parser.on('-l', '--list', 'List active sessions') do
        session_opts[:list] = true
      end
      parser.on('--attach ID', 'Reconnect to existing session') do |v|
        session_opts[:attach] = v
      end
      parser.on('--kill ID', 'Terminate a session') do |v|
        session_opts[:kill] = v
      end
      parser.on('--freeze ID', 'Pause session') do |v|
        session_opts[:freeze] = v
      end
      parser.on('--unfreeze ID', 'Resume session') do |v|
        session_opts[:unfreeze] = v
      end
      parser.on('--boost ID', 'Add vCPUs/RAM') do |v|
        session_opts[:boost] = v
      end
      parser.on('--unboost ID', 'Remove boost') do |v|
        session_opts[:unboost] = v
      end
      parser.on('--snapshot ID', 'Create snapshot') do |v|
        session_opts[:snapshot] = v
      end
      parser.on('--snapshot-name NAME', 'Name for snapshot') do |v|
        session_opts[:snapshot_name] = v
      end
      parser.on('--hot', 'Live snapshot (no freeze)') do
        session_opts[:hot] = true
      end
      parser.on('--tmux', 'Enable persistence with tmux') do
        session_opts[:tmux] = true
      end
      parser.on('--screen', 'Enable persistence with screen') do
        session_opts[:screen] = true
      end
      parser.on('--shell SHELL', 'Shell/REPL to use') do |v|
        session_opts[:shell] = v
      end
      parser.on('--audit', 'Record session') do
        session_opts[:audit] = true
      end

      parser.parse!(ARGV)

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }

      if session_opts[:list]
        sessions = list_sessions(**creds)
        cli_print_sessions_table(sessions)
      elsif session_opts[:kill]
        result = delete_session(session_opts[:kill], **creds)
        puts "Session #{session_opts[:kill]} terminated"
      elsif session_opts[:freeze]
        result = freeze_session(session_opts[:freeze], **creds)
        puts "Session #{session_opts[:freeze]} frozen"
      elsif session_opts[:unfreeze]
        result = unfreeze_session(session_opts[:unfreeze], **creds)
        puts "Session #{session_opts[:unfreeze]} unfrozen"
      elsif session_opts[:boost]
        result = boost_session(session_opts[:boost], **creds)
        puts "Session #{session_opts[:boost]} boosted"
      elsif session_opts[:unboost]
        result = unboost_session(session_opts[:unboost], **creds)
        puts "Session #{session_opts[:unboost]} unboosted"
      elsif session_opts[:snapshot]
        snapshot_id = session_snapshot(
          session_opts[:snapshot],
          name: session_opts[:snapshot_name],
          ephemeral: session_opts[:hot],
          **creds
        )
        puts "Snapshot created: #{snapshot_id}"
      elsif session_opts[:attach]
        # Attach to existing session - show info
        session = get_session(session_opts[:attach], **creds)
        puts "Session: #{session['id']}"
        puts "Status: #{session['status']}"
        puts "WebSocket URL: #{session['websocket_url']}" if session['websocket_url']
        puts "\nNote: Use a WebSocket client to connect interactively"
      else
        # Create new session
        multiplexer = nil
        multiplexer = 'tmux' if session_opts[:tmux]
        multiplexer = 'screen' if session_opts[:screen]

        result = create_session(
          session_opts[:shell],
          network_mode: options[:network],
          vcpu: options[:vcpu],
          multiplexer: multiplexer,
          **creds
        )
        puts "Session created: #{result['session_id']}"
        puts "Container: #{result['container_name']}" if result['container_name']
        puts "WebSocket URL: #{result['websocket_url']}" if result['websocket_url']
        puts "\nNote: Use a WebSocket client to connect interactively"
      end
    end

    # Print sessions table
    def cli_print_sessions_table(sessions)
      if sessions.empty?
        puts 'No active sessions'
        return
      end

      # Header
      puts format('%-38s %-20s %-10s %-20s', 'ID', 'NAME', 'STATUS', 'CREATED')
      sessions.each do |s|
        puts format('%-38s %-20s %-10s %-20s',
                    s['id'] || s['session_id'] || '-',
                    s['name'] || '-',
                    s['status'] || s['state'] || '-',
                    s['created_at'] || '-')
      end
    end

    # Session help
    def cli_session_help
      puts <<~HELP
        Session Management

        Usage:
          ruby un.rb session [options]

        Options:
          --shell SHELL          Shell/REPL to use (default: bash)
          -l, --list             List active sessions
          --attach ID            Reconnect to existing session
          --kill ID              Terminate a session
          --freeze ID            Pause session
          --unfreeze ID          Resume session
          --boost ID             Add vCPUs/RAM
          --unboost ID           Remove boost
          --tmux                 Enable persistence with tmux
          --screen               Enable persistence with screen
          --snapshot ID          Create snapshot
          --snapshot-name NAME   Name for snapshot
          --hot                  Live snapshot (no freeze)
          --audit                Record session

        Examples:
          ruby un.rb session                          # New bash session
          ruby un.rb session --shell python3          # Python REPL
          ruby un.rb session --tmux                   # Persistent session
          ruby un.rb session --list                   # List sessions
          ruby un.rb session --kill abc123            # Kill session
      HELP
    end

    # Service subcommand
    def cli_service(options)
      service_opts = {
        list: false,
        name: nil,
        ports: nil,
        domains: nil,
        type: nil,
        bootstrap: nil,
        bootstrap_file: nil,
        env_file: nil,
        info: nil,
        logs: nil,
        tail: nil,
        freeze: nil,
        unfreeze: nil,
        destroy: nil,
        lock: nil,
        unlock: nil,
        resize: nil,
        redeploy: nil,
        execute: nil,
        execute_cmd: nil,
        snapshot: nil,
        snapshot_name: nil
      }

      parser = parse_global_options(options) do
        cli_service_help
      end

      parser.on('-l', '--list', 'List all services') do
        service_opts[:list] = true
      end
      parser.on('--name NAME', 'Service name (creates new)') do |v|
        service_opts[:name] = v
      end
      parser.on('--ports PORTS', 'Comma-separated ports') do |v|
        service_opts[:ports] = v.split(',').map(&:to_i)
      end
      parser.on('--domains DOMAINS', 'Custom domains') do |v|
        service_opts[:domains] = v.split(',')
      end
      parser.on('--type TYPE', 'Service type (minecraft, tcp, udp)') do |v|
        service_opts[:type] = v
      end
      parser.on('--bootstrap CMD', 'Bootstrap command') do |v|
        service_opts[:bootstrap] = v
      end
      parser.on('--bootstrap-file FILE', 'Bootstrap from file') do |v|
        service_opts[:bootstrap_file] = v
      end
      parser.on('--env-file FILE', 'Load env from .env file') do |v|
        service_opts[:env_file] = v
      end
      parser.on('--info ID', 'Get service details') do |v|
        service_opts[:info] = v
      end
      parser.on('--logs ID', 'Get all logs') do |v|
        service_opts[:logs] = v
      end
      parser.on('--tail ID', 'Get last 9000 lines') do |v|
        service_opts[:tail] = v
      end
      parser.on('--freeze ID', 'Pause service') do |v|
        service_opts[:freeze] = v
      end
      parser.on('--unfreeze ID', 'Resume service') do |v|
        service_opts[:unfreeze] = v
      end
      parser.on('--destroy ID', 'Delete service') do |v|
        service_opts[:destroy] = v
      end
      parser.on('--lock ID', 'Prevent deletion') do |v|
        service_opts[:lock] = v
      end
      parser.on('--unlock ID', 'Allow deletion') do |v|
        service_opts[:unlock] = v
      end
      parser.on('--resize ID', 'Resize (with --vcpu)') do |v|
        service_opts[:resize] = v
      end
      parser.on('--redeploy ID', 'Re-run bootstrap') do |v|
        service_opts[:redeploy] = v
      end
      parser.on('--execute ID', 'Run command in service') do |v|
        service_opts[:execute] = v
      end
      parser.on('--snapshot ID', 'Create snapshot') do |v|
        service_opts[:snapshot] = v
      end
      parser.on('--snapshot-name NAME', 'Name for snapshot') do |v|
        service_opts[:snapshot_name] = v
      end

      parser.parse!(ARGV)

      # Check for env subcommand
      if ARGV[0] == 'env'
        ARGV.shift
        cli_service_env(options, service_opts)
        return
      end

      # Get command argument for execute
      service_opts[:execute_cmd] = ARGV.join(' ') if service_opts[:execute] && !ARGV.empty?

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }

      if service_opts[:list]
        services = list_services(**creds)
        cli_print_services_table(services)
      elsif service_opts[:info]
        service = get_service(service_opts[:info], **creds)
        cli_print_service_info(service)
      elsif service_opts[:logs]
        result = get_service_logs(service_opts[:logs], all: true, **creds)
        puts result['log'] || result['logs'] || ''
      elsif service_opts[:tail]
        result = get_service_logs(service_opts[:tail], all: false, **creds)
        puts result['log'] || result['logs'] || ''
      elsif service_opts[:freeze]
        freeze_service(service_opts[:freeze], **creds)
        puts "Service #{service_opts[:freeze]} frozen"
      elsif service_opts[:unfreeze]
        unfreeze_service(service_opts[:unfreeze], **creds)
        puts "Service #{service_opts[:unfreeze]} unfrozen"
      elsif service_opts[:destroy]
        delete_service(service_opts[:destroy], **creds)
        puts "Service #{service_opts[:destroy]} destroyed"
      elsif service_opts[:lock]
        lock_service(service_opts[:lock], **creds)
        puts "Service #{service_opts[:lock]} locked"
      elsif service_opts[:unlock]
        unlock_service(service_opts[:unlock], **creds)
        puts "Service #{service_opts[:unlock]} unlocked"
      elsif service_opts[:resize]
        update_service(service_opts[:resize], vcpu: options[:vcpu], **creds)
        puts "Service #{service_opts[:resize]} resized to #{options[:vcpu]} vCPUs"
      elsif service_opts[:redeploy]
        bootstrap = nil
        if service_opts[:bootstrap_file]
          bootstrap = File.read(service_opts[:bootstrap_file])
        elsif service_opts[:bootstrap]
          bootstrap = service_opts[:bootstrap]
        end
        redeploy_service(service_opts[:redeploy], bootstrap: bootstrap, **creds)
        puts "Service #{service_opts[:redeploy]} redeployed"
      elsif service_opts[:execute]
        cmd = service_opts[:execute_cmd]
        if cmd.nil? || cmd.empty?
          $stderr.puts 'Error: No command provided for --execute'
          exit(EXIT_INVALID_ARGS)
        end
        result = execute_in_service(service_opts[:execute], cmd, **creds)
        cli_print_execute_result(result)
      elsif service_opts[:snapshot]
        snapshot_id = service_snapshot(
          service_opts[:snapshot],
          name: service_opts[:snapshot_name],
          **creds
        )
        puts "Snapshot created: #{snapshot_id}"
      elsif service_opts[:name]
        # Create new service
        bootstrap = service_opts[:bootstrap]
        if service_opts[:bootstrap_file]
          bootstrap = File.read(service_opts[:bootstrap_file])
        end

        unless bootstrap
          $stderr.puts 'Error: --bootstrap or --bootstrap-file required'
          exit(EXIT_INVALID_ARGS)
        end
        unless service_opts[:ports]
          $stderr.puts 'Error: --ports required'
          exit(EXIT_INVALID_ARGS)
        end

        result = create_service(
          service_opts[:name],
          service_opts[:ports],
          bootstrap,
          network_mode: options[:network],
          vcpu: options[:vcpu],
          custom_domains: service_opts[:domains],
          service_type: service_opts[:type],
          **creds
        )
        puts "Service created: #{result['service_id']}"
        puts "URL: #{result['url']}" if result['url']
      else
        cli_service_help
        exit(EXIT_INVALID_ARGS)
      end
    end

    # Print services table
    def cli_print_services_table(services)
      if services.empty?
        puts 'No services'
        return
      end

      puts format('%-38s %-20s %-10s %-20s', 'ID', 'NAME', 'STATUS', 'CREATED')
      services.each do |s|
        puts format('%-38s %-20s %-10s %-20s',
                    s['id'] || s['service_id'] || '-',
                    s['name'] || '-',
                    s['status'] || s['state'] || '-',
                    s['created_at'] || '-')
      end
    end

    # Print service info
    def cli_print_service_info(service)
      puts "ID: #{service['id'] || service['service_id']}"
      puts "Name: #{service['name']}"
      puts "Status: #{service['status'] || service['state']}"
      puts "URL: #{service['url']}" if service['url']
      puts "Ports: #{service['ports']&.join(', ')}" if service['ports']
      puts "vCPU: #{service['vcpu']}" if service['vcpu']
      puts "Network: #{service['network_mode']}" if service['network_mode']
      puts "Created: #{service['created_at']}" if service['created_at']
      puts "Locked: #{service['locked']}" if service.key?('locked')
    end

    # Service help
    def cli_service_help
      puts <<~HELP
        Service Management

        Usage:
          ruby un.rb service [options]
          ruby un.rb service env <command> <id>

        Options:
          --name NAME            Service name (creates new)
          --ports PORTS          Comma-separated ports
          --domains DOMAINS      Custom domains
          --type TYPE            Service type (minecraft, tcp, udp)
          --bootstrap CMD        Bootstrap command
          --bootstrap-file FILE  Bootstrap from file
          --env-file FILE        Load env from .env file
          -l, --list             List all services
          --info ID              Get service details
          --logs ID              Get all logs
          --tail ID              Get last 9000 lines
          --freeze ID            Pause service
          --unfreeze ID          Resume service
          --destroy ID           Delete service
          --lock ID              Prevent deletion
          --unlock ID            Allow deletion
          --resize ID            Resize (with --vcpu)
          --redeploy ID          Re-run bootstrap
          --execute ID 'cmd'     Run command in service
          --snapshot ID          Create snapshot

        Env Subcommands:
          ruby un.rb service env status ID    Show vault status
          ruby un.rb service env set ID       Set from --env-file or stdin
          ruby un.rb service env export ID    Export to stdout
          ruby un.rb service env delete ID    Delete vault

        Examples:
          ruby un.rb service --name web --ports 80 --bootstrap "python -m http.server 80"
          ruby un.rb service --list
          ruby un.rb service --logs abc123
          ruby un.rb service --execute abc123 'ls -la'
          ruby un.rb service env status abc123
      HELP
    end

    # Service env subcommand
    def cli_service_env(options, service_opts)
      if ARGV.empty?
        $stderr.puts 'Error: env subcommand requires: status, set, export, or delete'
        exit(EXIT_INVALID_ARGS)
      end

      cmd = ARGV.shift
      service_id = ARGV.shift

      unless service_id
        $stderr.puts 'Error: Service ID required'
        exit(EXIT_INVALID_ARGS)
      end

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }

      case cmd
      when 'status'
        result = get_service_env(service_id, **creds)
        puts "Has vault: #{result['has_vault'] || false}"
        puts "Variables: #{result['count'] || 0}"
        puts "Updated: #{result['updated_at']}" if result['updated_at']
      when 'set'
        env_content = nil
        if service_opts[:env_file]
          env_content = File.read(service_opts[:env_file])
        elsif !$stdin.tty?
          env_content = $stdin.read
        else
          $stderr.puts 'Error: Provide --env-file or pipe content to stdin'
          exit(EXIT_INVALID_ARGS)
        end
        set_service_env(service_id, env_content, **creds)
        puts "Environment vault updated for #{service_id}"
      when 'export'
        result = export_service_env(service_id, **creds)
        puts result['env'] || ''
      when 'delete'
        delete_service_env(service_id, **creds)
        puts "Environment vault deleted for #{service_id}"
      else
        $stderr.puts "Error: Unknown env command: #{cmd}"
        exit(EXIT_INVALID_ARGS)
      end
    end

    # Snapshot subcommand
    def cli_snapshot(options)
      snapshot_opts = {
        list: false,
        info: nil,
        delete: nil,
        lock: nil,
        unlock: nil,
        clone: nil,
        clone_type: 'session',
        clone_name: nil,
        clone_shell: nil,
        clone_ports: nil
      }

      parser = parse_global_options(options) do
        cli_snapshot_help
      end

      parser.on('-l', '--list', 'List all snapshots') do
        snapshot_opts[:list] = true
      end
      parser.on('--info ID', 'Get snapshot details') do |v|
        snapshot_opts[:info] = v
      end
      parser.on('--delete ID', 'Delete snapshot') do |v|
        snapshot_opts[:delete] = v
      end
      parser.on('--lock ID', 'Prevent deletion') do |v|
        snapshot_opts[:lock] = v
      end
      parser.on('--unlock ID', 'Allow deletion') do |v|
        snapshot_opts[:unlock] = v
      end
      parser.on('--clone ID', 'Clone snapshot') do |v|
        snapshot_opts[:clone] = v
      end
      parser.on('--type TYPE', 'Clone type: session or service') do |v|
        snapshot_opts[:clone_type] = v
      end
      parser.on('--name NAME', 'Name for cloned resource') do |v|
        snapshot_opts[:clone_name] = v
      end
      parser.on('--shell SHELL', 'Shell for cloned session') do |v|
        snapshot_opts[:clone_shell] = v
      end
      parser.on('--ports PORTS', 'Ports for cloned service') do |v|
        snapshot_opts[:clone_ports] = v.split(',').map(&:to_i)
      end

      parser.parse!(ARGV)

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }

      if snapshot_opts[:list]
        snapshots = list_snapshots(**creds)
        cli_print_snapshots_table(snapshots)
      elsif snapshot_opts[:info]
        # Get snapshot details via restore endpoint or list
        snapshots = list_snapshots(**creds)
        snapshot = snapshots.find { |s| s['snapshot_id'] == snapshot_opts[:info] || s['id'] == snapshot_opts[:info] }
        if snapshot
          cli_print_snapshot_info(snapshot)
        else
          $stderr.puts "Error: Snapshot not found: #{snapshot_opts[:info]}"
          exit(EXIT_ERROR)
        end
      elsif snapshot_opts[:delete]
        delete_snapshot(snapshot_opts[:delete], **creds)
        puts "Snapshot #{snapshot_opts[:delete]} deleted"
      elsif snapshot_opts[:lock]
        lock_snapshot(snapshot_opts[:lock], **creds)
        puts "Snapshot #{snapshot_opts[:lock]} locked"
      elsif snapshot_opts[:unlock]
        unlock_snapshot(snapshot_opts[:unlock], **creds)
        puts "Snapshot #{snapshot_opts[:unlock]} unlocked"
      elsif snapshot_opts[:clone]
        result = clone_snapshot(
          snapshot_opts[:clone],
          type: snapshot_opts[:clone_type],
          name: snapshot_opts[:clone_name],
          shell: snapshot_opts[:clone_shell],
          ports: snapshot_opts[:clone_ports],
          **creds
        )
        if result['session_id']
          puts "Session created: #{result['session_id']}"
        elsif result['service_id']
          puts "Service created: #{result['service_id']}"
        else
          puts 'Clone completed'
          puts JSON.pretty_generate(result)
        end
      else
        cli_snapshot_help
        exit(EXIT_INVALID_ARGS)
      end
    end

    # Print snapshots table
    def cli_print_snapshots_table(snapshots)
      if snapshots.empty?
        puts 'No snapshots'
        return
      end

      puts format('%-38s %-20s %-10s %-20s', 'ID', 'NAME', 'TYPE', 'CREATED')
      snapshots.each do |s|
        puts format('%-38s %-20s %-10s %-20s',
                    s['snapshot_id'] || s['id'] || '-',
                    s['name'] || '-',
                    s['type'] || s['source_type'] || '-',
                    s['created_at'] || '-')
      end
    end

    # Print snapshot info
    def cli_print_snapshot_info(snapshot)
      puts "ID: #{snapshot['snapshot_id'] || snapshot['id']}"
      puts "Name: #{snapshot['name']}" if snapshot['name']
      puts "Type: #{snapshot['type'] || snapshot['source_type']}"
      puts "Source ID: #{snapshot['source_id']}" if snapshot['source_id']
      puts "Size: #{snapshot['size']}" if snapshot['size']
      puts "Locked: #{snapshot['locked']}" if snapshot.key?('locked')
      puts "Created: #{snapshot['created_at']}" if snapshot['created_at']
    end

    # Snapshot help
    def cli_snapshot_help
      puts <<~HELP
        Snapshot Management

        Usage:
          ruby un.rb snapshot [options]

        Options:
          -l, --list             List all snapshots
          --info ID              Get snapshot details
          --delete ID            Delete snapshot
          --lock ID              Prevent deletion
          --unlock ID            Allow deletion
          --clone ID             Clone snapshot
          --type TYPE            Clone type: session or service
          --name NAME            Name for cloned resource
          --shell SHELL          Shell for cloned session
          --ports PORTS          Ports for cloned service

        Examples:
          ruby un.rb snapshot --list
          ruby un.rb snapshot --clone abc123 --type service --name myapp --ports 80
      HELP
    end

    # Image subcommand
    def cli_image(options)
      image_opts = {
        list: false,
        info: nil,
        delete: nil,
        lock: nil,
        unlock: nil,
        publish: nil,
        source_type: nil,
        visibility: nil,
        visibility_mode: nil,
        spawn: nil,
        clone: nil,
        name: nil,
        ports: nil
      }

      parser = parse_global_options(options) do
        cli_image_help
      end

      parser.on('-l', '--list', 'List all images') do
        image_opts[:list] = true
      end
      parser.on('--info ID', 'Get image details') do |v|
        image_opts[:info] = v
      end
      parser.on('--delete ID', 'Delete image') do |v|
        image_opts[:delete] = v
      end
      parser.on('--lock ID', 'Prevent deletion') do |v|
        image_opts[:lock] = v
      end
      parser.on('--unlock ID', 'Allow deletion') do |v|
        image_opts[:unlock] = v
      end
      parser.on('--publish ID', 'Publish image from service/snapshot') do |v|
        image_opts[:publish] = v
      end
      parser.on('--source-type TYPE', 'Source type: service or snapshot') do |v|
        image_opts[:source_type] = v
      end
      parser.on('--visibility ID MODE', 'Set visibility (private, unlisted, public)') do |v|
        image_opts[:visibility] = v
      end
      parser.on('--spawn ID', 'Spawn new service from image') do |v|
        image_opts[:spawn] = v
      end
      parser.on('--clone ID', 'Clone an image') do |v|
        image_opts[:clone] = v
      end
      parser.on('--name NAME', 'Name for spawned service or cloned image') do |v|
        image_opts[:name] = v
      end
      parser.on('--ports PORTS', 'Ports for spawned service') do |v|
        image_opts[:ports] = v.split(',').map(&:to_i)
      end

      parser.parse!(ARGV)

      # Get visibility mode from remaining args if --visibility was used
      if image_opts[:visibility] && ARGV.length > 0 && !ARGV[0].start_with?('-')
        image_opts[:visibility_mode] = ARGV.shift
      end

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }

      if image_opts[:list]
        images = list_images(**creds)
        cli_print_images_table(images)
      elsif image_opts[:info]
        image = get_image(image_opts[:info], **creds)
        cli_print_image_info(image)
      elsif image_opts[:delete]
        delete_image(image_opts[:delete], **creds)
        puts "Image #{image_opts[:delete]} deleted"
      elsif image_opts[:lock]
        lock_image(image_opts[:lock], **creds)
        puts "Image #{image_opts[:lock]} locked"
      elsif image_opts[:unlock]
        unlock_image(image_opts[:unlock], **creds)
        puts "Image #{image_opts[:unlock]} unlocked"
      elsif image_opts[:publish]
        unless image_opts[:source_type]
          $stderr.puts 'Error: --source-type required for --publish'
          exit(EXIT_INVALID_ARGS)
        end
        result = image_publish(
          image_opts[:source_type],
          image_opts[:publish],
          name: image_opts[:name],
          **creds
        )
        image_id = result['image_id'] || result['id']
        puts "Image published: #{image_id}"
      elsif image_opts[:visibility] && image_opts[:visibility_mode]
        unless %w[private unlisted public].include?(image_opts[:visibility_mode])
          $stderr.puts 'Error: visibility must be private, unlisted, or public'
          exit(EXIT_INVALID_ARGS)
        end
        set_image_visibility(image_opts[:visibility], image_opts[:visibility_mode], **creds)
        puts "Image #{image_opts[:visibility]} visibility set to #{image_opts[:visibility_mode]}"
      elsif image_opts[:spawn]
        unless image_opts[:name]
          $stderr.puts 'Error: --name required for --spawn'
          exit(EXIT_INVALID_ARGS)
        end
        result = spawn_from_image(
          image_opts[:spawn],
          name: image_opts[:name],
          ports: image_opts[:ports],
          **creds
        )
        service_id = result['service_id'] || result['id']
        puts "Service spawned: #{service_id}"
      elsif image_opts[:clone]
        result = clone_image(
          image_opts[:clone],
          name: image_opts[:name],
          **creds
        )
        image_id = result['image_id'] || result['id']
        puts "Image cloned: #{image_id}"
      else
        cli_image_help
        exit(EXIT_INVALID_ARGS)
      end
    end

    # Print images table
    def cli_print_images_table(images)
      if images.empty?
        puts 'No images'
        return
      end

      puts format('%-38s %-20s %-10s %-10s %-20s', 'ID', 'NAME', 'VISIBILITY', 'SOURCE', 'CREATED')
      images.each do |img|
        puts format('%-38s %-20s %-10s %-10s %-20s',
                    img['image_id'] || img['id'] || '-',
                    (img['name'] || '-')[0..19],
                    img['visibility'] || 'private',
                    (img['source_type'] || '-')[0..9],
                    img['created_at'] || '-')
      end
    end

    # Print image info
    def cli_print_image_info(image)
      puts "ID: #{image['image_id'] || image['id']}"
      puts "Name: #{image['name']}" if image['name']
      puts "Visibility: #{image['visibility']}" if image['visibility']
      puts "Source Type: #{image['source_type']}" if image['source_type']
      puts "Source ID: #{image['source_id']}" if image['source_id']
      puts "Size: #{image['size']}" if image['size']
      puts "Locked: #{image['locked']}" if image.key?('locked')
      puts "Created: #{image['created_at']}" if image['created_at']
    end

    # Image help
    def cli_image_help
      puts <<~HELP
        Image Management

        Usage:
          ruby un.rb image [options]

        Options:
          -l, --list             List all images
          --info ID              Get image details
          --delete ID            Delete image
          --lock ID              Prevent deletion
          --unlock ID            Allow deletion
          --publish ID           Publish image (requires --source-type)
          --source-type TYPE     Source type: service or snapshot
          --visibility ID MODE   Set visibility (private, unlisted, public)
          --spawn ID             Spawn new service from image
          --clone ID             Clone an image
          --name NAME            Name for spawned service or cloned image
          --ports PORTS          Ports for spawned service

        Examples:
          ruby un.rb image --list
          ruby un.rb image --publish svc123 --source-type service --name myimage
          ruby un.rb image --spawn img123 --name myservice --ports 80,443
          ruby un.rb image --visibility img123 public
      HELP
    end

    # Key command
    def cli_key(options)
      parser = parse_global_options(options) do
        puts 'Usage: ruby un.rb key [-p PUBLIC_KEY] [-k SECRET_KEY]'
        puts
        puts 'Check API key validity and show account info'
      end
      parser.parse!(ARGV)

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }

      begin
        result = validate_keys(**creds)
        puts "Valid: #{result['valid']}"
        puts "Account: #{result['account'] || result['account_id']}" if result['account'] || result['account_id']
        puts "Email: #{result['email']}" if result['email']
        puts "Plan: #{result['plan']}" if result['plan']
        puts "Credits: #{result['credits']}" if result['credits']
      rescue APIError => e
        if e.status_code == 401 || e.status_code == 403
          puts 'Valid: false'
          puts "Error: #{e.message}"
          exit(EXIT_AUTH_ERROR)
        end
        raise
      end
    end

    # Languages command - list available languages
    def cli_languages(options)
      json_output = false
      parser = parse_global_options(options) do
        puts 'Usage: ruby un.rb languages [--json]'
        puts
        puts 'List available programming languages'
      end
      parser.on('--json', 'Output as JSON array') do
        json_output = true
      end
      parser.parse!(ARGV)

      creds = { public_key: options[:public_key], secret_key: options[:secret_key] }
      languages = get_languages(**creds)

      if json_output
        # Output as JSON array
        require 'json'
        puts JSON.generate(languages)
      else
        # Output one language per line (pipe-friendly)
        languages.each { |lang| puts lang }
      end
    end
  end
end

# CLI entry point
if __FILE__ == $0
  Un.cli_main
end

Documentation clarifications

Dependencies

C Binary (un1) — requires libcurl and libwebsockets:

sudo apt install build-essential libcurl4-openssl-dev libwebsockets-dev
wget unsandbox.com/downloads/un.c && gcc -O2 -o un un.c -lcurl -lwebsockets

SDK Implementations — most use stdlib only (Ruby, JS, Go, etc). Some require minimal deps:

pip install requests  # Python

Execute Code

Run a Script

./un hello.py
./un app.js
./un main.rs

With Environment Variables

./un -e DEBUG=1 -e NAME=World script.py

With Input Files (teleport files into sandbox)

./un -f data.csv -f config.json process.py

Get Compiled Binary (teleport artifacts out)

./un -a -o ./bin main.c

Interactive Sessions

Start a Shell Session

# Default bash shell
./un session

# Choose your shell
./un session --shell zsh
./un session --shell fish

# Jump into a REPL
./un session --shell python3
./un session --shell node
./un session --shell julia

Session with Network Access

./un session -n semitrusted

Session Auditing (full terminal recording)

# Record everything (including vim, interactive programs)
./un session --audit -o ./logs

# Replay session later
zcat session.log*.gz | less -R

Collect Artifacts from Session

# Files in /tmp/artifacts/ are collected on exit
./un session -a -o ./outputs

Session Persistence (tmux/screen)

# Default: session terminates on disconnect (clean exit)
./un session

# With tmux: session persists, can reconnect later
./un session --tmux
# Press Ctrl+b then d to detach

# With screen: alternative multiplexer
./un session --screen
# Press Ctrl+a then d to detach

List Active Sessions

./un session --list

# Output:
# Active sessions: 2
#
# SESSION ID                               CONTAINER            SHELL      TTL      STATUS
# abc123...                                unsb-vm-12345        python3    45m30s   active
# def456...                                unsb-vm-67890        bash       1h2m     active

Reconnect to Existing Session

# Reconnect by container name (requires --tmux or --screen)
./un session --attach unsb-vm-12345

# Use exit to terminate session, or detach to keep it running

Terminate a Session

./un session --kill unsb-vm-12345

Available Shells & REPLs

Shells: bash, dash, sh, zsh, fish, ksh, tcsh, csh, elvish, xonsh, ash

REPLs:  python3, bpython, ipython    # Python
        node                          # JavaScript
        ruby, irb                     # Ruby
        lua                           # Lua
        php                           # PHP
        perl                          # Perl
        guile, scheme                 # Scheme
        ghci                          # Haskell
        erl, iex                      # Erlang/Elixir
        sbcl, clisp                   # Common Lisp
        r                             # R
        julia                         # Julia
        clojure                       # Clojure

API Key Management

Check Key Status

# Check if your API key is valid
./un key

# Output:
# Valid: key expires in 30 days

Extend Expired Key

# Open the portal to extend an expired key
./un key --extend

# This opens the unsandbox.com portal where you can
# add more credits to extend your key's expiration

Authentication

Credentials are loaded in priority order (highest first):

# 1. CLI flags (highest priority)
./un -p unsb-pk-xxxx -k unsb-sk-xxxxx script.py

# 2. Environment variables
export UNSANDBOX_PUBLIC_KEY=unsb-pk-xxxx-xxxx-xxxx-xxxx
export UNSANDBOX_SECRET_KEY=unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx
./un script.py

# 3. Config file (lowest priority)
# ~/.unsandbox/accounts.csv format: public_key,secret_key
mkdir -p ~/.unsandbox
echo "unsb-pk-xxxx-xxxx-xxxx-xxxx,unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx" > ~/.unsandbox/accounts.csv
./un script.py

Requests are signed with HMAC-SHA256. The bearer token contains only the public key; the secret key computes the signature (never transmitted).

Resource Scaling

Set vCPU Count

# Default: 1 vCPU, 2GB RAM
./un script.py

# Scale up: 4 vCPUs, 8GB RAM
./un -v 4 script.py

# Maximum: 8 vCPUs, 16GB RAM
./un --vcpu 8 heavy_compute.py

Live Session Boosting

# Boost a running session to 2 vCPU, 4GB RAM
./un session --boost sandbox-abc

# Boost to specific vCPU count (4 vCPU, 8GB RAM)
./un session --boost sandbox-abc --boost-vcpu 4

# Return to base resources (1 vCPU, 2GB RAM)
./un session --unboost sandbox-abc

Session Freeze/Unfreeze

Freeze and Unfreeze Sessions

# Freeze a session (stop billing, preserve state)
./un session --freeze sandbox-abc

# Unfreeze a frozen session
./un session --unfreeze sandbox-abc

# Note: Requires --tmux or --screen for persistence

Persistent Services

Create a Service

# Web server with ports
./un service --name web --ports 80,443 --bootstrap "python -m http.server 80"

# With custom domains
./un service --name blog --ports 8000 --domains blog.example.com

# Game server with SRV records
./un service --name mc --type minecraft --bootstrap ./setup.sh

# Deploy app tarball with bootstrap script
./un service --name app --ports 8000 -f app.tar.gz --bootstrap-file ./setup.sh
# setup.sh: cd /tmp && tar xzf app.tar.gz && ./app/start.sh

Manage Services

# List all services
./un service --list

# Get service details
./un service --info abc123

# View bootstrap logs
./un service --logs abc123
./un service --tail abc123  # last 9000 lines

# Execute command in running service
./un service --execute abc123 'journalctl -u myapp -n 50'

# Dump bootstrap script (for migrations)
./un service --dump-bootstrap abc123
./un service --dump-bootstrap abc123 backup.sh

# Freeze/unfreeze service
./un service --freeze abc123
./un service --unfreeze abc123

# Service settings (auto-wake, freeze page display)
./un service --auto-unfreeze abc123      # enable auto-wake on HTTP
./un service --no-auto-unfreeze abc123   # disable auto-wake
./un service --show-freeze-page abc123   # show HTML payment page (default)
./un service --no-show-freeze-page abc123  # return JSON error instead

# Redeploy with new bootstrap
./un service --redeploy abc123 --bootstrap ./new-setup.sh

# Destroy service
./un service --destroy abc123

Snapshots

List Snapshots

./un snapshot --list

# Output:
# Snapshots: 3
#
# SNAPSHOT ID                              NAME             SOURCE     SIZE     CREATED
# unsb-snapshot-a1b2-c3d4-e5f6-g7h8        before-upgrade   session    512 MB   2h ago
# unsb-snapshot-i9j0-k1l2-m3n4-o5p6        stable-v1.0      service    1.2 GB   1d ago

Create Session Snapshot

# Snapshot with name
./un session --snapshot unsb-vm-12345 --name "before upgrade"

# Quick snapshot (auto-generated name)
./un session --snapshot unsb-vm-12345

Create Service Snapshot

# Standard snapshot (pauses container briefly)
./un service --snapshot unsb-service-abc123 --name "stable v1.0"

# Hot snapshot (no pause, may be inconsistent)
./un service --snapshot unsb-service-abc123 --hot

Restore from Snapshot

# Restore session from snapshot
./un session --restore unsb-snapshot-a1b2-c3d4-e5f6-g7h8

# Restore service from snapshot
./un service --restore unsb-snapshot-i9j0-k1l2-m3n4-o5p6

Delete Snapshot

./un snapshot --delete unsb-snapshot-a1b2-c3d4-e5f6-g7h8

Images

Images are independent, transferable container images that survive container deletion. Unlike snapshots (which live with their container), images can be shared with other users, transferred between API keys, or made public in the marketplace.

List Images

# List all images (owned + shared + public)
./un image --list

# List only your images
./un image --list owned

# List images shared with you
./un image --list shared

# List public marketplace images
./un image --list public

# Get image details
./un image --info unsb-image-xxxx-xxxx-xxxx-xxxx

Publish Images

# Publish from a stopped or frozen service
./un image --publish-service unsb-service-abc123 \
   --name "My App v1.0" --description "Production snapshot"

# Publish from a snapshot
./un image --publish-snapshot unsb-snapshot-xxxx-xxxx-xxxx-xxxx \
   --name "Stable Release"

# Note: Cannot publish from running containers - stop or freeze first

Create Services from Images

# Spawn a new service from an image
./un image --spawn unsb-image-xxxx-xxxx-xxxx-xxxx \
   --name new-service --ports 80,443

# Clone an image (creates a copy you own)
./un image --clone unsb-image-xxxx-xxxx-xxxx-xxxx

Image Protection

# Lock image to prevent accidental deletion
./un image --lock unsb-image-xxxx-xxxx-xxxx-xxxx

# Unlock image to allow deletion
./un image --unlock unsb-image-xxxx-xxxx-xxxx-xxxx

# Delete image (must be unlocked)
./un image --delete unsb-image-xxxx-xxxx-xxxx-xxxx

Visibility & Sharing

# Set visibility level
./un image --visibility unsb-image-xxxx-xxxx-xxxx-xxxx private   # owner only (default)
./un image --visibility unsb-image-xxxx-xxxx-xxxx-xxxx unlisted  # can be shared
./un image --visibility unsb-image-xxxx-xxxx-xxxx-xxxx public    # marketplace

# Share with specific user
./un image --grant unsb-image-xxxx-xxxx-xxxx-xxxx \
   --key unsb-pk-friend-friend-friend-friend

# Revoke access
./un image --revoke unsb-image-xxxx-xxxx-xxxx-xxxx \
   --key unsb-pk-friend-friend-friend-friend

# List who has access
./un image --trusted unsb-image-xxxx-xxxx-xxxx-xxxx

Transfer Ownership

# Transfer image to another API key
./un image --transfer unsb-image-xxxx-xxxx-xxxx-xxxx \
   --to unsb-pk-newowner-newowner-newowner-newowner

Usage Reference

Usage: ./un [options] <source_file>
       ./un session [options]
       ./un service [options]
       ./un snapshot [options]
       ./un image [options]
       ./un key

Commands:
  (default)        Execute source file in sandbox
  session          Open interactive shell/REPL session
  service          Manage persistent services
  snapshot         Manage container snapshots
  image            Manage container images (publish, share, transfer)
  key              Check API key validity and expiration

Options:
  -e KEY=VALUE     Set environment variable (can use multiple times)
  -f FILE          Add input file (can use multiple times)
  -a               Return and save artifacts from /tmp/artifacts/
  -o DIR           Output directory for artifacts (default: current dir)
  -p KEY           Public key (or set UNSANDBOX_PUBLIC_KEY env var)
  -k KEY           Secret key (or set UNSANDBOX_SECRET_KEY env var)
  -n MODE          Network mode: zerotrust (default) or semitrusted
  -v N, --vcpu N   vCPU count 1-8, each vCPU gets 2GB RAM (default: 1)
  -y               Skip confirmation for large uploads (>1GB)
  -h               Show this help

Authentication (priority order):
  1. -p and -k flags (public and secret key)
  2. UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY env vars
  3. ~/.unsandbox/accounts.csv (format: public_key,secret_key per line)

Session options:
  -s, --shell SHELL  Shell/REPL to use (default: bash)
  -l, --list         List active sessions
  --attach ID        Reconnect to existing session (ID or container name)
  --kill ID          Terminate a session (ID or container name)
  --freeze ID        Freeze a session (requires --tmux/--screen)
  --unfreeze ID      Unfreeze a frozen session
  --boost ID         Boost session resources (2 vCPU, 4GB RAM)
  --boost-vcpu N     Specify vCPU count for boost (1-8)
  --unboost ID       Return to base resources
  --audit            Record full session for auditing
  --tmux             Enable session persistence with tmux (allows reconnect)
  --screen           Enable session persistence with screen (allows reconnect)

Service options:
  --name NAME        Service name (creates new service)
  --ports PORTS      Comma-separated ports (e.g., 80,443)
  --domains DOMAINS  Custom domains (e.g., example.com,www.example.com)
  --type TYPE        Service type: minecraft, mumble, teamspeak, source, tcp, udp
  --bootstrap CMD    Bootstrap command/file/URL to run on startup
  -f FILE            Upload file to /tmp/ (can use multiple times)
  -l, --list         List all services
  --info ID          Get service details
  --tail ID          Get last 9000 lines of bootstrap logs
  --logs ID          Get all bootstrap logs
  --freeze ID        Freeze a service
  --unfreeze ID      Unfreeze a service
  --auto-unfreeze ID       Enable auto-wake on HTTP request
  --no-auto-unfreeze ID    Disable auto-wake on HTTP request
  --show-freeze-page ID    Show HTML payment page when frozen (default)
  --no-show-freeze-page ID Return JSON error when frozen
  --destroy ID       Destroy a service
  --redeploy ID      Re-run bootstrap script (requires --bootstrap)
  --execute ID CMD   Run a command in a running service
  --dump-bootstrap ID [FILE]  Dump bootstrap script (for migrations)
  --snapshot ID    Create snapshot of session or service
  --snapshot-name  User-friendly name for snapshot
  --hot            Create snapshot without pausing (may be inconsistent)
  --restore ID     Restore session/service from snapshot ID

Snapshot options:
  -l, --list       List all snapshots
  --info ID        Get snapshot details
  --delete ID      Delete a snapshot permanently

Image options:
  -l, --list [owned|shared|public]  List images (all, owned, shared, or public)
  --info ID        Get image details
  --publish-service ID   Publish image from stopped/frozen service
  --publish-snapshot ID  Publish image from snapshot
  --name NAME      Name for published image
  --description DESC  Description for published image
  --delete ID      Delete image (must be unlocked)
  --clone ID       Clone image (creates copy you own)
  --spawn ID       Create service from image (requires --name)
  --lock ID        Lock image to prevent deletion
  --unlock ID      Unlock image to allow deletion
  --visibility ID LEVEL  Set visibility (private|unlisted|public)
  --grant ID --key KEY   Grant access to another API key
  --revoke ID --key KEY  Revoke access from API key
  --transfer ID --to KEY Transfer ownership to API key
  --trusted ID     List API keys with access

Key options:
  (no options)       Check API key validity
  --extend           Open portal to extend an expired key

Examples:
  ./un script.py                       # execute Python script
  ./un -e DEBUG=1 script.py            # with environment variable
  ./un -f data.csv process.py          # with input file
  ./un -a -o ./bin main.c              # save compiled artifacts
  ./un -v 4 heavy.py                   # with 4 vCPUs, 8GB RAM
  ./un session                         # interactive bash session
  ./un session --tmux                  # bash with reconnect support
  ./un session --list                  # list active sessions
  ./un session --attach unsb-vm-12345  # reconnect to session
  ./un session --kill unsb-vm-12345    # terminate a session
  ./un session --freeze unsb-vm-12345  # freeze session
  ./un session --unfreeze unsb-vm-12345  # unfreeze session
  ./un session --boost unsb-vm-12345   # boost resources
  ./un session --unboost unsb-vm-12345 # return to base
  ./un session --shell python3         # Python REPL
  ./un session --shell node            # Node.js REPL
  ./un session -n semitrusted          # session with network access
  ./un session --audit -o ./logs       # record session for auditing
  ./un service --name web --ports 80   # create web service
  ./un service --list                  # list all services
  ./un service --logs abc123           # view bootstrap logs
  ./un key                             # check API key
  ./un key --extend                    # extend expired key
  ./un snapshot --list                 # list all snapshots
  ./un session --snapshot unsb-vm-123  # snapshot a session
  ./un service --snapshot abc123       # snapshot a service
  ./un session --restore unsb-snapshot-xxxx  # restore from snapshot
  ./un image --list                  # list all images
  ./un image --list owned            # list your images
  ./un image --publish-service abc   # publish image from service
  ./un image --spawn img123 --name x # create service from image
  ./un image --grant img --key pk    # share image with user

CLI Inception

The UN CLI has been implemented in 42 programming languages, demonstrating that the unsandbox API can be accessed from virtually any environment.

View All 42 Implementations →

License

PUBLIC DOMAIN - NO LICENSE, NO WARRANTY

This is free public domain software for the public good of a permacomputer hosted
at permacomputer.com - an always-on computer by the people, for the people. One
that is durable, easy to repair, and distributed like tap water for machine
learning intelligence.

The permacomputer is community-owned infrastructure optimized around four values:

  TRUTH    - First principles, math & science, open source code freely distributed
  FREEDOM  - Voluntary partnerships, freedom from tyranny & corporate control
  HARMONY  - Minimal waste, self-renewing systems with diverse thriving connections
  LOVE     - Be yourself without hurting others, cooperation through natural law

This software contributes to that vision by enabling code execution across all 42
programming languages through a unified interface, accessible to everyone. Code is
seeds to sprout on any abandoned technology.

Learn more: https://www.permacomputer.com

Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
software, either in source code form or as a compiled binary, for any purpose,
commercial or non-commercial, and by any means.

NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.

That said, our permacomputer's digital membrane stratum continuously runs unit,
integration, and functional tests on all its own software - with our permacomputer
monitoring itself, repairing itself, with minimal human guidance in the loop.
Our agents do their best.

Copyright 2025 TimeHexOn & foxhop & russell@unturf
https://www.timehexon.com
https://www.foxhop.net
https://www.unturf.com/software

Export Vault

Enter a password to encrypt your exported vault. You'll need this password to import the vault on another device.

Import Vault

Select an exported vault file and enter the export password to decrypt it.