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.
@@ -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
@@ -0,0 +1,3 @@
1
+ module Oops
2
+ VERSION = "0.1.0".freeze
3
+ end
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: []