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,115 @@
1
+ module Oops
2
+ # Turns captured records into the paste-ready clipboard text.
3
+ #
4
+ # Per field (command line and output) the pipeline is: strip ANSI, scrub
5
+ # invalid UTF-8, shorten the home directory to "~", mask secrets, apply the
6
+ # config replace table. Output is then truncated (head/tail) and fenced.
7
+ # With show_secrets, the home/mask/replace steps are skipped.
8
+ class Formatter
9
+ GITHUB_COLLAPSE_LINES = 30
10
+
11
+ # lines: output lines shown after formatting, summed over records. The
12
+ # "... N lines omitted ..." marker is not counted.
13
+ # command_line: the formatted first command, without "$ " or indentation.
14
+ Result = Struct.new(:text, :lines, :masked_count, :command_line)
15
+
16
+ def initialize(config:, home: Dir.home, show_secrets: false, head: nil, tail: nil, github: false)
17
+ @config = config
18
+ @home = home.to_s
19
+ @show_secrets = show_secrets
20
+ @head = head
21
+ @tail = tail
22
+ @github = github
23
+ @masked = 0
24
+ end
25
+
26
+ def format(records)
27
+ @masked = 0
28
+ blocks = records.map { |record| build_block(record) }
29
+ text = @github ? blocks.map { |b| github_block(b) }.join("\n") : plain_text(blocks)
30
+ Result.new(text, blocks.sum { |b| b[:count] }, @masked, blocks.first&.dig(:command))
31
+ end
32
+
33
+ private
34
+
35
+ def build_block(record)
36
+ command = clean(record.cmd.to_s).sub(/\s+\z/, "")
37
+ shown, count = shown_lines(clean(PromptSp.strip(record.output.to_s)))
38
+ exit_code = record.exit
39
+ exit_code = nil if exit_code.to_i.zero?
40
+ body = ["$ #{command.gsub("\n", "\n ").gsub(/^ +$/, '')}", *shown]
41
+ body << "(exit #{exit_code})" if exit_code
42
+ { command: command, exit: exit_code, count: count, content: body.join("\n") }
43
+ end
44
+
45
+ # Returns [lines to show, real output line count]: trailing blank lines
46
+ # dropped, then head/tail truncation.
47
+ def shown_lines(text)
48
+ lines = text.split("\n", -1)
49
+ lines.pop while lines.any? && lines.last.strip.empty?
50
+ shown = truncate(lines)
51
+ [shown, shown.equal?(lines) ? lines.size : shown.size - 1]
52
+ end
53
+
54
+ def truncate(lines)
55
+ return lines if @head.nil? && @tail.nil?
56
+
57
+ head = @head.to_i
58
+ tail = @tail.to_i
59
+ omitted = lines.size - head - tail
60
+ return lines if omitted <= 0
61
+
62
+ marker = "... #{omitted.to_s.reverse.scan(/\d{1,3}/).join(',').reverse} lines omitted ..."
63
+ [*lines.first(head), marker, *(tail.zero? ? [] : lines.last(tail))]
64
+ end
65
+
66
+ def clean(raw)
67
+ text = Ansi.strip(raw.dup).force_encoding(Encoding::UTF_8).scrub
68
+ return text if @show_secrets
69
+
70
+ text = shorten_home(text)
71
+ text, count = Secrets.mask(text, extra: @config.secrets)
72
+ @masked += count
73
+ apply_replace(text)
74
+ end
75
+
76
+ def shorten_home(text)
77
+ return text if @home.empty? || @home == "/"
78
+
79
+ text.gsub(/#{Regexp.escape(@home)}(?!\w)/, "~")
80
+ end
81
+
82
+ def apply_replace(text)
83
+ keys = @config.replace.keys.reject(&:empty?).sort_by { |k| -k.length }
84
+ return text if keys.empty?
85
+
86
+ table = @config.replace
87
+ text.gsub(Regexp.union(keys)) { |match| table[match] }
88
+ end
89
+
90
+ def plain_text(blocks)
91
+ fenced(blocks.map { |b| b[:content] }.join("\n\n"), "")
92
+ end
93
+
94
+ def github_block(block)
95
+ fence = fenced(block[:content], "console")
96
+ return fence if block[:count] <= GITHUB_COLLAPSE_LINES
97
+
98
+ parts = []
99
+ parts << "exit #{block[:exit]}" if block[:exit]
100
+ parts << "#{block[:count]} lines"
101
+ summary = escape_html("$ #{block[:command]}".gsub("\n", " "))
102
+ "<details>\n<summary><code>#{summary}</code> — #{parts.join(', ')}</summary>\n\n#{fence}\n</details>\n"
103
+ end
104
+
105
+ def fenced(content, language)
106
+ longest = content.scan(/`+/).map(&:length).max.to_i
107
+ fence = "`" * [3, longest + 1].max
108
+ "#{fence}#{language}\n#{content}\n#{fence}\n"
109
+ end
110
+
111
+ def escape_html(text)
112
+ text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,88 @@
1
+ # APC marker layout
2
+ #
3
+ # The shell hooks write these markers into the terminal stream and the pty
4
+ # relay extracts them again. Because they are APC sequences, terminals ignore
5
+ # them. Byte layout:
6
+ #
7
+ # PREFIX ESC _ o o p s ; ("\e_oops;")
8
+ # body <kind> [; <field> ...]
9
+ # SUFFIX ESC \ ("\e\\")
10
+ #
11
+ # Fields are separated by ";". Free-form text (cwd, cmd) is strict base64
12
+ # (RFC 4648, no line breaks), so it never contains ";", ESC or a newline.
13
+ #
14
+ # start;<seq>;<epoch>;<b64 cwd>;<b64 cmd> command is about to run
15
+ # end;<seq>;<exit> command finished
16
+ # pause stop recording (e.g. full-screen UI)
17
+ # resume start recording again
18
+ #
19
+ # <seq>, <epoch> and <exit> are decimal integers. Example:
20
+ #
21
+ # printf '\033_oops;start;%s;%s;%s;%s\033\\' "$seq" "$(date +%s)" \
22
+ # "$(printf '%s' "$PWD" | base64 | tr -d '\n')" \
23
+ # "$(printf '%s' "$cmd" | base64 | tr -d '\n')"
24
+ #
25
+ # Marker.parse takes the text between "ESC _" and "ESC \", i.e. it starts with
26
+ # "oops;".
27
+ module Oops
28
+ module Marker
29
+ PREFIX = "\e_oops;".b.freeze
30
+ SUFFIX = "\e\\".b.freeze
31
+
32
+ UINT = /\A\d+\z/
33
+ INT = /\A-?\d+\z/
34
+
35
+ def self.start(seq:, started_at:, cwd:, cmd:)
36
+ wrap("start;#{seq};#{started_at};#{encode(cwd)};#{encode(cmd)}")
37
+ end
38
+
39
+ def self.end(seq:, exit:)
40
+ wrap("end;#{seq};#{exit}")
41
+ end
42
+
43
+ def self.pause = wrap("pause")
44
+ def self.resume = wrap("resume")
45
+
46
+ # Returns a Hash for a well-formed payload, nil for anything else.
47
+ def self.parse(payload)
48
+ return nil unless payload.is_a?(String)
49
+
50
+ fields = payload.b.split(";", -1)
51
+ return nil unless fields.shift == "oops"
52
+
53
+ case fields.shift
54
+ when "start" then parse_start(fields)
55
+ when "end" then parse_end(fields)
56
+ when "pause" then fields.empty? ? { kind: :pause } : nil
57
+ when "resume" then fields.empty? ? { kind: :resume } : nil
58
+ end
59
+ end
60
+
61
+ def self.parse_start(fields)
62
+ return nil unless fields.size == 4 && fields[0].match?(UINT) && fields[1].match?(UINT)
63
+
64
+ cwd = decode(fields[2])
65
+ cmd = decode(fields[3])
66
+ return nil unless cwd && cmd
67
+
68
+ { kind: :start, seq: fields[0].to_i, started_at: fields[1].to_i, cwd: cwd, cmd: cmd }
69
+ end
70
+
71
+ def self.parse_end(fields)
72
+ return nil unless fields.size == 2 && fields[0].match?(UINT) && fields[1].match?(INT)
73
+
74
+ { kind: :end, seq: fields[0].to_i, exit: fields[1].to_i }
75
+ end
76
+
77
+ def self.wrap(body) = "#{PREFIX}#{body}#{SUFFIX}"
78
+ def self.encode(text) = [text.to_s.b].pack("m0")
79
+
80
+ def self.decode(text)
81
+ text.unpack1("m0").force_encoding(Encoding::UTF_8).scrub
82
+ rescue ArgumentError
83
+ nil
84
+ end
85
+
86
+ private_class_method :parse_start, :parse_end, :wrap, :encode, :decode
87
+ end
88
+ end
data/lib/oops/paths.rb ADDED
@@ -0,0 +1,28 @@
1
+ require "fileutils"
2
+
3
+ module Oops
4
+ # Filesystem locations following the XDG base directory spec.
5
+ module Paths
6
+ module_function
7
+
8
+ def config_file(env: ENV)
9
+ File.join(base(env, "XDG_CONFIG_HOME", ".config"), "oops", "config.yml")
10
+ end
11
+
12
+ def sessions_dir(env: ENV)
13
+ File.join(base(env, "XDG_STATE_HOME", ".local/state"), "oops", "sessions")
14
+ end
15
+
16
+ def ensure_sessions_dir!(env: ENV)
17
+ dir = sessions_dir(env: env)
18
+ FileUtils.mkdir_p(dir, mode: 0o700)
19
+ File.chmod(0o700, dir)
20
+ dir
21
+ end
22
+
23
+ def base(env, key, fallback)
24
+ value = env[key]
25
+ value.nil? || value.empty? ? File.join(Dir.home, fallback) : value
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,74 @@
1
+ module Oops
2
+ # Removes the PROMPT_SP filler that zsh writes at the end of a command's
3
+ # output.
4
+ #
5
+ # Before it draws a prompt (and before precmd, so before our end marker),
6
+ # zsh with PROMPT_SP and PROMPT_CR set (the default) writes:
7
+ #
8
+ # <PROMPT_EOL_MARK> <columns - W - (0 or 1) spaces> CR <W spaces> CR
9
+ #
10
+ # where W is the display width of the expanded mark (default "%B%S%#%s%b",
11
+ # a bold inverse "%" or "#"). On a terminal this leaves the mark after
12
+ # output that did not end in a newline and moves the prompt to a fresh
13
+ # line. In a record it is noise, and worse: line-control handling keeps the
14
+ # text after the last CR (" "), so a last line without a newline is lost.
15
+ #
16
+ # Only an exact trailing match is removed: the CR / spaces / CR shape, a
17
+ # mark of width W right before the spaces, and mark plus spaces at least
18
+ # MIN_COLUMNS wide (the filler spans the terminal). Assumes a mark that
19
+ # does not end in spaces.
20
+ module PromptSp
21
+ MIN_COLUMNS = 20
22
+ FILLER = /(?<! )( ++)\r( *+)\r\z/n
23
+ TOKEN = Regexp.union(Ansi::ESCAPES, /[\xC2-\xF4][\x80-\xBF]*|./mn)
24
+ WIDE = [
25
+ 0x1100..0x115F, 0x2E80..0x303E, 0x3041..0x33FF, 0x3400..0x4DBF, 0x4E00..0x9FFF,
26
+ 0xA000..0xA4CF, 0xAC00..0xD7A3, 0xF900..0xFAFF, 0xFE30..0xFE4F, 0xFF00..0xFF60,
27
+ 0xFFE0..0xFFE6, 0x1F300..0x1F64F, 0x1F900..0x1F9FF, 0x20000..0x3FFFD
28
+ ].freeze
29
+
30
+ module_function
31
+
32
+ # Returns bytes without a trailing filler, or bytes unchanged.
33
+ def strip(bytes)
34
+ raw = bytes.b
35
+ m = FILLER.match(raw)
36
+ return bytes unless m
37
+
38
+ width = m[2].bytesize
39
+ return bytes if width + m[1].bytesize < MIN_COLUMNS
40
+
41
+ before = raw.byteslice(0, m.begin(0))
42
+ line_start = (before.rindex("\n") || -1) + 1
43
+ tokens = before.byteslice(line_start..).scan(TOKEN)
44
+ tokens = drop_mark(tokens, width)
45
+ return bytes unless tokens
46
+
47
+ (before.byteslice(0, line_start) + tokens.join).force_encoding(bytes.encoding)
48
+ end
49
+
50
+ # Pops the mark (width display columns) and the escape sequences around
51
+ # it off the end of the line's tokens; nil if the line is too short.
52
+ def drop_mark(tokens, width)
53
+ tokens = tokens.dup
54
+ while width.positive?
55
+ token = tokens.pop
56
+ return nil unless token
57
+
58
+ width -= token_width(token)
59
+ end
60
+ tokens.pop while tokens.any? && token_width(tokens.last).zero?
61
+ tokens
62
+ end
63
+
64
+ def token_width(token)
65
+ return 0 if token.start_with?("\e", "\a")
66
+ return 1 if token.bytesize == 1
67
+
68
+ char = token.dup.force_encoding(Encoding::UTF_8)
69
+ return 1 unless char.valid_encoding?
70
+
71
+ WIDE.any? { |range| range.cover?(char.ord) } ? 2 : 1
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,41 @@
1
+ require "json"
2
+
3
+ module Oops
4
+ # One captured command with its output, stored as a single JSON line.
5
+ Record = Struct.new(:seq, :cmd, :cwd, :started_at, :ended_at, :exit, :output,
6
+ :dropped_bytes, :fullscreen, keyword_init: true) do
7
+ def to_json_line
8
+ h = to_h
9
+ h[:cmd] = utf8_clean(cmd)
10
+ h[:cwd] = utf8_clean(cwd)
11
+ h[:output] = [output.to_s.b].pack("m0")
12
+ "#{JSON.generate(h)}\n"
13
+ end
14
+
15
+ def utf8_clean(value)
16
+ return nil if value.nil?
17
+
18
+ value.to_s.dup.force_encoding(Encoding::UTF_8).scrub
19
+ end
20
+ private :utf8_clean
21
+
22
+ def oops_self?
23
+ cmd.to_s.match?(%r{\A\s*(\S*/)?oops(\s|\z)})
24
+ end
25
+
26
+ def self.from_json_line(line)
27
+ h = JSON.parse(line)
28
+ raise Record::ParseError, "record is not an object" unless h.is_a?(Hash)
29
+
30
+ new(**h.slice(*members.map(&:to_s)).transform_keys(&:to_sym),
31
+ output: h["output"].to_s.unpack1("m0"))
32
+ rescue JSON::ParserError, ArgumentError => e
33
+ raise Record::ParseError, e.message
34
+ end
35
+ end
36
+
37
+ # Raised when a session log line cannot be decoded into a Record.
38
+ class Record
39
+ class ParseError < StandardError; end
40
+ end
41
+ end