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,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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/oops/marker.rb
ADDED
|
@@ -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
|
data/lib/oops/record.rb
ADDED
|
@@ -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
|