Commit 238dd5b

2026-09-12 06:21:47
feat: add elelem-image, a native stable-diffusion.cpp tool plugin
Embeds leejet/stable-diffusion.cpp as a C extension compiled at gem install time, same pattern as elelem-llama: vendor as a submodule, ship the vendored source in spec.files, compile via extconf.rb. No external sd binary or CLI required at runtime, only the gem plus a GGUF checkpoint (ELELEM_SD_MODEL). Statically links libstable-diffusion.a + ggml archives with -fvisibility=hidden and only the 3 el_sd_* entry points marked default visibility, so this can safely load alongside elelem-llama's own (differently-vendored) ggml build in the same process without symbol collisions - verified via `nm -D --defined-only` showing zero exported ggml_* symbols. Registers an `image` tool via Toolbox#add, gated on the shim actually being built (warns and skips registration otherwise, per the "silent tool absence" gap noted in this session's elelem dogfooding). Verified via a real `gem install` into a clean prefix: extension compiles, generates a real PNG, no sd binary on PATH.
ext/elelem/image/elelem.cpp
@@ -0,0 +1,81 @@
+#include "stable-diffusion.h"
+
+#define STB_IMAGE_WRITE_IMPLEMENTATION
+#define STB_IMAGE_WRITE_STATIC
+#include "stb_image_write.h"
+
+#include <cstdio>
+#include <cstring>
+#include <string>
+
+#define EL_API __attribute__((visibility("default")))
+
+extern "C" {
+  struct el_sd_handle {
+    sd_ctx_t *ctx;
+  };
+
+  static void el_sd_log_cb(enum sd_log_level_t, const char *, void *) {
+    // sd.cpp's INFO/progress logging goes straight to this process's stderr
+    // with no subprocess boundary to contain it; drop it here. Callers that
+    // want it can wire sd_set_log_callback themselves.
+  }
+
+  static void el_sd_progress_cb(int, int, float, void *) {
+    // Same rationale as el_sd_log_cb: no subprocess boundary to contain the
+    // progress bar sd.cpp writes straight to stderr.
+  }
+
+  EL_API void *el_sd_open(const char *model_path, int n_threads) {
+    sd_ctx_params_t params;
+    sd_ctx_params_init(&params);
+    params.model_path = model_path;
+    params.n_threads = n_threads;
+
+    sd_set_log_callback(el_sd_log_cb, nullptr);
+    sd_set_progress_callback(el_sd_progress_cb, nullptr);
+
+    sd_ctx_t *ctx = new_sd_ctx(&params);
+    if (!ctx) return nullptr;
+
+    el_sd_handle *handle = new el_sd_handle{ctx};
+    return handle;
+  }
+
+  EL_API bool el_sd_generate(void *handle_ptr, const char *prompt, const char *negative_prompt,
+                       int width, int height, int steps, int64_t seed, const char *output_path) {
+    el_sd_handle *handle = static_cast<el_sd_handle *>(handle_ptr);
+    if (!handle || !handle->ctx) return false;
+
+    sd_img_gen_params_t params;
+    sd_img_gen_params_init(&params);
+    params.prompt = prompt;
+    params.negative_prompt = negative_prompt;
+    params.width = width;
+    params.height = height;
+    params.sample_params.sample_steps = steps;
+    params.seed = seed;
+    params.batch_count = 1;
+
+    sd_image_t *images = nullptr;
+    int num_images = 0;
+    bool ok = generate_image(handle->ctx, &params, &images, &num_images);
+    if (!ok || num_images < 1 || !images) return false;
+
+    int written = stbi_write_png(output_path, images[0].width, images[0].height,
+                                  images[0].channel, images[0].data,
+                                  images[0].width * images[0].channel);
+
+    free(images[0].data);
+    free(images);
+
+    return written != 0;
+  }
+
+  EL_API void el_sd_close(void *handle_ptr) {
+    el_sd_handle *handle = static_cast<el_sd_handle *>(handle_ptr);
+    if (!handle) return;
+    if (handle->ctx) free_sd_ctx(handle->ctx);
+    delete handle;
+  }
+}
ext/elelem/image/extconf.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require "fileutils"
+
+EXT_DIR   = __dir__
+GEM_ROOT  = File.expand_path("../../..", EXT_DIR)
+VENDOR    = File.join(GEM_ROOT, "vendor", "stable-diffusion.cpp")
+BUILD_DIR = File.join(EXT_DIR, "build")
+NATIVE    = File.join(GEM_ROOT, "ext", "elelem", "image", "native")
+DARWIN = RbConfig::CONFIG["host_os"] =~ /darwin/
+
+class BuildError < StandardError; end
+
+def run(*cmd)
+  warn "elelem: + #{cmd.join(' ')}"
+  system(*cmd) || raise(BuildError, "build step failed: #{cmd.join(' ')}")
+end
+
+def configure_and_build
+  run(
+    "cmake", "-S", VENDOR, "-B", BUILD_DIR,
+    "-DCMAKE_BUILD_TYPE=Release",
+    "-DCMAKE_POSITION_INDEPENDENT_CODE=ON",
+    "-DSD_BUILD_SHARED_LIBS=OFF",
+    "-DSD_BUILD_EXAMPLES=OFF",
+    "-DSD_WEBP=OFF",
+    "-DSD_WEBM=OFF"
+  )
+  run("cmake", "--build", BUILD_DIR, "--target", "stable-diffusion", "--parallel")
+end
+
+unless File.exist?(File.join(VENDOR, "CMakeLists.txt"))
+  abort "elelem: vendored stable-diffusion.cpp missing at #{VENDOR}\n        run: git submodule update --init --recursive"
+end
+
+def find_archive(name)
+  Dir.glob(File.join(BUILD_DIR, "**", "#{name}.a")).first ||
+    abort("elelem: could not find #{name}.a under #{BUILD_DIR}")
+end
+
+def link_shim
+  ext = DARWIN ? "dylib" : "so"
+  shim = File.join(NATIVE, "libelelem_image.#{ext}")
+  FileUtils.mkdir_p(NATIVE)
+
+  sd_archive = find_archive("libstable-diffusion")
+  ggml_archives = Dir.glob(File.join(BUILD_DIR, "ggml", "src", "**", "*.a"))
+
+  cxx = ENV["CXX"] || "c++"
+  whole_archive = DARWIN ? [] : ["-Wl,--whole-archive", sd_archive, *ggml_archives, "-Wl,--no-whole-archive"]
+  archive_flags = DARWIN ? ["-Wl,-force_load,#{sd_archive}", *ggml_archives.flat_map { |a| ["-Wl,-force_load,#{a}"] }] : whole_archive
+  visibility_flags = DARWIN ? [] : ["-Wl,--exclude-libs,ALL"]
+  omp_flags = DARWIN ? [] : ["-fopenmp"]
+  omp_link_flags = DARWIN ? [] : ["-lgomp"]
+  link_flags = DARWIN ? ["-Wl,-install_name,@rpath/libelelem_image.dylib"] : []
+
+  run(cxx, "-std=c++17", "-O2", "-Wall", "-Wextra", "-shared", "-fPIC", "-fvisibility=hidden", *omp_flags,
+      "-I", File.join(VENDOR, "include"),
+      "-I", File.join(VENDOR, "thirdparty"),
+      File.join(EXT_DIR, "elelem.cpp"),
+      "-o", shim,
+      *archive_flags,
+      *visibility_flags, *link_flags, *omp_link_flags, "-lpthread")
+end
+
+begin
+  warn "elelem: building stable-diffusion.cpp"
+  configure_and_build
+  link_shim
+rescue BuildError => e
+  abort "elelem: build failed: #{e.message}"
+end
+
+File.write(File.join(EXT_DIR, "Makefile"), <<~MAKE)
+  all:
+  clean:
+  install:
+  .PHONY: all clean install
+MAKE
+
+warn "elelem: built stable-diffusion.cpp + shim into #{NATIVE}"
lib/elelem/image/version.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Image
+    VERSION = "0.1.0"
+  end
+end
lib/elelem/image.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require "elelem"
+require "etc"
+require "fiddle"
+
+require_relative "image/version"
+
+module Elelem
+  module Image
+    NATIVE = File.expand_path("../../ext/elelem/image/native", __dir__)
+    SHIM = File.join(NATIVE, "libelelem_image.#{RbConfig::CONFIG["host_os"] =~ /darwin/ ? "dylib" : "so"}")
+    V = Fiddle::TYPE_VOIDP
+    I = Fiddle::TYPE_INT
+    L = Fiddle::TYPE_LONG_LONG
+
+    def self.available?
+      File.exist?(SHIM)
+    end
+
+    def self.functions
+      @functions ||= begin
+        lib = Fiddle.dlopen(SHIM)
+        {
+          open: Fiddle::Function.new(lib["el_sd_open"], [V, I], V),
+          generate: Fiddle::Function.new(lib["el_sd_generate"], [V, V, V, I, I, I, L, V], I, need_gvl: false),
+          close: Fiddle::Function.new(lib["el_sd_close"], [V], Fiddle::TYPE_VOID)
+        }
+      end
+    end
+
+    def self.generate(prompt:, path:, negative_prompt: "", steps: 20, width: 512, height: 512, seed: 42, n_threads: nil)
+      model = ENV.fetch("ELELEM_SD_MODEL")
+      handle = functions[:open].call(model, n_threads || Etc.nprocessors)
+      return { ok: false, error: "failed to load model at #{model}" } if handle.null?
+
+      begin
+        ok = functions[:generate].call(handle, prompt, negative_prompt, width, height, steps, seed, path)
+        ok.zero? ? { ok: false, error: "generation failed" } : { ok: true, path: path }
+      ensure
+        functions[:close].call(handle)
+      end
+    end
+  end
+end
+
+Elelem.configure do |config|
+  config.setup(:image) do |agent|
+    unless Elelem::Image.available?
+      warn "elelem-image: native extension not built, skipping image tool " \
+           "(reinstall the gem or run: cd ext/elelem/image && ruby extconf.rb)"
+      next
+    end
+
+    agent.toolbox.add("image",
+      description: "Generate an image from a text prompt using a local Stable Diffusion model. " \
+                    "Lower steps/width/height trade image quality for speed.",
+      params: {
+        prompt: { type: "string" },
+        path: { type: "string" },
+        steps: { type: "integer", description: "sample steps, default 20; below ~15 often degrades quality" },
+        width: { type: "integer", description: "pixels, default 512" },
+        height: { type: "integer", description: "pixels, default 512" }
+      },
+      required: ["prompt", "path"]
+    ) do |args|
+      Elelem::Image.generate(
+        prompt: args["prompt"], path: args["path"],
+        steps: args["steps"] || 20, width: args["width"] || 512, height: args["height"] || 512
+      )
+    end
+  end
+end
spec/elelem/image_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Image do
+  describe ".generate" do
+    around do |example|
+      original = ENV["ELELEM_SD_MODEL"]
+      ENV["ELELEM_SD_MODEL"] = "/models/sd.gguf"
+      example.run
+    ensure
+      ENV["ELELEM_SD_MODEL"] = original
+    end
+
+    let(:open_fn) { instance_double(Fiddle::Function, call: handle) }
+    let(:generate_fn) { instance_double(Fiddle::Function) }
+    let(:close_fn) { instance_double(Fiddle::Function, call: nil) }
+    let(:handle) { instance_double(Fiddle::Pointer, null?: false) }
+
+    before do
+      allow(described_class).to receive(:functions).and_return(open: open_fn, generate: generate_fn, close: close_fn)
+    end
+
+    it "returns ok and the path on success" do
+      allow(generate_fn).to receive(:call).and_return(1)
+
+      result = described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
+
+      expect(result).to eq(ok: true, path: "/tmp/cat.png")
+    end
+
+    it "returns ok: false when generation fails" do
+      allow(generate_fn).to receive(:call).and_return(0)
+
+      result = described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
+
+      expect(result).to eq(ok: false, error: "generation failed")
+    end
+
+    it "returns ok: false without generating when the model fails to load" do
+      allow(open_fn).to receive(:call).and_return(instance_double(Fiddle::Pointer, null?: true))
+      allow(generate_fn).to receive(:call)
+
+      result = described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
+
+      expect(result).to eq(ok: false, error: "failed to load model at /models/sd.gguf")
+      expect(generate_fn).not_to have_received(:call)
+    end
+
+    it "closes the context even when generation fails" do
+      allow(generate_fn).to receive(:call).and_return(0)
+
+      described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
+
+      expect(close_fn).to have_received(:call).with(handle)
+    end
+
+    it "defaults steps/width/height/negative_prompt/seed and passes them to the shim" do
+      allow(generate_fn).to receive(:call).and_return(1)
+
+      described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
+
+      expect(generate_fn).to have_received(:call).with(handle, "a cat", "", 512, 512, 20, 42, "/tmp/cat.png")
+    end
+  end
+
+  describe ".available?" do
+    it "is true when the compiled shim is present" do
+      allow(File).to receive(:exist?).with(Elelem::Image::SHIM).and_return(true)
+
+      expect(described_class).to be_available
+    end
+
+    it "is false when the compiled shim is missing" do
+      allow(File).to receive(:exist?).with(Elelem::Image::SHIM).and_return(false)
+
+      expect(described_class).not_to be_available
+    end
+  end
+end
spec/spec_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require_relative "../lib/elelem/image"
+
+RSpec.configure do |config|
+  config.disable_monkey_patching!
+
+  config.expect_with :rspec do |c|
+    c.syntax = :expect
+  end
+end
vendor/stable-diffusion.cpp
@@ -0,0 +1,1 @@
+Subproject commit 7f410a3793c5bba8eb198e962ce7a3d6095f9d89
.gitignore
@@ -0,0 +1,11 @@
+/.bundle/
+/.yardoc
+/_yardoc/
+/coverage/
+/doc/
+/pkg/
+/spec/reports/
+/tmp/
+/ext/elelem/image/build/
+/ext/elelem/image/native/
+/ext/elelem/image/Makefile
.gitmodules
@@ -0,0 +1,3 @@
+[submodule "vendor/stable-diffusion.cpp"]
+	path = vendor/stable-diffusion.cpp
+	url = https://github.com/leejet/stable-diffusion.cpp.git
.rspec
@@ -0,0 +1,1 @@
+--require spec_helper
elelem-image.gemspec
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require_relative "lib/elelem/image/version"
+
+Gem::Specification.new do |spec|
+  spec.name = "elelem-image"
+  spec.version = Elelem::Image::VERSION
+  spec.authors = ["mo khan"]
+  spec.email = ["[email protected]"]
+
+  spec.summary = "Local image generation tool for elelem."
+  spec.description = "Adds an image tool backed by an embedded stable-diffusion.cpp to generate images from text prompts."
+  spec.homepage = "https://src.mokhan.ca/elelem/image"
+  spec.license = "MIT"
+  spec.required_ruby_version = ">= 4.0.0"
+  spec.metadata["allowed_push_host"] = "https://rubygems.org"
+  spec.metadata["homepage_uri"] = spec.homepage
+  spec.metadata["source_code_uri"] = spec.homepage
+
+  spec.files = [
+    "LICENSE.txt",
+    "lib/elelem/image.rb",
+    "lib/elelem/image/version.rb",
+    "ext/elelem/image/elelem.cpp",
+    "ext/elelem/image/extconf.rb",
+  ]
+  # Only what extconf.rb's cmake build (SD_BUILD_EXAMPLES/WEBP/WEBM all OFF)
+  # actually needs to configure and compile. Applies to both stable-diffusion.cpp's
+  # own tree and its nested ggml submodule.
+  vendor_exclude = %r{/(examples|docs|tests|ci|assets|\.github|thirdparty/libwebp|thirdparty/libwebm)/}
+  spec.files += Dir["vendor/stable-diffusion.cpp/**/*"].select do |path|
+    File.file?(path) && path !~ vendor_exclude
+  end
+  spec.extensions = ["ext/elelem/image/extconf.rb"]
+  spec.require_paths = ["lib"]
+
+  spec.add_dependency "elelem", "~> 0.11"
+  spec.add_dependency "fiddle", "~> 1.1"
+end
Gemfile
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+gemspec name: "elelem-image"
+
+gem "irb"
+gem "rake", "~> 13.0"
+gem "rspec", "~> 3.0"
Gemfile.lock
@@ -0,0 +1,128 @@
+PATH
+  remote: .
+  specs:
+    elelem-image (0.1.0)
+      elelem (~> 0.11)
+      fiddle (~> 1.1)
+
+GEM
+  remote: https://rubygems.org/
+  specs:
+    bigdecimal (4.1.2)
+    diff-lcs (1.6.2)
+    elelem (0.11.0)
+      erb (~> 6.0)
+      forwardable (~> 1.4)
+      io-console (~> 0.9)
+      json (~> 3.0)
+      json_schemer (~> 2.5)
+      logger (~> 1.7)
+      open3 (~> 0.2)
+      optparse (~> 0.8)
+      pathname (~> 0.5)
+      reline (~> 0.7)
+      shellwords (~> 0.2)
+      uri (~> 1.0)
+    erb (6.0.7)
+    fiddle (1.1.8)
+    forwardable (1.4.0)
+    hana (1.3.7)
+    io-console (0.9.2)
+    irb (1.18.0)
+      pp (>= 0.6.0)
+      prism (>= 1.3.0)
+      rdoc (>= 4.0.0)
+      reline (>= 0.4.2)
+    json (3.0.2)
+    json_schemer (2.5.0)
+      bigdecimal
+      hana (~> 1.3)
+      regexp_parser (~> 2.0)
+      simpleidn (~> 0.2)
+    logger (1.7.0)
+    open3 (0.2.1)
+    optparse (0.8.1)
+    pathname (0.5.0)
+    pp (0.6.4)
+      prettyprint
+    prettyprint (0.2.0)
+    prism (1.9.0)
+    rake (13.4.2)
+    rbs (4.2.0)
+      logger
+      prism (>= 1.6.0)
+      tsort
+    rdoc (8.0.0)
+      erb
+      prism (>= 1.6.0)
+      rbs (>= 4.0.0)
+      tsort
+    regexp_parser (2.12.0)
+    reline (0.7.0)
+      io-console (~> 0.5)
+    rspec (3.13.2)
+      rspec-core (~> 3.13.0)
+      rspec-expectations (~> 3.13.0)
+      rspec-mocks (~> 3.13.0)
+    rspec-core (3.13.6)
+      rspec-support (~> 3.13.0)
+    rspec-expectations (3.13.5)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.13.0)
+    rspec-mocks (3.13.8)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.13.0)
+    rspec-support (3.13.7)
+    shellwords (0.2.2)
+    simpleidn (0.3.0)
+    tsort (0.2.0)
+    uri (1.1.1)
+
+PLATFORMS
+  ruby
+  x86_64-linux
+
+DEPENDENCIES
+  elelem-image!
+  irb
+  rake (~> 13.0)
+  rspec (~> 3.0)
+
+CHECKSUMS
+  bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
+  bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae
+  diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
+  elelem (0.11.0) sha256=552cafb092320e3896b8e07572d322fe2b94f0f10d965bf7b993b74517a6f744
+  elelem-image (0.1.0)
+  erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
+  fiddle (1.1.8)
+  forwardable (1.4.0) sha256=f1cd40cc9812937980e1c76f1aa053660990a7c9b6a98fc37d945468afcce838
+  hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d
+  io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
+  irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
+  json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df
+  json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396
+  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+  open3 (0.2.1) sha256=8e2d7d2113526351201438c1aa35c8139f0141c9e8913baa007c898973bf3952
+  optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
+  pathname (0.5.0) sha256=d5a331784f6e1f2fefb31c2ff0b8855aabfb661d807284ead9fa47b883d81623
+  pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
+  prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
+  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
+  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+  rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
+  rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
+  regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
+  reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
+  rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
+  rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
+  rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
+  rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
+  rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
+  shellwords (0.2.2) sha256=b8695a791de2f71472de5abdc3f4332f6535a4177f55d8f99e7e44266cd32f94
+  simpleidn (0.3.0) sha256=12ca730bed2f3db04d11e9bfd1bca3e11fb37f55b21eb2e9793fb5814bf54d03
+  tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+  uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
+
+BUNDLED WITH
+  4.0.20
LICENSE.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2026 mo khan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
Rakefile
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+require "bundler/gem_tasks"
+require "rspec/core/rake_task"
+
+RSpec::Core::RakeTask.new(:spec)
+
+task default: %i[spec]