oops-cli 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/CHANGELOG.ja.md +14 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +21 -0
- data/README.ja.md +236 -0
- data/README.md +236 -0
- data/exe/oops +5 -0
- data/lib/oops/ansi.rb +52 -0
- data/lib/oops/cli.rb +186 -0
- data/lib/oops/clipboard.rb +218 -0
- data/lib/oops/config.rb +72 -0
- data/lib/oops/doctor.rb +133 -0
- data/lib/oops/formatter.rb +115 -0
- data/lib/oops/marker.rb +88 -0
- data/lib/oops/paths.rb +28 -0
- data/lib/oops/prompt_sp.rb +74 -0
- data/lib/oops/record.rb +41 -0
- data/lib/oops/recorder.rb +416 -0
- data/lib/oops/secrets.rb +85 -0
- data/lib/oops/session_log.rb +213 -0
- data/lib/oops/shell/oops.bash +336 -0
- data/lib/oops/shell/oops.zsh +102 -0
- data/lib/oops/shell_init.rb +26 -0
- data/lib/oops/stream_parser.rb +347 -0
- data/lib/oops/tty.rb +19 -0
- data/lib/oops/version.rb +3 -0
- data/lib/oops.rb +17 -0
- metadata +71 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
module Oops
|
|
2
|
+
# Splits the shell's pty output into bytes for the user's terminal and
|
|
3
|
+
# per-command records.
|
|
4
|
+
#
|
|
5
|
+
# Two layers work on the stream:
|
|
6
|
+
#
|
|
7
|
+
# 1. Marker extraction. Complete "ESC _ oops; ... ESC \" APCs are removed
|
|
8
|
+
# and handled as events; everything else is forwarded byte for byte, in
|
|
9
|
+
# order. A chunk may end inside a possible marker, so a trailing possible
|
|
10
|
+
# prefix is held back until the next feed decides it:
|
|
11
|
+
# :text nothing held
|
|
12
|
+
# :prefix holding a proper prefix of "ESC _ oops;" (1..7 bytes)
|
|
13
|
+
# :body holding "ESC _ oops;" plus body bytes, waiting for "ESC \"
|
|
14
|
+
# A held body is given up (emitted as ordinary bytes) when it grows past
|
|
15
|
+
# MAX_HELD_MARKER bytes, or when an ESC inside it is not followed by "\".
|
|
16
|
+
# An APC that does not start with "oops;" never reaches :body, so it is
|
|
17
|
+
# forwarded untouched.
|
|
18
|
+
#
|
|
19
|
+
# 2. Recording. Content bytes seen between a start marker and the matching
|
|
20
|
+
# end marker are stored for that command, except inside alternate-screen
|
|
21
|
+
# spans ("ESC [ ? 1049 h", "ESC [ ? 1047 h", "ESC [ ? 47 h" up to any of
|
|
22
|
+
# the matching "l" forms), which are replaced by one OMITTED line. The
|
|
23
|
+
# alternate-screen detector keeps its own small pending tail so that a
|
|
24
|
+
# sequence split across chunks is still recognised; this only delays
|
|
25
|
+
# storing, never forwarding.
|
|
26
|
+
#
|
|
27
|
+
# While paused nothing is stored and start/end markers are ignored (they are
|
|
28
|
+
# still removed from the forwarded bytes).
|
|
29
|
+
#
|
|
30
|
+
# Any program can print a marker, so the optional pause_gate decides whether
|
|
31
|
+
# a pause or resume marker is genuine: it is called with :pause or :resume
|
|
32
|
+
# and the marker is ignored when it returns false.
|
|
33
|
+
class StreamParser
|
|
34
|
+
OMITTED = "[full-screen app output omitted]\n".b.freeze
|
|
35
|
+
MAX_HELD_MARKER = 65_536
|
|
36
|
+
|
|
37
|
+
PREFIX = Marker::PREFIX
|
|
38
|
+
ESC = "\e".b.freeze
|
|
39
|
+
APC_START = "\e_".b.freeze
|
|
40
|
+
ESC_BYTE = 0x1b
|
|
41
|
+
BACKSLASH = 0x5c
|
|
42
|
+
|
|
43
|
+
ALT_ENTER = /\e\[\?(?:1049|1047|47)h/n
|
|
44
|
+
ALT_LEAVE = /\e\[\?(?:1049|1047|47)l/n
|
|
45
|
+
ALT_ENTER_SEQS = ["\e[?1049h", "\e[?1047h", "\e[?47h"].map { |s| s.b.freeze }.freeze
|
|
46
|
+
ALT_LEAVE_SEQS = ["\e[?1049l", "\e[?1047l", "\e[?47l"].map { |s| s.b.freeze }.freeze
|
|
47
|
+
ALT_MAX_PARTIAL = ALT_ENTER_SEQS.map(&:bytesize).max - 1
|
|
48
|
+
|
|
49
|
+
# A command between its start marker and its end marker.
|
|
50
|
+
OpenCommand = Struct.new(:seq, :cmd, :cwd, :started_at, :buffer, :fullscreen, :in_span, :pending)
|
|
51
|
+
|
|
52
|
+
# Keeps the last max bytes appended, without re-copying the retained data
|
|
53
|
+
# on every append: bytes live in chunks of about CHUNK bytes and the front
|
|
54
|
+
# is trimmed by moving an offset into the first chunk.
|
|
55
|
+
class TailBuffer
|
|
56
|
+
CHUNK = 65_536
|
|
57
|
+
|
|
58
|
+
attr_reader :dropped
|
|
59
|
+
|
|
60
|
+
def initialize(max)
|
|
61
|
+
@max = max
|
|
62
|
+
@chunks = []
|
|
63
|
+
@head = 0
|
|
64
|
+
@size = 0
|
|
65
|
+
@dropped = 0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def <<(str)
|
|
69
|
+
return self if str.empty?
|
|
70
|
+
|
|
71
|
+
if str.bytesize >= @max
|
|
72
|
+
@dropped += @size + str.bytesize - @max
|
|
73
|
+
@chunks = [str.byteslice(str.bytesize - @max, @max).b]
|
|
74
|
+
@head = 0
|
|
75
|
+
@size = @max
|
|
76
|
+
return self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
last = @chunks.last
|
|
80
|
+
if last && last.bytesize < CHUNK
|
|
81
|
+
last << str
|
|
82
|
+
else
|
|
83
|
+
@chunks << String.new(str, encoding: Encoding::BINARY, capacity: CHUNK)
|
|
84
|
+
end
|
|
85
|
+
@size += str.bytesize
|
|
86
|
+
trim
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def to_s
|
|
91
|
+
return "".b if @chunks.empty?
|
|
92
|
+
|
|
93
|
+
out = String.new(encoding: Encoding::BINARY, capacity: @size)
|
|
94
|
+
out << @chunks[0].byteslice(@head..)
|
|
95
|
+
@chunks.drop(1).each { |chunk| out << chunk }
|
|
96
|
+
out
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def trim
|
|
102
|
+
excess = @size - @max
|
|
103
|
+
while excess.positive?
|
|
104
|
+
avail = @chunks[0].bytesize - @head
|
|
105
|
+
take = [avail, excess].min
|
|
106
|
+
if take == avail
|
|
107
|
+
@chunks.shift
|
|
108
|
+
@head = 0
|
|
109
|
+
else
|
|
110
|
+
@head += take
|
|
111
|
+
end
|
|
112
|
+
@size -= take
|
|
113
|
+
@dropped += take
|
|
114
|
+
excess -= take
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def initialize(max_bytes: 1_048_576, pause_gate: nil, &on_record)
|
|
120
|
+
@max_bytes = max_bytes
|
|
121
|
+
@pause_gate = pause_gate
|
|
122
|
+
@on_record = on_record
|
|
123
|
+
@state = :text
|
|
124
|
+
@hold = +"".b
|
|
125
|
+
@hold_ends_with_esc = false
|
|
126
|
+
@paused = false
|
|
127
|
+
@command = nil
|
|
128
|
+
@recorded_upto = 0
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def paused? = @paused
|
|
132
|
+
|
|
133
|
+
# Returns the bytes to write to the terminal (binary String).
|
|
134
|
+
def feed(bytes)
|
|
135
|
+
data = bytes.b
|
|
136
|
+
out = String.new(encoding: Encoding::BINARY, capacity: data.bytesize)
|
|
137
|
+
@recorded_upto = 0
|
|
138
|
+
pos = 0
|
|
139
|
+
while pos < data.bytesize
|
|
140
|
+
pos = case @state
|
|
141
|
+
when :text then scan_text(data, pos, out)
|
|
142
|
+
when :prefix then scan_prefix(data, pos, out)
|
|
143
|
+
else scan_body(data, pos, out)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
flush_recording(out)
|
|
147
|
+
out
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Ends the stream: held-back bytes are treated as ordinary content and
|
|
151
|
+
# returned so the caller can still forward them, and an open command is
|
|
152
|
+
# emitted with exit: nil.
|
|
153
|
+
def finish
|
|
154
|
+
out = +"".b
|
|
155
|
+
@recorded_upto = 0
|
|
156
|
+
release_hold(out)
|
|
157
|
+
flush_recording(out)
|
|
158
|
+
close_command(nil) if @command
|
|
159
|
+
out
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# --- marker extraction ---
|
|
165
|
+
|
|
166
|
+
# Only "ESC _" can start a marker, plus a lone ESC at the end of the
|
|
167
|
+
# chunk, which might be followed by "_" in the next one.
|
|
168
|
+
def scan_text(data, pos, out)
|
|
169
|
+
i = data.index(APC_START, pos)
|
|
170
|
+
if i
|
|
171
|
+
out << data.byteslice(pos, i - pos)
|
|
172
|
+
@hold = +APC_START
|
|
173
|
+
@state = :prefix
|
|
174
|
+
return i + 2
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
last = data.bytesize - 1
|
|
178
|
+
if data.getbyte(last) == ESC_BYTE
|
|
179
|
+
out << data.byteslice(pos, last - pos)
|
|
180
|
+
@hold = +ESC
|
|
181
|
+
@state = :prefix
|
|
182
|
+
else
|
|
183
|
+
out << data.byteslice(pos..)
|
|
184
|
+
end
|
|
185
|
+
data.bytesize
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def scan_prefix(data, pos, out)
|
|
189
|
+
unless data.getbyte(pos) == PREFIX.getbyte(@hold.bytesize)
|
|
190
|
+
release_hold(out)
|
|
191
|
+
return pos
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
@hold << data.byteslice(pos, 1)
|
|
195
|
+
@state = :body if @hold.bytesize == PREFIX.bytesize
|
|
196
|
+
pos + 1
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def scan_body(data, pos, out)
|
|
200
|
+
return scan_terminator(data, pos, out) if @hold_ends_with_esc
|
|
201
|
+
|
|
202
|
+
i = data.index(ESC, pos) || data.bytesize
|
|
203
|
+
@hold << data.byteslice(pos, i - pos)
|
|
204
|
+
if i < data.bytesize
|
|
205
|
+
@hold << ESC
|
|
206
|
+
@hold_ends_with_esc = true
|
|
207
|
+
i += 1
|
|
208
|
+
end
|
|
209
|
+
give_up_body(out) if @hold.bytesize > MAX_HELD_MARKER
|
|
210
|
+
i
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The held body ends with ESC; the byte at pos decides whether it is "ESC \".
|
|
214
|
+
def scan_terminator(data, pos, out)
|
|
215
|
+
if data.getbyte(pos) == BACKSLASH
|
|
216
|
+
payload = @hold.byteslice(2, @hold.bytesize - 3)
|
|
217
|
+
reset_hold
|
|
218
|
+
flush_recording(out)
|
|
219
|
+
handle_marker(payload)
|
|
220
|
+
pos + 1
|
|
221
|
+
else
|
|
222
|
+
give_up_body(out)
|
|
223
|
+
pos
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Emits a held body as ordinary bytes. A trailing ESC may start a new
|
|
228
|
+
# marker, so it stays held as a fresh prefix.
|
|
229
|
+
def give_up_body(out)
|
|
230
|
+
restart = @hold_ends_with_esc
|
|
231
|
+
@hold.chop! if restart
|
|
232
|
+
release_hold(out)
|
|
233
|
+
return unless restart
|
|
234
|
+
|
|
235
|
+
@hold = +ESC
|
|
236
|
+
@state = :prefix
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Emits held bytes as ordinary content.
|
|
240
|
+
def release_hold(out)
|
|
241
|
+
held = @hold
|
|
242
|
+
reset_hold
|
|
243
|
+
out << held
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def reset_hold
|
|
247
|
+
@hold = +"".b
|
|
248
|
+
@hold_ends_with_esc = false
|
|
249
|
+
@state = :text
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Forwarded bytes are collected in out; the part after @recorded_upto has
|
|
253
|
+
# not been offered to the recorder yet. Flushing once per run of content
|
|
254
|
+
# (before each marker and at the end of a feed) keeps the per-byte cost
|
|
255
|
+
# low for escape-heavy output.
|
|
256
|
+
def flush_recording(out)
|
|
257
|
+
record_content(out.byteslice(@recorded_upto..)) if @command && out.bytesize > @recorded_upto
|
|
258
|
+
@recorded_upto = out.bytesize
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# --- marker events ---
|
|
262
|
+
|
|
263
|
+
def handle_marker(payload)
|
|
264
|
+
marker = Marker.parse(payload)
|
|
265
|
+
return unless marker
|
|
266
|
+
|
|
267
|
+
case marker[:kind]
|
|
268
|
+
when :pause
|
|
269
|
+
return unless gate_open?(:pause)
|
|
270
|
+
|
|
271
|
+
@command = nil
|
|
272
|
+
@paused = true
|
|
273
|
+
when :resume
|
|
274
|
+
@paused = false if gate_open?(:resume)
|
|
275
|
+
when :start
|
|
276
|
+
open_command(marker) unless @paused
|
|
277
|
+
when :end
|
|
278
|
+
close_command(marker[:exit]) if !@paused && @command && @command.seq == marker[:seq]
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def gate_open?(kind)
|
|
283
|
+
@pause_gate.nil? || @pause_gate.call(kind)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def open_command(marker)
|
|
287
|
+
close_command(nil) if @command
|
|
288
|
+
@command = OpenCommand.new(marker[:seq], marker[:cmd], marker[:cwd], marker[:started_at],
|
|
289
|
+
TailBuffer.new(@max_bytes), false, false, "".b)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def close_command(exit_status)
|
|
293
|
+
cmd = @command
|
|
294
|
+
@command = nil
|
|
295
|
+
cmd.buffer << cmd.pending unless cmd.in_span
|
|
296
|
+
@on_record&.call(Record.new(seq: cmd.seq, cmd: cmd.cmd, cwd: cmd.cwd, started_at: cmd.started_at,
|
|
297
|
+
ended_at: Time.now.to_i, exit: exit_status, output: cmd.buffer.to_s,
|
|
298
|
+
dropped_bytes: cmd.buffer.dropped, fullscreen: cmd.fullscreen))
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# --- recording with alternate-screen detection ---
|
|
302
|
+
|
|
303
|
+
def record_content(bytes)
|
|
304
|
+
cmd = @command
|
|
305
|
+
data = cmd.pending.empty? ? bytes : cmd.pending + bytes
|
|
306
|
+
cmd.pending = "".b
|
|
307
|
+
pos = 0
|
|
308
|
+
loop do
|
|
309
|
+
if cmd.in_span
|
|
310
|
+
match = ALT_LEAVE.match(data, pos)
|
|
311
|
+
unless match
|
|
312
|
+
cut = partial_start(data, pos, ALT_LEAVE_SEQS)
|
|
313
|
+
cmd.pending = data.byteslice(cut..) if cut
|
|
314
|
+
return
|
|
315
|
+
end
|
|
316
|
+
cmd.in_span = false
|
|
317
|
+
else
|
|
318
|
+
match = ALT_ENTER.match(data, pos)
|
|
319
|
+
unless match
|
|
320
|
+
cut = partial_start(data, pos, ALT_ENTER_SEQS) || data.bytesize
|
|
321
|
+
cmd.buffer << data.byteslice(pos, cut - pos)
|
|
322
|
+
cmd.pending = data.byteslice(cut..)
|
|
323
|
+
return
|
|
324
|
+
end
|
|
325
|
+
cmd.buffer << data.byteslice(pos, match.begin(0) - pos)
|
|
326
|
+
cmd.buffer << OMITTED
|
|
327
|
+
cmd.fullscreen = true
|
|
328
|
+
cmd.in_span = true
|
|
329
|
+
end
|
|
330
|
+
pos = match.end(0)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Index of the earliest ESC at or after from whose tail is a proper prefix
|
|
335
|
+
# of one of seqs, or nil.
|
|
336
|
+
def partial_start(data, from, seqs)
|
|
337
|
+
first = [from, data.bytesize - ALT_MAX_PARTIAL].max
|
|
338
|
+
(first...data.bytesize).each do |k|
|
|
339
|
+
next unless data.getbyte(k) == ESC_BYTE
|
|
340
|
+
|
|
341
|
+
tail = data.byteslice(k..)
|
|
342
|
+
return k if seqs.any? { |seq| seq.bytesize > tail.bytesize && seq.start_with?(tail) }
|
|
343
|
+
end
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
data/lib/oops/tty.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Oops
|
|
2
|
+
# The device path of the terminal an IO is connected to.
|
|
3
|
+
module Tty
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
# Returns e.g. "/dev/ttys003", or nil when io is not a terminal. IO#ttyname
|
|
7
|
+
# needs io-console 0.7.2 or later (Ruby 3.3 ships 0.7.1), so this falls
|
|
8
|
+
# back to tty(1) on older versions.
|
|
9
|
+
def name(io)
|
|
10
|
+
return nil unless io.respond_to?(:tty?) && io.tty?
|
|
11
|
+
return io.ttyname if io.respond_to?(:ttyname)
|
|
12
|
+
|
|
13
|
+
name = IO.popen(["tty"], in: io, err: File::NULL, &:read).strip
|
|
14
|
+
name.start_with?("/") ? name : nil
|
|
15
|
+
rescue SystemCallError, IOError, NotImplementedError
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/oops/version.rb
ADDED
data/lib/oops.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require_relative "oops/version"
|
|
2
|
+
require_relative "oops/ansi"
|
|
3
|
+
require_relative "oops/secrets"
|
|
4
|
+
require_relative "oops/record"
|
|
5
|
+
require_relative "oops/paths"
|
|
6
|
+
require_relative "oops/tty"
|
|
7
|
+
require_relative "oops/config"
|
|
8
|
+
require_relative "oops/prompt_sp"
|
|
9
|
+
require_relative "oops/formatter"
|
|
10
|
+
require_relative "oops/session_log"
|
|
11
|
+
require_relative "oops/marker"
|
|
12
|
+
require_relative "oops/stream_parser"
|
|
13
|
+
require_relative "oops/recorder"
|
|
14
|
+
require_relative "oops/shell_init"
|
|
15
|
+
require_relative "oops/clipboard"
|
|
16
|
+
require_relative "oops/doctor"
|
|
17
|
+
require_relative "oops/cli"
|
metadata
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: oops-cli
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ysb3996-blip
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: oops records what your zsh or bash prints and copies the last command
|
|
13
|
+
(or the last failed one) with its output to the clipboard as a code block, with
|
|
14
|
+
secrets masked, ready to paste into chat, Slack or GitHub.
|
|
15
|
+
executables:
|
|
16
|
+
- oops
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- CHANGELOG.ja.md
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.ja.md
|
|
24
|
+
- README.md
|
|
25
|
+
- exe/oops
|
|
26
|
+
- lib/oops.rb
|
|
27
|
+
- lib/oops/ansi.rb
|
|
28
|
+
- lib/oops/cli.rb
|
|
29
|
+
- lib/oops/clipboard.rb
|
|
30
|
+
- lib/oops/config.rb
|
|
31
|
+
- lib/oops/doctor.rb
|
|
32
|
+
- lib/oops/formatter.rb
|
|
33
|
+
- lib/oops/marker.rb
|
|
34
|
+
- lib/oops/paths.rb
|
|
35
|
+
- lib/oops/prompt_sp.rb
|
|
36
|
+
- lib/oops/record.rb
|
|
37
|
+
- lib/oops/recorder.rb
|
|
38
|
+
- lib/oops/secrets.rb
|
|
39
|
+
- lib/oops/session_log.rb
|
|
40
|
+
- lib/oops/shell/oops.bash
|
|
41
|
+
- lib/oops/shell/oops.zsh
|
|
42
|
+
- lib/oops/shell_init.rb
|
|
43
|
+
- lib/oops/stream_parser.rb
|
|
44
|
+
- lib/oops/tty.rb
|
|
45
|
+
- lib/oops/version.rb
|
|
46
|
+
homepage: https://github.com/ysb3996-blip/oops
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata:
|
|
50
|
+
source_code_uri: https://github.com/ysb3996-blip/oops
|
|
51
|
+
changelog_uri: https://github.com/ysb3996-blip/oops/blob/main/CHANGELOG.md
|
|
52
|
+
bug_tracker_uri: https://github.com/ysb3996-blip/oops/issues
|
|
53
|
+
rubygems_mfa_required: 'true'
|
|
54
|
+
rdoc_options: []
|
|
55
|
+
require_paths:
|
|
56
|
+
- lib
|
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.3'
|
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
requirements: []
|
|
68
|
+
rubygems_version: 4.0.20
|
|
69
|
+
specification_version: 4
|
|
70
|
+
summary: Copy the last shell command and its output to the clipboard
|
|
71
|
+
test_files: []
|