run_kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,307 @@
1
+ # Shared runtime helpers for text/csv/json IO, shell cmds, banners, etc.
2
+ # note: instance methods will be private due to module_function
3
+
4
+ module RunKit
5
+ module Shell
6
+ module_function
7
+
8
+ #
9
+ # file read/write, including gz
10
+ #
11
+
12
+ def file_read(path)
13
+ Pathname(path).then do |path|
14
+ data = path.read
15
+ data = gunzip(data) if path.extname == ".gz"
16
+ data
17
+ end
18
+ end
19
+
20
+ def file_write(path, str)
21
+ path = Pathname(path)
22
+ atomic_write(path) do |tmp|
23
+ str = gzip(str) if path.extname == ".gz"
24
+ tmp.write(str)
25
+ end
26
+ end
27
+
28
+ #
29
+ # json file read/write, including gz
30
+ #
31
+
32
+ def json_read(path, symbolize_names: true) = JSON.parse(file_read(path), symbolize_names:)
33
+ def json_write(path, json) = file_write(path, JSON.pretty_generate(json))
34
+ def jsonl_read(path, symbolize_names: true) = file_read(path).split("\n").map { JSON.parse(_1, symbolize_names:) }
35
+ def jsonl_write(path, json) = file_write(path, json.map { JSON.generate(_1) }.join("\n"))
36
+
37
+ #
38
+ # gzip/gunzip data
39
+ #
40
+
41
+ def gzip(str)
42
+ Zlib::GzipWriter.new(StringIO.new).tap do
43
+ _1.write(str)
44
+ end.close.string
45
+ end
46
+
47
+ def gunzip(str_gz)
48
+ gz = Zlib::GzipReader.new(StringIO.new(str_gz))
49
+ gz.read
50
+ ensure
51
+ gz&.close
52
+ end
53
+
54
+ #
55
+ # CSV read/write
56
+ #
57
+
58
+ def csv_read(path, infer: false)
59
+ io = StringIO.new(file_read(path))
60
+ rows = CSV.read(io, encoding: "bom|utf-8")
61
+
62
+ headers = rows.shift.map(&:to_sym)
63
+ klass = Struct.new(*headers)
64
+
65
+ rows.map do |row|
66
+ row = row.map { _infer_csv(_1) } if infer
67
+ klass.new(*row)
68
+ end
69
+ end
70
+
71
+ def csv_write(path, rows, headers: nil)
72
+ atomic_write(path) do |tmp|
73
+ CSV.open(tmp, "wb") { _csv_write0(_1, rows, headers:) }
74
+ end
75
+ end
76
+
77
+ def csv_write_stdout(rows, headers: nil)
78
+ CSV($stdout) { _csv_write0(_1, rows, headers:) }
79
+ end
80
+
81
+ #
82
+ # shell/shell!
83
+ #
84
+
85
+ # Run a command via Open3.capture2e. `cmd` can be passed as:
86
+ #
87
+ # - shell("git status") # a single string
88
+ # - shell("git", "status") # varargs strings
89
+ # - shell(["git", "status"]) # an array of strings
90
+ #
91
+ # Prefer arrays so escaping stays explicit and Ruby handles argument
92
+ # boundaries for you. Single strings are convenient but put escaping
93
+ # responsibility on the caller. `vars:` lets you interpolate `{{ hi }}` into
94
+ # the command before it runs. `Pathname` values get shell-escaped, which is
95
+ # real nice here.
96
+ #
97
+ # Returns `[stdout_and_stderr, exit_code]`
98
+ def shell(*cmd, vars: nil)
99
+ output, status, _ = _shell(*cmd, vars:)
100
+ [output, status]
101
+ end
102
+
103
+ # like shell, but raises on non zero exit code. returns stdout_and_stderr otherwise
104
+ def shell!(*cmd, vars: nil)
105
+ output, status, cmd = _shell(*cmd, vars:)
106
+ raise "#{cmd.inspect} failed #{status}\noutput: #{output}" if status != 0
107
+ output
108
+ end
109
+
110
+ # Atomically transform src into dst.
111
+ def shell_transform!(*cmd, src:, dst:, force: false)
112
+ src, dst = Pathname(src), Pathname(dst)
113
+ in_place = src.abs == dst.abs
114
+ raise Errno::EEXIST, dst.to_s if dst.exist? && !force && !in_place
115
+
116
+ dst.dirname.mkdir
117
+ tmp = nil
118
+ Tempfile.create([".tmp-", dst.extname], dst.dirname.to_s) do |tmpfile|
119
+ tmp = Pathname(tmpfile.path)
120
+ tmpfile.close
121
+ shell!(*cmd, vars: {src:, dst: tmp})
122
+ cp_metadata(src, tmp)
123
+ # Temp lives beside dst, so rename replaces it atomically; do not use mv.
124
+ tmp.rename(dst)
125
+ end
126
+ dst
127
+ ensure
128
+ tmp&.rm
129
+ end
130
+
131
+ # copy mtime and perms from src => dst
132
+ def cp_metadata(src, dst)
133
+ src, dst = Pathname(src), Pathname(dst)
134
+ stat = src.stat
135
+ dst.chmod(stat.mode)
136
+ dst.chown(stat.uid, stat.gid)
137
+ dst.touch(mtime: stat.mtime)
138
+ end
139
+
140
+ # Kill a process, ignore failure
141
+ def kill_process(pid)
142
+ Process.kill("KILL", pid)
143
+ rescue Errno::ESRCH
144
+ end
145
+
146
+ # one-liners
147
+ def glob(pats) = Pathname.glob(pats).uniq.sort
148
+ def installed?(cmd) = shell("sh", "-c", "command -v #{cmd.shellescape}")[1] == 0
149
+ def lines_in_file(path) = shell!("wc", "-l", path).strip.split.first.to_i
150
+ def md5(str) = Digest::MD5.hexdigest(str)
151
+ def program_name = Pathname($PROGRAM_NAME).basename
152
+ def sha256(str) = Digest::SHA256.hexdigest(str)
153
+
154
+ #
155
+ # banner/warning/fatal
156
+ #
157
+
158
+ def banner(str, color: :green)
159
+ puts Term.paint_banner("[#{_now.strftime("%H:%M:%S")}] #{str.ljust(72)} ", color)
160
+ end
161
+
162
+ def warning(str)
163
+ banner(str, color: :peach)
164
+ end
165
+
166
+ def fatal(str)
167
+ banner(str, color: :red)
168
+ exit(1)
169
+ end
170
+
171
+ # Ask the user a question via stderr, then return true if they enter YES, yes, y, etc.
172
+ def prompt?(prompt = "Proceed?")
173
+ $stderr.write("#{prompt} (y/n) ")
174
+ $stderr.flush
175
+ ch = $stdin.gets || "no"
176
+ ch.match?(/^y/i)
177
+ end
178
+
179
+ # Fetches data from `cache` file. If there is data in the cache with the given key, then that data is returned.
180
+ def cache_fetch(cache:, compress: false, expires_in: nil, force: false, format: :json, symbolize: true, &)
181
+ cache = Pathname(cache)
182
+ stale = cache.exist? && expires_in && (_now - cache.mtime > expires_in.to_i)
183
+ data = if !cache.exist? || stale || force
184
+ _cache_write(cache:, compress:, expires_in:, format:, &)
185
+ else
186
+ _cache_read(cache:, compress:, expires_in:, format:)
187
+ end
188
+ data = _symbolize_keys(data) if symbolize
189
+ data
190
+ end
191
+
192
+ #
193
+ # helpers
194
+ #
195
+
196
+ # Atomically replace a file by writing to a temporary path first.
197
+ def atomic_write(path, &block)
198
+ tmp = nil
199
+ Pathname(path).tap do |path|
200
+ path.dirname.mkdir
201
+ tmp = Pathname("#{path}.tmp").tap(&:rm)
202
+ yield(tmp)
203
+ # Temp lives beside path, so rename replaces it atomically; do not use mv.
204
+ tmp.rename(path)
205
+ end
206
+ ensure
207
+ tmp&.rm
208
+ end
209
+
210
+ # low-level helper for writing csv <= rows w/ headers
211
+ def _csv_write0(csv, rows, headers: nil)
212
+ headers ||= rows.first.to_h.keys
213
+ csv << headers
214
+ rows.each do |row|
215
+ row = row.to_h
216
+ csv << headers.map { row[_1] }
217
+ end
218
+ end
219
+
220
+ # infer int/float from str
221
+ def _infer_csv(str)
222
+ case str
223
+ when /\A-?\d+\z/ then return str.to_i
224
+ when /\A-?\d+[.\d]+\z/ then return str.to_f
225
+ end
226
+ str
227
+ end
228
+
229
+ # shell helper
230
+ def _shell(*cmd, vars: nil)
231
+ begin
232
+ cmd = _shell_cmd(cmd, vars:)
233
+ output, status = Open3.capture2e(*cmd)
234
+ status = status.exitstatus
235
+ rescue Errno::ENOENT => ex
236
+ output, status = ex.message, 127
237
+ end
238
+ [output.strip, status, cmd]
239
+ end
240
+
241
+ def _shell_cmd(cmd, vars: nil)
242
+ cmd = cmd.first if cmd.one? && (cmd.first.is_a?(Array) || cmd.first.is_a?(String))
243
+ if vars
244
+ raise ArgumentError, "cmd must be string with vars: {...}" if !cmd.is_a?(String)
245
+ cmd = vars.reduce(cmd) do |memo, (k, v)|
246
+ k = "{{#{k}}}"
247
+ raise ArgumentError, "#{cmd.inspect} does not contain #{k}" if !memo.include?(k)
248
+
249
+ v = case v
250
+ when Array then v.shelljoin
251
+ when Pathname then v.escape
252
+ else; v.to_s
253
+ end
254
+ memo.gsub(k, v)
255
+ end
256
+ end
257
+ Array(cmd).map(&:to_s)
258
+ end
259
+
260
+ def _cache_read(cache:, compress: false, expires_in: nil, format: :json)
261
+ data = cache.binread
262
+ data = gunzip(data) if compress
263
+ case format
264
+ when :bin then data.force_encoding("ascii-8bit")
265
+ when :json then JSON.parse(data)
266
+ when :jsonl then data.split("\n").map { JSON.parse(_1) }
267
+ when :marshal then Marshal.load(data)
268
+ when :str, :string then data.force_encoding("utf-8")
269
+ else; raise "unknown format #{format.inspect}"
270
+ end
271
+ end
272
+
273
+ def _cache_write(cache:, compress: false, expires_in: nil, format: :json, &)
274
+ yield.tap do
275
+ data = case format
276
+ when :bin, :str, :string then _1.to_s
277
+ when :json then _1.to_json
278
+ when :jsonl then _1.map(&:to_json).join("\n")
279
+ when :marshal then Marshal.dump(_1)
280
+ else; raise "unknown format #{format.inspect}"
281
+ end
282
+ data = gzip(data) if compress
283
+ cache.binwrite(data)
284
+ end
285
+ end
286
+
287
+ # note: no activesupport dependency
288
+ def _symbolize_keys(obj)
289
+ case obj
290
+ when Hash
291
+ obj.to_h do |k, v|
292
+ k = begin
293
+ k.to_sym
294
+ rescue
295
+ k
296
+ end
297
+ [k, _symbolize_keys(v)]
298
+ end
299
+ when Array then obj.map { _symbolize_keys(_1) }
300
+ else; obj
301
+ end
302
+ end
303
+
304
+ # we don't want activesupport, force getlocal
305
+ def _now = Time.now.getlocal
306
+ end
307
+ end
@@ -0,0 +1,201 @@
1
+ # ANSI-aware terminal helpers.
2
+ module RunKit
3
+ module Term
4
+ module_function
5
+
6
+ #
7
+ # terminal helpers
8
+ #
9
+
10
+ # Calculate terminal width, defaulting to 48x80.
11
+ def winsize(...)
12
+ IO.console&.winsize(...) || [48, 80]
13
+ end
14
+
15
+ def with_hidden_cursor(output)
16
+ output.write CURSOR_HIDE
17
+ yield
18
+ ensure
19
+ output.write CURSOR_SHOW
20
+ end
21
+
22
+ # Measure characters while ignoring ANSI control sequences.
23
+ def width(str) = str.gsub(ANSI_RE, "").length
24
+
25
+ # Return word-wrapped text. ANSI escapes are taked into account, but we do
26
+ # not wrap colored regions across lines.
27
+ def wrap(str, truncate_to)
28
+ return "" if str.empty?
29
+
30
+ lines, words = [], []
31
+ tokens = str.split(/[ \t\r]+|(\n)/).reject(&:empty?) # words and newlines
32
+ tokens.each do |word|
33
+ if word == "\n"
34
+ lines << words.join(" ")
35
+ words = []
36
+ next
37
+ end
38
+ if !words.empty? && width("#{words.join(" ")} #{word}") > truncate_to
39
+ lines << words.join(" ")
40
+ words = []
41
+ end
42
+
43
+ words << word
44
+ end
45
+ lines << words.join(" ") unless words.empty?
46
+ lines << "" if tokens.last == "\n"
47
+ lines.join("\n")
48
+ end
49
+
50
+ #
51
+ # painting color strings
52
+ #
53
+
54
+ PALETTE = {
55
+ # catppuccin latte
56
+ rosewater: "#dc8a78",
57
+ flamingo: "#dd7878",
58
+ pink: "#ea76cb",
59
+ mauve: "#8839ef",
60
+ red: "#d20f39",
61
+ maroon: "#e64553",
62
+ peach: "#fe640b",
63
+ yellow: "#df8e1d",
64
+ green: "#40a02b",
65
+ teal: "#179299",
66
+ sky: "#04a5e5",
67
+ sapphire: "#209fb5",
68
+ blue: "#1e66f5",
69
+ lavender: "#7287fd",
70
+ text: "#4c4f69",
71
+ subtext1: "#5c5f77",
72
+ subtext0: "#6c6f85",
73
+ overlay2: "#7c7f93",
74
+ overlay1: "#8c8fa1",
75
+ overlay0: "#9ca0b0",
76
+ surface2: "#acb0be",
77
+ surface1: "#bcc0cc",
78
+ surface0: "#ccd0da",
79
+ base: "#eff1f5",
80
+ mantle: "#e6e9ef",
81
+ crust: "#dce0e8",
82
+
83
+ # some basic colors
84
+ white: "#ffffff",
85
+ black: "#000000",
86
+ muted: "#585858",
87
+ }
88
+
89
+ def paint8(str, color) = paint_ansi(str, BOLD, ansi8_fg(color))
90
+ def paint_ansi(str, *codes) = "#{CSI}#{codes.join(";")}m#{str}#{RESET}"
91
+ def paint_muted(str) = paint_ansi(str, ansi_fg(:muted))
92
+ def paint_banner(str, color) = paint_ansi(str, BOLD, ansi_fg(:white), ansi_bg(color))
93
+
94
+ #
95
+ # ANSI escape codes and colors. ANSI itself supports three different colors
96
+ # schemes (8-color table, 256 indexed color cube, and full rgb). We have
97
+ # helpers for each.
98
+ #
99
+
100
+ ANSI_RE = /\e\[[\d;]*m/
101
+ ESC = "\e"
102
+ CSI = "#{ESC}["
103
+ BOLD = "1"
104
+ RESET = "#{CSI}0m"
105
+ CURSOR_HIDE = "#{CSI}?25l"
106
+ CURSOR_SHOW = "#{CSI}?25h"
107
+
108
+ #
109
+ # ansi rgb / direct / 24m color formatting. This is the easiest to use and
110
+ # almost always the right choice.
111
+ #
112
+
113
+ def ansi_fg(color) = "38;2;#{to_rgb(color).join(";")}"
114
+ def ansi_bg(color) = "48;2;#{to_rgb(color).join(";")}"
115
+
116
+ #
117
+ # ANSI 8-color table, map from color symbol to color index. Use this if you
118
+ # want to paint in the user's own terminal color palette. Not good for
119
+ # reverse color or fine control.
120
+ #
121
+
122
+ ANSI8 = {black: 0, red: 1, green: 2, yellow: 3, blue: 4, magenta: 5, cyan: 6, white: 7, default: 9}
123
+
124
+ def ansi8_fg(name8) = 30 + ANSI8.fetch(name8)
125
+ def ansi8_bg(name8) = 40 + ANSI8.fetch(name8)
126
+
127
+ #
128
+ # ANSI 256 indexed color cube. Rarely used. If you want to use ansi 256 for
129
+ # compat or perf reasons, choose your color palette in advance instead of
130
+ # converting on the fly with this stuff.
131
+ #
132
+
133
+ def ansi256_fg(color) = "38;5;#{to_256(color)}"
134
+ def ansi256_bg(color) = "48;5;#{to_256(color)}"
135
+
136
+ def ansi256_cube
137
+ @ansi256_cube ||= begin
138
+ cube = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
139
+ rgb_for = ->(idx) do
140
+ if idx >= 232
141
+ r = g = b = 8 + (idx - 232) * 10
142
+ else
143
+ off = idx - 16
144
+ r = cube[(off / 36) % 6]
145
+ g = cube[(off / 6) % 6]
146
+ b = cube[(off / 1) % 6]
147
+ end
148
+ [r, g, b]
149
+ end
150
+ Array.new(256) { rgb_for.call(_1) if _1 >= 16 }
151
+ end
152
+ end
153
+
154
+ #
155
+ # color converters. `color` is any of:
156
+ # - nil
157
+ # - an ansi 256 color cube index
158
+ # - an [r,g,b] channel array
159
+ # - a hex color string "#rrggbb"
160
+ # - one of our named PALETTE symbols
161
+ #
162
+
163
+ HEX_RE = /\A#[\da-f]{6}\z/i
164
+
165
+ def to_256(color)
166
+ case color
167
+ when nil then return
168
+ when (0..255) then return color
169
+ end
170
+ rgb = to_rgb(color)
171
+ (16..255).min_by do |idx|
172
+ rgb.zip(ansi256_cube[idx]).sum { |a, b| (a - b)**2 }
173
+ end
174
+ end
175
+
176
+ def to_hex(color)
177
+ case color
178
+ when nil then color
179
+ when (16..255) then rgb_to_hex(ansi256_cube[color])
180
+ when Array then rgb_to_hex(color)
181
+ when HEX_RE then color
182
+ when Symbol then PALETTE.fetch(color)
183
+ else; raise "unknown color format #{color.inspect}"
184
+ end
185
+ end
186
+
187
+ def to_rgb(color)
188
+ case color
189
+ when nil then color
190
+ when (16..255) then ansi256_cube[color]
191
+ when Array then color
192
+ when HEX_RE then hex_to_rgb(color)
193
+ when Symbol then hex_to_rgb(PALETTE.fetch(color))
194
+ else; raise "unknown color format #{color.inspect}"
195
+ end
196
+ end
197
+
198
+ def hex_to_rgb(hex) = hex.delete_prefix("#").scan(/../).map { _1.to_i(16) }
199
+ def rgb_to_hex(rgb) = sprintf("#%02x%02x%02x", *rgb)
200
+ end
201
+ end
data/lib/run_kit.rb ADDED
@@ -0,0 +1,22 @@
1
+ require "csv"
2
+ require "digest"
3
+ require "fileutils"
4
+ require "io/console"
5
+ require "json"
6
+ require "open3"
7
+ require "pathname"
8
+ require "ruby-progressbar"
9
+ require "shellwords"
10
+ require "stringio"
11
+ require "tempfile"
12
+ require "zlib"
13
+
14
+ require_relative "run_kit/core_ext"
15
+ require_relative "run_kit/term"
16
+ require_relative "run_kit/options"
17
+ require_relative "run_kit/shell"
18
+
19
+ # handy entry point for RunKit::Options
20
+ module RunKit
21
+ def self.parse(...) = Options.parse(...)
22
+ end
data/prek.toml ADDED
@@ -0,0 +1,46 @@
1
+ #
2
+ # args: [--severity=error]
3
+ #
4
+ # prek install
5
+ # prek run
6
+ # prek run --all-files
7
+ #
8
+
9
+ [[repos]]
10
+ repo = "https://github.com/pre-commit/pre-commit-hooks"
11
+ rev = "v6.0.0"
12
+ hooks = [
13
+ { id = "check-added-large-files" }, # reject large files from git
14
+ { id = "check-case-conflict" }, # detect filename case collisions
15
+ { id = "check-executables-have-shebangs" }, # verify shebangs for shell scripts
16
+ { id = "check-json" }, # validate json files
17
+ { id = "check-merge-conflict" }, # detect merge markers
18
+ { id = "check-shebang-scripts-are-executable" }, # verify shell script perms
19
+ { id = "check-symlinks" }, # detect broken symlinks
20
+ { id = "check-toml" }, # validate toml files
21
+ { id = "check-yaml" }, # validate yaml file
22
+ { id = "detect-private-key" }, # reject private keys
23
+ { id = "end-of-file-fixer" }, # ensure final newline
24
+ { id = "mixed-line-ending" }, # normalize line endings
25
+ { id = "trailing-whitespace" }, # remove trailing whitespace
26
+ ]
27
+
28
+ [[repos]]
29
+ repo = "https://github.com/rhysd/actionlint"
30
+ rev = "v1.7.12"
31
+ hooks = [ { id = "actionlint" } ] # lint github actions
32
+
33
+ [[repos]]
34
+ repo = "https://github.com/Yelp/detect-secrets"
35
+ rev = "v1.5.0"
36
+ hooks = [ { id = "detect-secrets" } ] # detect committed secrets
37
+
38
+ [[repos]]
39
+ repo = "https://github.com/koalaman/shellcheck-precommit"
40
+ rev = "v0.11.0"
41
+ hooks = [ { id = "shellcheck" } ] # lint shell scripts
42
+
43
+ [[repos]]
44
+ repo = "https://github.com/zizmorcore/zizmor-pre-commit"
45
+ rev = "v1.28.0"
46
+ hooks = [ { id = "zizmor" } ] # github actions security
data/run_kit.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = "run_kit"
3
+ s.version = "0.1.0"
4
+ s.authors = ["Adam Doppelt"]
5
+ s.email = "amd@gurge.com"
6
+ s.summary = "Run kit."
7
+ s.homepage = "https://github.com/gurgeous/run_kit"
8
+ s.license = "MIT"
9
+ s.required_ruby_version = ">= 3.2.0"
10
+ s.metadata = {
11
+ "homepage_uri" => s.homepage,
12
+ "rubygems_mfa_required" => "true",
13
+ "source_code_uri" => s.homepage,
14
+ }
15
+
16
+ # what's in the gem?
17
+ s.files = `git ls-files`.split("\n").grep_v(%r{^(bin|test)/})
18
+ s.require_paths = ["lib"]
19
+
20
+ # gem dependencies
21
+ s.add_dependency "csv", "~> 3.3"
22
+ s.add_dependency "ruby-progressbar", "~> 1.13"
23
+ end
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: run_kit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Adam Doppelt
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: csv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ruby-progressbar
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.13'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.13'
40
+ email: amd@gurge.com
41
+ executables: []
42
+ extensions: []
43
+ extra_rdoc_files: []
44
+ files:
45
+ - ".github/workflows/ci.yml"
46
+ - ".gitignore"
47
+ - ".justfile"
48
+ - ".mise.toml"
49
+ - ".rubocop.yml"
50
+ - AGENTS.md
51
+ - Gemfile
52
+ - README.md
53
+ - Rakefile
54
+ - demo.rb
55
+ - lib/run_kit.rb
56
+ - lib/run_kit/core_ext.rb
57
+ - lib/run_kit/options.rb
58
+ - lib/run_kit/options/color.rb
59
+ - lib/run_kit/options/config.rb
60
+ - lib/run_kit/options/flag.rb
61
+ - lib/run_kit/options/help.rb
62
+ - lib/run_kit/options/main.rb
63
+ - lib/run_kit/options/parser.rb
64
+ - lib/run_kit/options/positional.rb
65
+ - lib/run_kit/shell.rb
66
+ - lib/run_kit/term.rb
67
+ - prek.toml
68
+ - run_kit.gemspec
69
+ homepage: https://github.com/gurgeous/run_kit
70
+ licenses:
71
+ - MIT
72
+ metadata:
73
+ homepage_uri: https://github.com/gurgeous/run_kit
74
+ rubygems_mfa_required: 'true'
75
+ source_code_uri: https://github.com/gurgeous/run_kit
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 3.2.0
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.6.9
91
+ specification_version: 4
92
+ summary: Run kit.
93
+ test_files: []