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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +27 -0
- data/.gitignore +3 -0
- data/.justfile +60 -0
- data/.mise.toml +11 -0
- data/.rubocop.yml +61 -0
- data/AGENTS.md +29 -0
- data/Gemfile +13 -0
- data/README.md +107 -0
- data/Rakefile +14 -0
- data/demo.rb +11 -0
- data/lib/run_kit/core_ext.rb +116 -0
- data/lib/run_kit/options/color.rb +30 -0
- data/lib/run_kit/options/config.rb +123 -0
- data/lib/run_kit/options/flag.rb +135 -0
- data/lib/run_kit/options/help.rb +85 -0
- data/lib/run_kit/options/main.rb +82 -0
- data/lib/run_kit/options/parser.rb +142 -0
- data/lib/run_kit/options/positional.rb +23 -0
- data/lib/run_kit/options.rb +7 -0
- data/lib/run_kit/shell.rb +307 -0
- data/lib/run_kit/term.rb +201 -0
- data/lib/run_kit.rb +22 -0
- data/prek.toml +46 -0
- data/run_kit.gemspec +23 -0
- metadata +93 -0
|
@@ -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
|
data/lib/run_kit/term.rb
ADDED
|
@@ -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: []
|