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.
data/lib/oops/cli.rb ADDED
@@ -0,0 +1,186 @@
1
+ require "optparse"
2
+
3
+ module Oops
4
+ module CLI
5
+ # Raised for a usage error; the message is printed after "oops: " and the
6
+ # exit code is 2.
7
+ class UsageError < StandardError; end
8
+
9
+ # Raised when the command cannot do its job; exit code 1.
10
+ class Failure < StandardError; end
11
+
12
+ NO_CLIPBOARD = "no clipboard tool found (tried pbcopy, wl-copy, xclip, xsel, clip.exe, OSC 52).".freeze
13
+ POSITIVE_INTEGER = /\A\d+\z/
14
+
15
+ def self.run(argv, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr)
16
+ case argv.first
17
+ when "record" then return record(argv.drop(1), env: env, stdin: stdin, stdout: stdout, stderr: stderr)
18
+ when "init" then return init(argv.drop(1), stdout: stdout, stderr: stderr)
19
+ when "doctor" then return doctor(argv.drop(1), env: env, stdin: stdin, stdout: stdout)
20
+ when "pause", "resume" then return toggle(argv.first, argv.drop(1), env: env, stdout: stdout)
21
+ end
22
+
23
+ options = { head: nil, tail: nil, github: false, show_secrets: false }
24
+ parser = OptionParser.new do |o|
25
+ o.banner = "Usage: oops [N | err] [options]\n oops init <shell> | pause | resume | doctor"
26
+ o.on("--github", "Format for a GitHub issue or PR comment") { options[:github] = true }
27
+ o.on("--tail N", "Keep only the last N output lines") { |v| options[:tail] = positive(v, "--tail") }
28
+ o.on("--head N", "Keep only the first N output lines") { |v| options[:head] = positive(v, "--head") }
29
+ o.on("--show-secrets", "Do not mask secrets or apply the replace table") { options[:show_secrets] = true }
30
+ o.on("-v", "--version", "Print version and exit") do
31
+ stdout.puts "oops #{VERSION}"
32
+ return 0
33
+ end
34
+ o.on("-h", "--help", "Print this help and exit") do
35
+ stdout.puts o
36
+ return 0
37
+ end
38
+ end
39
+ copy(target(parser.parse(argv)), options, env: env, stdout: stdout, stderr: stderr)
40
+ rescue OptionParser::ParseError, UsageError => e
41
+ stderr.puts "oops: #{e.message}"
42
+ 2
43
+ rescue Failure => e
44
+ stderr.puts "oops: #{e.message}"
45
+ 1
46
+ end
47
+
48
+ def self.positive(value, name)
49
+ raise UsageError, "#{name} must be a positive integer" unless value.match?(POSITIVE_INTEGER) && value.to_i >= 1
50
+
51
+ value.to_i
52
+ end
53
+
54
+ # Positional arguments: none (the last command), N (the last N commands)
55
+ # or "err" (the last failed command). Returns 1, N or :err.
56
+ def self.target(args)
57
+ raise UsageError, "unexpected argument: #{args[1]}" if args.size > 1
58
+
59
+ arg = args.first
60
+ return 1 if arg.nil?
61
+ return :err if arg == "err"
62
+ return positive(arg, "N") if arg.match?(/\A[+-]?\d/)
63
+
64
+ raise UsageError, "unknown command: #{arg}"
65
+ end
66
+
67
+ def self.copy(target, options, env:, stdout:, stderr:)
68
+ log = recording_log(env)
69
+ raise Failure, "recording is paused. Run oops resume first." if log.paused?
70
+
71
+ config = Config.load(env: env)
72
+ config.warnings.each { |w| stderr.puts "oops: warning: #{w}" }
73
+ records = target == :err ? [log.last_failed].compact : log.last(target)
74
+ raise Failure, (target == :err ? "no failed command found." : "no command found yet.") if records.empty?
75
+
76
+ home = env["HOME"].to_s.empty? ? Dir.home : env["HOME"]
77
+ result = Formatter.new(config: config, home: home, **options).format(records)
78
+ backend = Clipboard.detect(config.clipboard, env: env)
79
+ raise Failure, NO_CLIPBOARD if backend.nil?
80
+
81
+ begin
82
+ backend.copy(result.text)
83
+ rescue Clipboard::Error => e
84
+ raise Failure, e.message
85
+ end
86
+ stdout.puts "copied: $ #{report_command(result.command_line)} (#{report_counts(result)})"
87
+ 0
88
+ end
89
+
90
+ def self.report_command(command_line)
91
+ first, rest = command_line.to_s.split("\n", 2)
92
+ rest ? "#{first} ..." : first.to_s
93
+ end
94
+
95
+ def self.report_counts(result)
96
+ counts = "#{result.lines} lines"
97
+ result.masked_count.positive? ? "#{counts}, #{result.masked_count} secrets masked" : counts
98
+ end
99
+
100
+ # The session log of the recorder this shell runs under. Raises Failure
101
+ # when there is none.
102
+ def self.recording_log(env)
103
+ path = env["OOPS_SESSION"].to_s
104
+ return SessionLog.new(path) if !path.empty? && File.file?(path)
105
+
106
+ shell, rc = env["SHELL"].to_s.end_with?("bash") ? %w[bash ~/.bashrc] : %w[zsh ~/.zshrc]
107
+ raise Failure, "not recording in this shell. Add eval \"$(oops init #{shell})\" " \
108
+ "to #{rc} (after whatever puts oops's Ruby on PATH, as early as possible) " \
109
+ "and open a new tab."
110
+ end
111
+
112
+ # `oops pause` / `oops resume`: the marker goes through the pty to the
113
+ # recorder; the flag file tells `oops` (and `doctor`) the state. The
114
+ # recorder only obeys a marker that agrees with the flag file, so the
115
+ # file changes first.
116
+ def self.toggle(name, args, env:, stdout:)
117
+ raise UsageError, "usage: oops #{name}" unless args.empty?
118
+
119
+ log = recording_log(env)
120
+ if name == "pause"
121
+ log.pause!
122
+ write_marker(Marker.pause, env: env, stdout: stdout)
123
+ else
124
+ log.resume!
125
+ write_marker(Marker.resume, env: env, stdout: stdout)
126
+ end
127
+ 0
128
+ end
129
+
130
+ # Like the shell hooks, write straight to the recorded terminal: with
131
+ # stdout redirected (`oops pause >/dev/null`) the recorder would never see
132
+ # the marker although the flag file already says "paused". Without a
133
+ # terminal that can be opened, stdout is used.
134
+ def self.write_marker(marker, env:, stdout:)
135
+ tty = env["OOPS_TTY"].to_s
136
+ unless tty.empty?
137
+ begin
138
+ return File.open(tty, File::WRONLY | File::NOCTTY) { |io| io.write(marker) }
139
+ rescue SystemCallError, IOError
140
+ nil
141
+ end
142
+ end
143
+ stdout.write marker
144
+ stdout.flush
145
+ end
146
+
147
+ # `oops doctor`: diagnostics on stdout; always exits 0.
148
+ def self.doctor(args, env:, stdin:, stdout:)
149
+ raise UsageError, "usage: oops doctor" unless args.empty?
150
+
151
+ config = Config.load(env: env)
152
+ tty = Tty.name(stdin)
153
+ stdout.write Doctor.report(env: env, config: config, sessions_dir: Paths.sessions_dir(env: env), tty: tty)
154
+ 0
155
+ rescue StandardError => e
156
+ raise if e.is_a?(UsageError)
157
+
158
+ stdout.puts "doctor: #{e.message}"
159
+ 0
160
+ end
161
+
162
+ # `oops record [--] <shell> [args...]`
163
+ def self.record(args, env:, stdin:, stdout:, stderr:)
164
+ args = args.drop(1) if args.first == "--"
165
+ Recorder.new(args, env: env, stdin: stdin, stdout: stdout, stderr: stderr).run
166
+ end
167
+
168
+ # `oops init zsh|bash`: prints the snippet for `eval "$(oops init zsh)"`.
169
+ def self.init(args, stdout:, stderr:)
170
+ supported = ShellInit::SHELLS.join(", ")
171
+ if args.size != 1
172
+ stderr.puts "oops: usage: oops init <shell> (supported: #{supported})"
173
+ return 2
174
+ end
175
+ unless ShellInit::SHELLS.include?(args.first)
176
+ stderr.puts "oops: unsupported shell: #{args.first} (supported: #{supported})"
177
+ return 2
178
+ end
179
+ stdout.write ShellInit.snippet(args.first, bin: File.expand_path($PROGRAM_NAME))
180
+ 0
181
+ end
182
+
183
+ private_class_method :positive, :target, :copy, :report_command, :report_counts, :recording_log, :toggle,
184
+ :write_marker, :doctor
185
+ end
186
+ end
@@ -0,0 +1,218 @@
1
+ require "tempfile"
2
+
3
+ module Oops
4
+ # Clipboard backends and detection of the best one for this environment.
5
+ module Clipboard
6
+ class Error < StandardError; end
7
+
8
+ # Seconds to wait for a clipboard command before giving up.
9
+ TIMEOUT = 5
10
+ # The wait polls for the command's exit: it starts with short sleeps (the
11
+ # command usually finishes within a few milliseconds) and doubles up to
12
+ # POLL_MAX.
13
+ POLL_MIN = 0.001
14
+ POLL_MAX = 0.01
15
+ # Bytes of a failing command's stderr kept in the error message.
16
+ MAX_STDERR = 2048
17
+
18
+ # Shared process handling: text goes in through a temp file on stdin and
19
+ # output goes to temp files, so a daemonized child (xclip, wl-copy) that
20
+ # keeps inheriting those descriptors can never block us. We only wait for
21
+ # the direct child; on a timeout its whole process group is killed.
22
+ module Runner
23
+ module_function
24
+
25
+ def run(label, argv, text, timeout: TIMEOUT)
26
+ Tempfile.create("oops-in", binmode: true) do |input|
27
+ Tempfile.create("oops-err", binmode: true) do |err|
28
+ input.write(text)
29
+ input.flush
30
+ input.rewind
31
+ status = spawn_and_wait(label, argv, input, err, timeout)
32
+ next if status.success?
33
+
34
+ raise Error, "#{label} failed (#{describe(status)}): #{read_head(err)}"
35
+ end
36
+ end
37
+ rescue SystemCallError, IOError => e
38
+ raise Error, "#{label}: #{e.message}"
39
+ end
40
+
41
+ def spawn_and_wait(label, argv, input, err, timeout)
42
+ pid = Process.spawn(*argv, in: input, out: File::NULL, err: err, pgroup: true)
43
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
44
+ delay = POLL_MIN
45
+ loop do
46
+ _, status = Process.wait2(pid, Process::WNOHANG)
47
+ return status if status
48
+
49
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
50
+ kill_group(pid)
51
+ raise Error, "#{label} timed out after #{timeout}s"
52
+ end
53
+ sleep delay
54
+ delay = [delay * 2, POLL_MAX].min
55
+ end
56
+ end
57
+
58
+ # The child leads its own process group, so this also ends anything it
59
+ # started that is still running.
60
+ def kill_group(pid)
61
+ begin
62
+ Process.kill("KILL", -pid)
63
+ rescue SystemCallError
64
+ Process.kill("KILL", pid)
65
+ end
66
+ Process.wait(pid)
67
+ rescue SystemCallError
68
+ nil
69
+ end
70
+
71
+ def describe(status)
72
+ return "exit #{status.exitstatus}" if status.exited?
73
+ return "killed by SIG#{Signal.signame(status.termsig)}" if status.signaled?
74
+
75
+ status.to_s
76
+ end
77
+
78
+ def read_head(file)
79
+ file.rewind
80
+ head = file.read(MAX_STDERR + 1).to_s
81
+ more = head.bytesize > MAX_STDERR
82
+ text = head.byteslice(0, MAX_STDERR).force_encoding(Encoding::UTF_8).scrub.strip
83
+ more ? "#{text} ..." : text
84
+ end
85
+ end
86
+
87
+ # Runs an argv with the text on stdin.
88
+ class Command
89
+ attr_reader :name
90
+
91
+ def initialize(name, argv, encoding: nil)
92
+ @name = name
93
+ @argv = argv
94
+ @encoding = encoding
95
+ end
96
+
97
+ def copy(text)
98
+ text = text.dup.force_encoding(Encoding::UTF_8).scrub.encode(@encoding) if @encoding
99
+ Runner.run(@name, @argv, text)
100
+ end
101
+ end
102
+
103
+ # Runs a user supplied command line through sh -c.
104
+ class Shell
105
+ attr_reader :name
106
+
107
+ def initialize(cmdline)
108
+ @cmdline = cmdline
109
+ @name = cmdline.to_s.split.first || cmdline.to_s
110
+ end
111
+
112
+ def copy(text)
113
+ Runner.run(@name, ["sh", "-c", @cmdline], text)
114
+ end
115
+ end
116
+
117
+ # Writes an OSC 52 escape sequence to the terminal.
118
+ class Osc52
119
+ WARNING = "oops: warning: clipboard text truncated to fit OSC 52 limit".freeze
120
+
121
+ def initialize(tty: "/dev/tty", max_bytes: 100_000)
122
+ @tty = tty
123
+ @max_bytes = max_bytes
124
+ end
125
+
126
+ def name = "osc52"
127
+
128
+ def copy(text)
129
+ payload = [fit(text.b)].pack("m0")
130
+ write("\e]52;c;#{payload}\a")
131
+ end
132
+
133
+ private
134
+
135
+ # Keeps the tail of the bytes so the base64 form fits max_bytes, never
136
+ # starting in the middle of a UTF-8 character.
137
+ def fit(bytes)
138
+ limit = @max_bytes / 4 * 3
139
+ return bytes if bytes.bytesize <= limit
140
+
141
+ warn WARNING
142
+ start = bytes.bytesize - limit
143
+ start += 1 while start < bytes.bytesize && (bytes.getbyte(start) & 0xC0) == 0x80
144
+ bytes.byteslice(start..)
145
+ end
146
+
147
+ # One write(2) where possible, so the sequence is not split up between
148
+ # other output.
149
+ def write(data)
150
+ if @tty.respond_to?(:syswrite)
151
+ write_all(@tty, data)
152
+ else
153
+ File.open(@tty, "wb") { |f| write_all(f, data) }
154
+ end
155
+ rescue SystemCallError, IOError => e
156
+ raise Error, "osc52: cannot write to #{@tty.respond_to?(:syswrite) ? 'tty' : @tty}: #{e.message}"
157
+ end
158
+
159
+ def write_all(io, data)
160
+ data = data.b
161
+ until data.empty?
162
+ written = io.syswrite(data)
163
+ data = data.byteslice(written..)
164
+ end
165
+ end
166
+ end
167
+
168
+ KNOWN = {
169
+ "pbcopy" => ["pbcopy"],
170
+ "wl-copy" => ["wl-copy"],
171
+ "xclip" => ["xclip", "-selection", "clipboard"],
172
+ "xsel" => ["xsel", "--clipboard", "--input"],
173
+ "clip.exe" => ["clip.exe"]
174
+ }.freeze
175
+
176
+ DEFAULT_WHICH = ->(cmd) { system("command -v #{cmd} >/dev/null 2>&1") }
177
+
178
+ module_function
179
+
180
+ def command_backend(name)
181
+ Command.new(name, KNOWN.fetch(name), encoding: (name == "clip.exe" ? "UTF-16LE" : nil))
182
+ end
183
+
184
+ def detect(setting, env: ENV, which: DEFAULT_WHICH, platform: RUBY_PLATFORM, proc_version: nil)
185
+ setting = setting.to_s.strip
186
+ setting = "auto" if setting.empty? || %w[true false].include?(setting)
187
+ return explicit(setting) unless setting == "auto"
188
+
189
+ auto(env, which, platform, proc_version)
190
+ end
191
+
192
+ def explicit(setting)
193
+ return Osc52.new if setting == "osc52"
194
+ return command_backend(setting) if KNOWN.key?(setting)
195
+
196
+ Shell.new(setting)
197
+ end
198
+
199
+ def auto(env, which, platform, proc_version)
200
+ return Osc52.new unless env["SSH_TTY"].to_s.empty?
201
+ return command_backend("pbcopy") if platform.include?("darwin") && which.call("pbcopy")
202
+ return command_backend("clip.exe") if wsl?(proc_version) && which.call("clip.exe")
203
+ return command_backend("wl-copy") if !env["WAYLAND_DISPLAY"].to_s.empty? && which.call("wl-copy")
204
+
205
+ unless env["DISPLAY"].to_s.empty?
206
+ %w[xclip xsel].each { |n| return command_backend(n) if which.call(n) }
207
+ end
208
+ Osc52.new
209
+ end
210
+
211
+ def wsl?(proc_version)
212
+ proc_version ||= File.exist?("/proc/version") ? File.read("/proc/version") : ""
213
+ proc_version.match?(/microsoft/i)
214
+ rescue SystemCallError, IOError
215
+ false
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,72 @@
1
+ require_relative "paths"
2
+
3
+ module Oops
4
+ # User configuration loaded from a YAML file; problems become warnings.
5
+ class Config
6
+ KNOWN_KEYS = %w[replace secrets clipboard].freeze
7
+
8
+ attr_reader :replace, :secrets, :clipboard, :warnings
9
+
10
+ def self.load(path = nil, env: ENV)
11
+ new(path || Paths.config_file(env: env))
12
+ end
13
+
14
+ def initialize(path)
15
+ @replace = {}
16
+ @secrets = []
17
+ @clipboard = "auto"
18
+ @warnings = []
19
+ read(path)
20
+ end
21
+
22
+ private
23
+
24
+ def read(path)
25
+ return unless File.file?(path)
26
+
27
+ # Loaded here, not at the top: most runs have no config file, and the
28
+ # library takes about 5 ms to load.
29
+ require "yaml"
30
+ data = YAML.safe_load_file(path)
31
+ return if data.nil?
32
+ return @warnings << "could not parse #{path}: top level must be a mapping" unless data.is_a?(Hash)
33
+
34
+ apply(data)
35
+ rescue Psych::Exception, SystemCallError => e
36
+ @warnings << "could not parse #{path}: #{e.message}"
37
+ end
38
+
39
+ def apply(data)
40
+ (data.keys.map(&:to_s) - KNOWN_KEYS).each { |k| @warnings << "unknown key: #{k}" }
41
+ apply_replace(data["replace"]) if data.key?("replace")
42
+ apply_secrets(data["secrets"]) if data.key?("secrets")
43
+ apply_clipboard(data["clipboard"]) if data.key?("clipboard")
44
+ end
45
+
46
+ # YAML reads `clipboard: true` (or false) as a boolean; as a command it
47
+ # would run `sh -c true` and copy nothing, so both mean auto.
48
+ def apply_clipboard(value)
49
+ text = value.to_s.strip
50
+ @clipboard = text.empty? || %w[true false].include?(text) ? "auto" : value.to_s
51
+ end
52
+
53
+ def apply_replace(value)
54
+ return @warnings << "replace must be a mapping; ignored" unless value.is_a?(Hash)
55
+
56
+ pairs = value.map { |k, v| [k.to_s, v.to_s] }
57
+ @replace = pairs.sort_by { |k, _| -k.length }.to_h
58
+ end
59
+
60
+ def apply_secrets(value)
61
+ return @warnings << "secrets must be a list; ignored" unless value.is_a?(Array)
62
+
63
+ value.each do |src|
64
+ next @warnings << "blank secret pattern ignored" if src.to_s.strip.empty?
65
+
66
+ @secrets << Regexp.new(src.to_s)
67
+ rescue RegexpError => e
68
+ @warnings << "invalid secret pattern #{src.inspect}: #{e.message}"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,133 @@
1
+ require_relative "clipboard"
2
+
3
+ module Oops
4
+ # `oops doctor`: one `label: value` line per check (spec 7.8). Best effort:
5
+ # it never raises, whatever state the environment is in.
6
+ module Doctor
7
+ module_function
8
+
9
+ def report(env:, config:, sessions_dir:, tty:, which: Clipboard::DEFAULT_WHICH)
10
+ lines = []
11
+ lines.concat(recording_lines(env, tty))
12
+ lines << shell_line(env)
13
+ lines << clipboard_line(config, env, which)
14
+ lines << "config: #{config_line(env, config)}"
15
+ lines << sessions_line(sessions_dir)
16
+ lines << "ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM})"
17
+ lines.concat(warnings(env))
18
+ "#{lines.join("\n")}\n"
19
+ end
20
+
21
+ def recording_lines(env, tty)
22
+ session = env["OOPS_SESSION"].to_s
23
+ return ["recording: no"] if session.empty?
24
+
25
+ lines = []
26
+ state = File.file?(session) ? session : "#{session} - log file missing"
27
+ lines << "recording: yes (#{state})"
28
+ lines << terminal_line(env["OOPS_TTY"].to_s, tty.to_s)
29
+ status = read_status(session)
30
+ lines << "log writable: #{status == 'ok' ? 'yes' : 'no'} (#{status.empty? ? 'no status file' : status})"
31
+ lines << "paused: #{File.exist?("#{session}.paused") ? 'yes' : 'no'}"
32
+ lines
33
+ end
34
+
35
+ def terminal_line(recorded, here)
36
+ return "this terminal: recorded" if !here.empty? && recorded == here
37
+
38
+ "this terminal: not the recorded tty (this: #{here.empty? ? 'unknown' : here}, " \
39
+ "recorded: #{recorded.empty? ? 'unknown' : recorded})"
40
+ end
41
+
42
+ def read_status(session)
43
+ File.read("#{session}.status").strip
44
+ rescue SystemCallError, IOError
45
+ ""
46
+ end
47
+
48
+ def shell_line(env)
49
+ shell = File.basename(env["SHELL"].to_s)
50
+ shell = "unknown" if shell.empty?
51
+ version = shell_version(env)
52
+ hooks = env["__OOPS_SEQ"].to_s.empty? ? "missing" : "installed"
53
+ "shell: #{[shell, version].compact.join(' ')}, hooks: #{hooks}"
54
+ end
55
+
56
+ def shell_version(env)
57
+ env["ZSH_VERSION"] || env["BASH_VERSION"]
58
+ end
59
+
60
+ def clipboard_line(config, env, which)
61
+ backend = Clipboard.detect(config.clipboard, env: env, which: which)
62
+ "clipboard: #{backend.name}"
63
+ rescue StandardError => e
64
+ "clipboard: unknown (#{e.message})"
65
+ end
66
+
67
+ def config_line(env, config)
68
+ path = Paths.config_file(env: env)
69
+ state = if config.warnings.any? then config.warnings.join("; ")
70
+ elsif File.file?(path) then "ok"
71
+ else "missing"
72
+ end
73
+ "#{path} (#{state})"
74
+ rescue StandardError => e
75
+ "unknown (#{e.message})"
76
+ end
77
+
78
+ # Counts the session logs themselves, not their .status/.paused files.
79
+ def sessions_line(dir)
80
+ return "sessions: 0 logs, 0.0 MB" unless File.directory?(dir)
81
+
82
+ logs = Dir.children(dir).map { |n| File.join(dir, n) }.select { |f| f.end_with?(".jsonl") && File.file?(f) }
83
+ bytes = logs.sum { |f| File.size(f) }
84
+ "sessions: #{logs.size} #{logs.size == 1 ? 'log' : 'logs'}, #{format('%.1f', bytes / 1_048_576.0)} MB"
85
+ rescue SystemCallError
86
+ "sessions: unreadable"
87
+ end
88
+
89
+ def warnings(env)
90
+ env["SHELL"].to_s.end_with?("bash") ? bash_warnings(env) : []
91
+ rescue StandardError
92
+ []
93
+ end
94
+
95
+ def bash_warnings(env)
96
+ home = env["HOME"].to_s.empty? ? Dir.home : env["HOME"]
97
+ rc = File.join(home, ".bashrc")
98
+ return [] unless File.file?(rc)
99
+
100
+ lines = after_init_line(File.read(rc).force_encoding(Encoding::UTF_8).scrub)
101
+ out = []
102
+ if lines.any? { |l| sets_debug_trap?(l) }
103
+ out << "warning: ~/.bashrc sets a DEBUG trap, which can replace the oops hook; " \
104
+ "set it before the oops init line or chain it"
105
+ end
106
+ if lines.any? { |l| l.match?(/\A(export\s+)?PROMPT_COMMAND=["']?(?!.*PROMPT_COMMAND)/) }
107
+ out << "warning: ~/.bashrc assigns PROMPT_COMMAND without appending; " \
108
+ "this can drop the oops hook if it runs after the oops init line"
109
+ end
110
+ out
111
+ rescue SystemCallError, IOError
112
+ []
113
+ end
114
+
115
+ # The rc lines after the `oops init bash` line, without comments. Settings
116
+ # made before that line are taken over by the snippet, so only later ones
117
+ # can drop the hooks; without an init line there is nothing to check.
118
+ def after_init_line(text)
119
+ lines = text.lines.map { |l| l.strip.sub(/(?:\A|\s+)#.*\z/, "") }
120
+ init = lines.index { |l| l.match?(/oops['"]?\s+init\s+bash\b/) }
121
+ init ? lines.drop(init + 1) : []
122
+ end
123
+
124
+ # `trap ... DEBUG` that sets a trap; `trap - DEBUG` (reset), `trap -p`
125
+ # and `trap -l` do not.
126
+ def sets_debug_trap?(line)
127
+ args = line[/\Atrap\s+(.*)/, 1]
128
+ return false if args.nil? || args.match?(/\A-(?:[pl]\b|\s|\z)/)
129
+
130
+ args.match?(/\bDEBUG\b/)
131
+ end
132
+ end
133
+ end