Commit 238dd5b
2026-09-12 06:21:47
Changed files (15)
ext
elelem
image
lib
elelem
image
spec
elelem
vendor
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(¶ms);
+ 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(¶ms);
+ 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(¶ms);
+ 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, ¶ms, &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]