oops-cli 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 238211aa8b381cf9998c6a744627d61d276be3350aa273c9d7fbdba8c078125a
4
- data.tar.gz: 1858aff54bd54535e3aa2ebc5b5a0c18b4f6168572c1e55e1dd0d13b97f31fdd
3
+ metadata.gz: f8a84c3fefd0069ad9b8f6be16f6a14957ed36d3449232c0b8bdbf69f1fe817b
4
+ data.tar.gz: 8f12fe23babadb66f8c1e156a966c850d564c62c78781df1faef3366a1569b09
5
5
  SHA512:
6
- metadata.gz: dcae845a6bc939a775258eba59a360dfff475dc91af52be47d7495eea915de21cd04ae1c82afc1ea157df0db3e84b0b576e029d1731e6347c3faa429cd18e0d9
7
- data.tar.gz: 14472ae5e385e10142be06be2264eff1d828415fca08388fba48d66d8a7c258b64f8bde75da0f86f94b2ef511cd2b331de972563267f597595aadb7eac074d9c
6
+ metadata.gz: 429be7bd8136a0a8d3b1a128a485bb58f6eeb71bd478743e728f2a6c5e9335b8d4362db86c72b5b8c177949d4400b54e239c81e9a77528a5c28c430ce005df88
7
+ data.tar.gz: a4448becb14db389e6bb58da82a9958135603d93f42fb5de46d2193e45fb7b277c0dd72f1584be76da4d2ad35348f45d9703651d92ab6a3ae6ce523534a8fc9f
data/CHANGELOG.ja.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  [English](CHANGELOG.md) | 日本語
4
4
 
5
+ ## 0.2.0 - 2026-11-01
6
+
7
+ - `oops status` を追加。`recording`・`paused`・`off` のどれかを 1 行で出力し、記録中のときだけ終了コード 0 を返します。プロンプトやスクリプトから、このシェルが記録されているか確認できます。
8
+ - `--time` オプションを追加。各 `$ コマンド` の前に開始時刻(ローカル時刻)を `# 2026-10-31 19:30:09 +09:00` の行として入れ、`--github` の要約にも足します。
9
+ - `--output-only` オプションを追加。`$ コマンド` の行を省き、出力と `(exit N)` だけをコピーします。
10
+ - `oops` と `oops err` に `-n N` オプションを追加。`oops -n 3` は `oops 3` と同じで、`oops err -n 2` は直近の失敗した 2 コマンドを(成功したコマンドは飛ばして)古い順にコピーします。[@ricky9408](https://github.com/ricky9408) さんによる貢献([#22](https://github.com/ysb3996-blip/oops/pull/22))。
11
+ - メールアドレスのマスクが、`/opt/homebrew/opt/mysql@8.4` や `go@1.27` のような版付きの名前を伏せないように修正。ドメインの最後の部分が、実際のトップレベルドメインのように英字のときだけ伏せます([#24](https://github.com/ysb3996-blip/oops/issues/24))。
12
+
5
13
  ## 0.1.0 - 2026-10-30
6
14
 
7
15
  最初のバージョン。
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  English | [日本語](CHANGELOG.ja.md)
4
4
 
5
+ ## 0.2.0 - 2026-11-01
6
+
7
+ - `oops status` prints `recording`, `paused` or `off` on one line and exits 0 only when recording, so a prompt or script can show whether this shell is recorded.
8
+ - `--time` option: adds the local time each command started as a `# 2026-10-31 19:30:09 +09:00` line before `$ command`, and to the `--github` summary.
9
+ - `--output-only` option: leaves out the `$ command` line and copies only the output and `(exit N)`.
10
+ - `-n N` option for `oops` and `oops err`: `oops -n 3` is the same as `oops 3`, and `oops err -n 2` copies the last 2 failed commands (oldest first), skipping over successful ones. Contributed by [@ricky9408](https://github.com/ricky9408) in [#22](https://github.com/ysb3996-blip/oops/pull/22).
11
+ - Email masking no longer hides versioned names such as `/opt/homebrew/opt/mysql@8.4` or `go@1.27`: the last part of the domain must now be letters, like a real top-level domain ([#24](https://github.com/ysb3996-blip/oops/issues/24)).
12
+
5
13
  ## 0.1.0 - 2026-10-30
6
14
 
7
15
  First version.
data/README.ja.md CHANGED
@@ -2,8 +2,12 @@
2
2
 
3
3
  [English](README.md) | 日本語
4
4
 
5
+ [![Gem Version](https://img.shields.io/gem/v/oops-cli)](https://rubygems.org/gems/oops-cli) [![CI](https://github.com/ysb3996-blip/oops/actions/workflows/ci.yml/badge.svg)](https://github.com/ysb3996-blip/oops/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
5
7
  直前のシェルコマンドとその出力を、貼り付けられる形でクリップボードにコピーします。
6
8
 
9
+ ![デモ: テストが失敗し、oops がコマンドと出力をコピーし、コメント行と pbpaste でクリップボードの中身(コードブロック)を見せる。続いて cat .env、oops で API キーが [secret] になる](docs/demo.gif)
10
+
7
11
  コマンドを実行して失敗した。その結果をチャットのアシスタントや Slack の同僚、GitHub の issue に見せたい。普通は、出力をドラッグで選択してコピーし、貼り付けて、コードフェンスを自分で付ける、という手間がかかります。oops なら 2 ステップです。
8
12
 
9
13
  1. `oops`(直前の失敗したコマンドなら `oops err`)と打つ。
@@ -48,11 +52,13 @@ eval "$(oops init bash)" # bash
48
52
  | コマンド | 内容 |
49
53
  |---|---|
50
54
  | `oops` | 直前のコマンドとその出力をコピーする |
51
- | `oops 3` | 直前の 3 コマンドを(古い順に)1 つのコードブロックとしてコピーする |
55
+ | `oops 3` / `oops -n 3` | 直前の 3 コマンドを(古い順に)1 つのコードブロックとしてコピーする |
52
56
  | `oops err` | 終了コードが 0 以外だった直近のコマンドをコピーする(成功したコマンドは飛ばす) |
57
+ | `oops err -n 2` | 直近の失敗した 2 コマンドを古い順にコピーする(成功したコマンドは飛ばす) |
53
58
  | `oops init zsh` / `oops init bash` | シェルのスニペットを出力する(`eval` 用) |
54
59
  | `oops doctor` | 記録・フック・クリップボード・設定・ログサイズを診断する |
55
60
  | `oops pause` / `oops resume` | 記録を止める/再開する。停止中は何もディスクに書かれない |
61
+ | `oops status` | `recording`・`paused`・`off` のどれかを 1 行で出力する(スクリプトやプロンプト向け) |
56
62
  | `oops record -- <shell>` | 内部用: 記録用プログラムの下でシェルを起動する。スニペットが呼び出す |
57
63
  | `oops --version`, `oops --help` | バージョンと使い方 |
58
64
 
@@ -60,11 +66,16 @@ eval "$(oops init bash)" # bash
60
66
 
61
67
  | オプション | 内容 |
62
68
  |---|---|
69
+ | `-n N` | 直前の N コマンドをコピーする(`err` と併用すると直近の失敗した N コマンド) |
63
70
  | `--github` | GitHub の issue / PR コメント向けの書式にする |
64
71
  | `--tail N` | 出力の最後の N 行だけ残す |
65
72
  | `--head N` | 出力の最初の N 行だけ残す(`--tail` と併用すると両端を残す) |
73
+ | `--time` | 各コマンドの開始時刻を `# 2026-10-31 19:30:09 +09:00` の行として付ける |
74
+ | `--output-only` | `$ コマンド` の行を省き、出力だけをコピーする |
66
75
  | `--show-secrets` | 秘密情報をマスクせず、置換テーブルも適用しない |
67
76
 
77
+ `N` は正の整数で指定します。件数は位置引数の `N` または `-n N` のどちらかで 1 回だけ指定でき、併用や `-n` の繰り返しは使い方の誤りになります。対象のコマンドが指定件数より少なければ、見つかった分をコピーします。`-n` は上記の書式オプションと併用できます。
78
+
68
79
  `oops` 自身はコピー対象になりません。直前のコマンドが `oops` だった場合は、その前のコマンドがコピーされます。
69
80
 
70
81
  終了コード: `0` コピーした、`1` コピーしなかった(記録していない、まだコマンドがない、失敗したコマンドがない、一時停止中、クリップボードのコマンドが失敗)、`2` 使い方の誤り。
@@ -118,6 +129,22 @@ $ bundle exec rspec
118
129
 
119
130
  `oops --tail 50` は出力の最後の 50 行を残し、その前に `... 2,950 lines omitted ...` を入れます。`--head 20` は最初の 20 行を残し、その後ろに同じ印を入れます。両方指定すると、中間が印 1 行に置き換わります。`oops 3` では、行数の制限は各コマンドごとに適用されます。
120
131
 
132
+ ### `--time` と `--output-only`
133
+
134
+ `oops --time` は、各 `$ コマンド` の前にコマンドを始めたローカル時刻を `#` の行として入れ、`--github` の要約にも時刻を足します。`oops --output-only` は `$ コマンド` の行を省いて出力と `(exit N)` だけを残します。画面に出る `copied: $ ...` の行と `--github` の要約には、コマンドの代わりに終了コードと行数だけが出ます。どちらも他のオプションと併用できます。
135
+
136
+ ````
137
+ ```
138
+ # 2026-10-31 19:30:09 +09:00
139
+ ....F
140
+ (exit 1)
141
+ ```
142
+ ````
143
+
144
+ ### `oops status`
145
+
146
+ `oops status` は `recording`・`paused`・`off`(このシェルが記録されていない、またはログが削除された)のどれかを出力し、記録中のときだけ終了コード `0` を返します。エラーは出さないので、プロンプトやスクリプトから呼べます(例: [starship のカスタムモジュール](https://starship.rs/config/#custom-commands))。呼ぶたびに Ruby が起動するため 0.1〜0.2 秒ほどかかります。`off` のときの詳しい理由は `oops doctor` で分かります。
147
+
121
148
  ## 設定
122
149
 
123
150
  任意の設定ファイルは `${XDG_CONFIG_HOME:-~/.config}/oops/config.yml` です。
@@ -143,6 +170,8 @@ clipboard: auto # auto | pbcopy | wl-copy | xclip | xsel | clip.exe | osc52
143
170
 
144
171
  ## 仕組み
145
172
 
173
+ ![oops の仕組み: 録音機があなたとシェルの間にいて、コマンドはディスク上のノートに書かれ、oops と打つと最後のページを秘密を隠して写し、どこにでも貼れる](docs/how-it-works.ja.svg)
174
+
146
175
  1. `oops init` は、rc ファイルから `eval` するスニペットを出力します。まだ記録されていない端末の対話シェルでは、シェルを `oops record` の下で再起動します。
147
176
  2. `oops record` は、Ruby 製の小さな pty 中継です。キー入力とシェルの出力をそのまま通し、ウィンドウのリサイズをシェルに伝えます。
148
177
  3. シェルのフック(zsh は `preexec`/`precmd`、bash は `DEBUG` トラップと `PROMPT_COMMAND`)が、各コマンドの前後に目に見えない APC マーカーを出力します。中継がそれを取り除くので、端末には見えません。
@@ -152,6 +181,7 @@ clipboard: auto # auto | pbcopy | wl-copy | xclip | xsel | clip.exe | osc52
152
181
  ## プライバシーとセキュリティ
153
182
 
154
183
  - セッションログには、記録されたすべてのコマンドの生の出力が**マスクされないまま**入っています。マスクと置換テーブルはコピーするときにだけ適用されます。ログは `${XDG_STATE_HOME:-~/.local/state}/oops/sessions/` にあり、あなただけが読めます(ファイルは 0600、ディレクトリは 0700)。すべて削除するには `rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/oops"` を実行します(標準では `rm -rf ~/.local/state/oops`)。ディスクに残したくない秘密情報を表示する前には `oops pause` を使ってください。
184
+ - セキュリティ上の問題を非公開で報告する方法は [SECURITY.ja.md](SECURITY.ja.md) を見てください。公開の issue には書かないでください。
155
185
  - 記録される出力は信頼できません。実行したプログラムは、oops のマーカーに見せかけたテキストを出力できます。そのため oops は、`oops pause` / `oops resume` が作るフラグファイルと一致する一時停止・再開マーカーにだけ従います。出力によって黙って記録を止められることはありません。ただし、プログラムがログ上でコマンドの開始と終了の位置を偽装することはできるので、貼り付ける内容は確認してください。
156
186
 
157
187
  ## 既知の制限
@@ -207,11 +237,11 @@ bundle exec exe/oops doctor
207
237
 
208
238
  ```sh
209
239
  gem build oops-cli.gemspec
210
- gem install ./oops-cli-0.1.0.gem # ファイル名は lib/oops/version.rb に合わせる
240
+ gem install ./oops-cli-0.2.0.gem # ファイル名は lib/oops/version.rb に合わせる
211
241
  rbenv rehash # rbenv を使う場合のみ
212
242
  ```
213
243
 
214
- - gem は Ruby のバージョンごとに入ります。新しいタブが、インストールした Ruby とは別の Ruby を使っていると(たとえばリポジトリは 4.0.9 を指定しているのに、標準の Ruby が 3.3.5 の場合)、シェルに `oops: command not found` と出ます。その Ruby でも `gem install` を実行してください(例: `RBENV_VERSION=3.3.5 gem install ./oops-cli-0.1.0.gem`)。
244
+ - gem は Ruby のバージョンごとに入ります。新しいタブが、インストールした Ruby とは別の Ruby を使っていると(たとえばリポジトリは 4.0.9 を指定しているのに、標準の Ruby が 3.3.5 の場合)、シェルに `oops: command not found` と出ます。その Ruby でも `gem install` を実行してください(例: `RBENV_VERSION=3.3.5 gem install ./oops-cli-0.2.0.gem`)。
215
245
  - 変更のたびに入れ直してください。インストールされた gem はコピーで、チェックアウトへのリンクではありません。
216
246
  - 削除するには `gem uninstall oops-cli` です。
217
247
  - `eval "$(oops init zsh)"` は、プロンプトで打つだけでなく rc ファイルに書いてください。記録されるシェルは rc ファイルを読み直すため、行がないとフックが入りません(`oops doctor` に `hooks: missing` と出ます)。
data/README.md CHANGED
@@ -2,8 +2,12 @@
2
2
 
3
3
  English | [日本語](README.ja.md)
4
4
 
5
+ [![Gem Version](https://img.shields.io/gem/v/oops-cli)](https://rubygems.org/gems/oops-cli) [![CI](https://github.com/ysb3996-blip/oops/actions/workflows/ci.yml/badge.svg)](https://github.com/ysb3996-blip/oops/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
5
7
  Copy the last shell command and its output to the clipboard, ready to paste.
6
8
 
9
+ ![Demo: a test fails, oops copies the command and its output, a comment line and pbpaste show what is on the clipboard; then cat .env, oops, and the API key is shown as [secret]](docs/demo.gif)
10
+
7
11
  You ran a command and it failed. You want to show it to a chat assistant, a teammate on Slack, or a GitHub issue. Normally that means dragging to select the output, copying, pasting, and adding the code fences yourself. With oops it is two steps:
8
12
 
9
13
  1. Type `oops` (or `oops err` for the last failed command).
@@ -48,11 +52,13 @@ To turn oops off for one shell, start it with `OOPS_DISABLE=1` (for example `OOP
48
52
  | Command | What it does |
49
53
  |---|---|
50
54
  | `oops` | Copy the last command and its output |
51
- | `oops 3` | Copy the last 3 commands (oldest first) as one code block |
55
+ | `oops 3` / `oops -n 3` | Copy the last 3 commands (oldest first) as one code block |
52
56
  | `oops err` | Copy the last command that exited non-zero, skipping over successful ones |
57
+ | `oops err -n 2` | Copy the last 2 failed commands (oldest first), skipping over successful ones |
53
58
  | `oops init zsh` / `oops init bash` | Print the shell snippet (for `eval`) |
54
59
  | `oops doctor` | Diagnose recording, hooks, clipboard, config and log size |
55
60
  | `oops pause` / `oops resume` | Stop and restart recording. While paused, nothing is written to disk |
61
+ | `oops status` | Print `recording`, `paused` or `off` on one line, for scripts and prompts |
56
62
  | `oops record -- <shell>` | Internal: runs a shell under the recorder. The snippet calls this |
57
63
  | `oops --version`, `oops --help` | Version and usage |
58
64
 
@@ -60,11 +66,16 @@ Options for `oops`, `oops N` and `oops err`:
60
66
 
61
67
  | Option | What it does |
62
68
  |---|---|
69
+ | `-n N` | Copy the last N commands (with `err`, the last N failed commands) |
63
70
  | `--github` | Format for a GitHub issue or PR comment |
64
71
  | `--tail N` | Keep only the last N output lines |
65
72
  | `--head N` | Keep only the first N output lines (with `--tail`, keeps both ends) |
73
+ | `--time` | Add the time each command started as a `# 2026-10-31 19:30:09 +09:00` line |
74
+ | `--output-only` | Leave out the `$ command` line and copy only the output |
66
75
  | `--show-secrets` | Do not mask secrets and do not apply the replace table |
67
76
 
77
+ `N` must be a positive integer. Specify the command count once, either as a positional `N` or with `-n N`; combining them or repeating `-n` is a usage error. If fewer matching commands are available, oops copies those it finds. `-n` can be combined with the formatting options above.
78
+
68
79
  `oops` never copies itself: if the previous command was `oops`, you get the command before it.
69
80
 
70
81
  Exit codes: `0` copied, `1` nothing copied (not recording, no command yet, no failed command, paused, or the clipboard command failed), `2` usage error.
@@ -118,6 +129,22 @@ $ bundle exec rspec
118
129
 
119
130
  `oops --tail 50` keeps the last 50 lines of output and puts `... 2,950 lines omitted ...` before them. `--head 20` keeps the first 20 and puts the marker after. With both, the middle is replaced by one marker line. With `oops 3`, the limits apply to each command.
120
131
 
132
+ ### `--time` and `--output-only`
133
+
134
+ `oops --time` puts the local time the command started on a `#` line before each `$ command`, and adds it to the `--github` summary. `oops --output-only` leaves out the `$ command` line and keeps the output and `(exit N)`; the `copied: $ ...` line on screen and the `--github` summary then show the exit code and line count without the command. Both can be combined with the other options:
135
+
136
+ ````
137
+ ```
138
+ # 2026-10-31 19:30:09 +09:00
139
+ ....F
140
+ (exit 1)
141
+ ```
142
+ ````
143
+
144
+ ### `oops status`
145
+
146
+ `oops status` prints `recording`, `paused` or `off` (this shell is not recorded, or its log was deleted) and exits `0` only when recording. It prints no error, so a prompt or script can call it, for example with a [starship custom module](https://starship.rs/config/#custom-commands). Each call starts Ruby, which takes roughly 0.1 to 0.2 seconds; `oops doctor` tells more when the answer is `off`.
147
+
121
148
  ## Configuration
122
149
 
123
150
  Optional file at `${XDG_CONFIG_HOME:-~/.config}/oops/config.yml`:
@@ -143,6 +170,8 @@ Masking is pattern based, so it is a safety net, not a guarantee: a secret in a
143
170
 
144
171
  ## How it works
145
172
 
173
+ ![How oops works: a recorder sits between you and your shell, every command is written to a notebook on your disk, typing oops copies the last page with secrets hidden, and you paste it anywhere](docs/how-it-works.svg)
174
+
146
175
  1. `oops init` prints a snippet that you `eval` from your rc file. In an interactive shell on a terminal that is not recorded yet, it restarts the shell under `oops record`.
147
176
  2. `oops record` is a small Ruby pty relay: it passes your keystrokes and the shell's output through untouched and forwards window resizes to the shell.
148
177
  3. Hooks in the shell (zsh `preexec`/`precmd`, bash `DEBUG` trap and `PROMPT_COMMAND`) emit invisible APC markers around each command. The relay strips them, so your terminal never sees them.
@@ -152,6 +181,7 @@ Masking is pattern based, so it is a safety net, not a guarantee: a secret in a
152
181
  ## Privacy and security
153
182
 
154
183
  - The session logs hold the raw, **unmasked** output of every recorded command. Masking and the replace table are applied only when you copy. The logs live under `${XDG_STATE_HOME:-~/.local/state}/oops/sessions/`, readable only by you (mode 0600, directory 0700). To delete all of them, run `rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/oops"` (by default `rm -rf ~/.local/state/oops`). Use `oops pause` before showing secrets you do not want on disk.
184
+ - To report a security problem privately, see [SECURITY.md](SECURITY.md). Please do not open a public issue for it.
155
185
  - Recorded output is untrusted: any program you run can print text that looks like oops's markers. oops therefore obeys a pause or resume marker only when it agrees with the flag file that `oops pause` and `oops resume` set, so output cannot silently pause recording. A program can still fake where a command starts and ends in the log, so check what you paste.
156
186
 
157
187
  ## Known limitations
@@ -207,11 +237,11 @@ To try it as your shell does, build and install the gem into the Ruby you are us
207
237
 
208
238
  ```sh
209
239
  gem build oops-cli.gemspec
210
- gem install ./oops-cli-0.1.0.gem # the file name follows lib/oops/version.rb
240
+ gem install ./oops-cli-0.2.0.gem # the file name follows lib/oops/version.rb
211
241
  rbenv rehash # only with rbenv
212
242
  ```
213
243
 
214
- - A gem is installed per Ruby version. If a new tab uses another Ruby than the one you installed into (for example the repository pins 4.0.9, but your global Ruby is 3.3.5), the shell prints `oops: command not found`. Run `gem install` again under that Ruby, e.g. `RBENV_VERSION=3.3.5 gem install ./oops-cli-0.1.0.gem`.
244
+ - A gem is installed per Ruby version. If a new tab uses another Ruby than the one you installed into (for example the repository pins 4.0.9, but your global Ruby is 3.3.5), the shell prints `oops: command not found`. Run `gem install` again under that Ruby, e.g. `RBENV_VERSION=3.3.5 gem install ./oops-cli-0.2.0.gem`.
215
245
  - Reinstall after every change. The installed gem is a copy, not a link to your checkout.
216
246
  - To remove it: `gem uninstall oops-cli`.
217
247
  - Put `eval "$(oops init zsh)"` in your rc file, not just at the prompt: the recorded shell reads the rc file again, and without the line it has no hooks (`oops doctor` shows `hooks: missing`).
data/SECURITY.ja.md ADDED
@@ -0,0 +1,34 @@
1
+ # セキュリティポリシー
2
+
3
+ [English](SECURITY.md) | 日本語
4
+
5
+ oops はシェルの操作を録画し、秘密情報を含みうる文字列を扱います。それがうまくいかないケースを見つけたら、まず非公開で教えてください。
6
+
7
+ ## 対象バージョン
8
+
9
+ | バージョン | 対応 |
10
+ |---|---|
11
+ | RubyGems 上の最新の 0.x リリース | あり |
12
+ | それより古いリリース | なし。アップグレードしてください |
13
+
14
+ ## セキュリティ問題とみなすもの
15
+
16
+ - oops がマスクすると言っている形式の秘密情報(README の「設定」を参照)が、マスクされずにコピーされる。
17
+ - セッションログやそのディレクトリが、ファイル `0600`・ディレクトリ `0700` より緩い権限で作られる。
18
+ - 録画機(`oops record`)が、説明された振る舞いを超えて何かをする。どこかへデータを送る、勝手にコマンドを実行する、状態ディレクトリの外に書き込む、など。
19
+ - プログラムの出力によって、ユーザーの操作なしに記録が止まったり再開したり、oops を操れたりする。
20
+ - 設定ファイルや独自の `clipboard:` コマンドで、本来できないはずのコードが実行できる。
21
+
22
+ oops が対応を謳っていない形式の秘密情報が通るのは脆弱性ではありません。マスクはパターンに基づくもので、README にもそう書いています。パターンの追加は通常の issue で頼んでください([issue #15](https://github.com/ysb3996-blip/oops/issues/15) を参照)。
23
+
24
+ ## 報告方法
25
+
26
+ GitHub の非公開の脆弱性報告を使ってください: [アドバイザリの下書きを作る](https://github.com/ysb3996-blip/oops/security/advisories/new)。メンテナだけが読めます。セキュリティ問題は、公開の issue や pull request には書かないでください。
27
+
28
+ 報告には、**実際の秘密情報やセッションログを絶対に貼らないでください**。秘密情報は同じ形の作り物の値(例: `sk-live-XXXXXXXXXXXXXXXX`)に置き換え、ログはファイルを添付せず該当行を言葉で説明してください。セッションログには、記録した全コマンドの生の(マスクされていない)出力が入っています。
29
+
30
+ ## 対応の目安
31
+
32
+ - 7 日以内に受け取りの連絡をします。
33
+ - パッチリリースで修正し、CHANGELOG に変更を載せます。希望しない場合を除き、そこに報告者の名前を記載します。
34
+ - アドバイザリは修正版のリリース後に公開します。
data/SECURITY.md ADDED
@@ -0,0 +1,34 @@
1
+ # Security policy
2
+
3
+ English | [日本語](SECURITY.ja.md)
4
+
5
+ oops records your shell session and handles text that may contain secrets. If you find a way for that to go wrong, please tell us privately first.
6
+
7
+ ## Supported versions
8
+
9
+ | Version | Supported |
10
+ |---|---|
11
+ | Latest 0.x release on RubyGems | Yes |
12
+ | Older releases | No, please upgrade |
13
+
14
+ ## What counts as a security issue
15
+
16
+ - A secret in a format oops says it masks (see "Configuration" in the README) that is copied unmasked.
17
+ - Session logs, or their directory, created with looser permissions than `0600` for files and `0700` for the directory.
18
+ - The recorder (`oops record`) doing anything beyond its documented behavior: sending data anywhere, running commands on its own, or writing outside the state directory.
19
+ - Program output that can pause or resume recording, or otherwise control oops, without the user asking for it.
20
+ - The config file or a custom `clipboard:` command being able to run code it should not.
21
+
22
+ A secret in a format oops does not claim to know is not a vulnerability: masking is pattern based and the README says so. Ask for the pattern in a regular issue instead (see [issue #15](https://github.com/ysb3996-blip/oops/issues/15)).
23
+
24
+ ## How to report
25
+
26
+ Use GitHub's private vulnerability reporting: [open a draft advisory](https://github.com/ysb3996-blip/oops/security/advisories/new). Only the maintainer can see it. Please do not open a public issue or pull request for a security problem.
27
+
28
+ In the report, **never paste a real secret or a session log**. Replace secrets with made-up values of the same shape (for example `sk-live-XXXXXXXXXXXXXXXX`), and describe the log line instead of attaching the file. Session logs hold the raw, unmasked output of every recorded command.
29
+
30
+ ## What to expect
31
+
32
+ - An acknowledgement within 7 days.
33
+ - A fix in a patch release, with the change listed in the CHANGELOG. You will be credited there unless you ask not to be.
34
+ - The advisory is published after the fix is released.
data/lib/oops/cli.rb CHANGED
@@ -11,6 +11,9 @@ module Oops
11
11
 
12
12
  NO_CLIPBOARD = "no clipboard tool found (tried pbcopy, wl-copy, xclip, xsel, clip.exe, OSC 52).".freeze
13
13
  POSITIVE_INTEGER = /\A\d+\z/
14
+ CopyTarget = Data.define(:count, :failed_only)
15
+ COUNT_REPEATED = "specify the command count only once (N or -n)".freeze
16
+ private_constant :CopyTarget, :COUNT_REPEATED
14
17
 
15
18
  def self.run(argv, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr)
16
19
  case argv.first
@@ -18,14 +21,24 @@ module Oops
18
21
  when "init" then return init(argv.drop(1), stdout: stdout, stderr: stderr)
19
22
  when "doctor" then return doctor(argv.drop(1), env: env, stdin: stdin, stdout: stdout)
20
23
  when "pause", "resume" then return toggle(argv.first, argv.drop(1), env: env, stdout: stdout)
24
+ when "status" then return status(argv.drop(1), env: env, stdout: stdout, stderr: stderr)
21
25
  end
22
26
 
23
- options = { head: nil, tail: nil, github: false, show_secrets: false }
27
+ options = { head: nil, tail: nil, github: false, show_secrets: false, time: false, output_only: false }
28
+ count = nil
24
29
  parser = OptionParser.new do |o|
25
- o.banner = "Usage: oops [N | err] [options]\n oops init <shell> | pause | resume | doctor"
30
+ o.banner = "Usage: oops [N | err] [-n N] [options]\n " \
31
+ "oops init <shell> | pause | resume | status | doctor"
32
+ o.on("-n N", "Copy the last N commands (with err, last N failures)") do |v|
33
+ raise UsageError, COUNT_REPEATED if count
34
+
35
+ count = positive(v, "-n")
36
+ end
26
37
  o.on("--github", "Format for a GitHub issue or PR comment") { options[:github] = true }
27
38
  o.on("--tail N", "Keep only the last N output lines") { |v| options[:tail] = positive(v, "--tail") }
28
39
  o.on("--head N", "Keep only the first N output lines") { |v| options[:head] = positive(v, "--head") }
40
+ o.on("--time", "Add the time each command started") { options[:time] = true }
41
+ o.on("--output-only", "Leave out the command line, keep the output") { options[:output_only] = true }
29
42
  o.on("--show-secrets", "Do not mask secrets or apply the replace table") { options[:show_secrets] = true }
30
43
  o.on("-v", "--version", "Print version and exit") do
31
44
  stdout.puts "oops #{VERSION}"
@@ -36,7 +49,7 @@ module Oops
36
49
  return 0
37
50
  end
38
51
  end
39
- copy(target(parser.parse(argv)), options, env: env, stdout: stdout, stderr: stderr)
52
+ copy(target(parser.parse(argv), count: count), options, env: env, stdout: stdout, stderr: stderr)
40
53
  rescue OptionParser::ParseError, UsageError => e
41
54
  stderr.puts "oops: #{e.message}"
42
55
  2
@@ -51,17 +64,18 @@ module Oops
51
64
  value.to_i
52
65
  end
53
66
 
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)
67
+ # Count comes from either a positional N or -n N. "err" filters the
68
+ # selection to failed commands; without a count, one command is copied.
69
+ def self.target(args, count:)
57
70
  raise UsageError, "unexpected argument: #{args[1]}" if args.size > 1
58
71
 
59
72
  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/)
73
+ return CopyTarget.new(count: count || 1, failed_only: arg == "err") if arg.nil? || arg == "err"
74
+
75
+ raise UsageError, "unknown command: #{arg}" unless arg.match?(/\A[+-]?\d/)
76
+ raise UsageError, COUNT_REPEATED if count
63
77
 
64
- raise UsageError, "unknown command: #{arg}"
78
+ CopyTarget.new(count: positive(arg, "N"), failed_only: false)
65
79
  end
66
80
 
67
81
  def self.copy(target, options, env:, stdout:, stderr:)
@@ -70,8 +84,8 @@ module Oops
70
84
 
71
85
  config = Config.load(env: env)
72
86
  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?
87
+ records = log.last(target.count, failed_only: target.failed_only)
88
+ raise Failure, (target.failed_only ? "no failed command found." : "no command found yet.") if records.empty?
75
89
 
76
90
  home = env["HOME"].to_s.empty? ? Dir.home : env["HOME"]
77
91
  result = Formatter.new(config: config, home: home, **options).format(records)
@@ -144,6 +158,24 @@ module Oops
144
158
  stdout.flush
145
159
  end
146
160
 
161
+ # `oops status`: one word on stdout. "recording" exits 0; "paused" and
162
+ # "off" (this shell is not recorded, or its log is gone) exit 1 without a
163
+ # message, so a prompt can call it.
164
+ def self.status(args, env:, stdout:, stderr:)
165
+ unless args.empty?
166
+ stderr.puts "oops: usage: oops status"
167
+ return 2
168
+ end
169
+
170
+ path = env["OOPS_SESSION"].to_s
171
+ word = if path.empty? || !File.file?(path) then "off"
172
+ elsif SessionLog.new(path).paused? then "paused"
173
+ else "recording"
174
+ end
175
+ stdout.puts word
176
+ word == "recording" ? 0 : 1
177
+ end
178
+
147
179
  # `oops doctor`: diagnostics on stdout; always exits 0.
148
180
  def self.doctor(args, env:, stdin:, stdout:)
149
181
  raise UsageError, "usage: oops doctor" unless args.empty?
@@ -181,6 +213,6 @@ module Oops
181
213
  end
182
214
 
183
215
  private_class_method :positive, :target, :copy, :report_command, :report_counts, :recording_log, :toggle,
184
- :write_marker, :doctor
216
+ :write_marker, :status, :doctor
185
217
  end
186
218
  end
@@ -4,7 +4,9 @@ module Oops
4
4
  # Per field (command line and output) the pipeline is: strip ANSI, scrub
5
5
  # invalid UTF-8, shorten the home directory to "~", mask secrets, apply the
6
6
  # config replace table. Output is then truncated (head/tail) and fenced.
7
- # With show_secrets, the home/mask/replace steps are skipped.
7
+ # With show_secrets, the home/mask/replace steps are skipped. With time, a
8
+ # "# <local start time>" line precedes each command; with output_only, the
9
+ # "$ command" line is left out.
8
10
  class Formatter
9
11
  GITHUB_COLLAPSE_LINES = 30
10
12
 
@@ -13,13 +15,18 @@ module Oops
13
15
  # command_line: the formatted first command, without "$ " or indentation.
14
16
  Result = Struct.new(:text, :lines, :masked_count, :command_line)
15
17
 
16
- def initialize(config:, home: Dir.home, show_secrets: false, head: nil, tail: nil, github: false)
18
+ TIME_FORMAT = "%Y-%m-%d %H:%M:%S %:z".freeze
19
+
20
+ def initialize(config:, home: Dir.home, show_secrets: false, head: nil, tail: nil, github: false,
21
+ time: false, output_only: false)
17
22
  @config = config
18
23
  @home = home.to_s
19
24
  @show_secrets = show_secrets
20
25
  @head = head
21
26
  @tail = tail
22
27
  @github = github
28
+ @time = time
29
+ @output_only = output_only
23
30
  @masked = 0
24
31
  end
25
32
 
@@ -37,9 +44,21 @@ module Oops
37
44
  shown, count = shown_lines(clean(PromptSp.strip(record.output.to_s)))
38
45
  exit_code = record.exit
39
46
  exit_code = nil if exit_code.to_i.zero?
40
- body = ["$ #{command.gsub("\n", "\n ").gsub(/^ +$/, '')}", *shown]
47
+ stamp = start_time(record)
48
+ body = []
49
+ body << "# #{stamp}" if stamp
50
+ body << "$ #{command.gsub("\n", "\n ").gsub(/^ +$/, '')}" unless @output_only
51
+ body.concat(shown)
41
52
  body << "(exit #{exit_code})" if exit_code
42
- { command: command, exit: exit_code, count: count, content: body.join("\n") }
53
+ { command: command, exit: exit_code, count: count, time: stamp, content: body.join("\n") }
54
+ end
55
+
56
+ # Local time the command started, or nil without --time or for a record
57
+ # that has no start time.
58
+ def start_time(record)
59
+ return nil unless @time && record.started_at
60
+
61
+ Time.at(record.started_at.to_i).strftime(TIME_FORMAT)
43
62
  end
44
63
 
45
64
  # Returns [lines to show, real output line count]: trailing blank lines
@@ -98,13 +117,20 @@ module Oops
98
117
  parts = []
99
118
  parts << "exit #{block[:exit]}" if block[:exit]
100
119
  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"
120
+ parts << block[:time] if block[:time]
121
+ summary = parts.join(", ")
122
+ unless @output_only
123
+ command = escape_html("$ #{block[:command]}".gsub("\n", " "))
124
+ summary = "<code>#{command}</code> — #{summary}"
125
+ end
126
+ "<details>\n<summary>#{summary}</summary>\n\n#{fence}\n</details>\n"
103
127
  end
104
128
 
105
129
  def fenced(content, language)
106
130
  longest = content.scan(/`+/).map(&:length).max.to_i
107
131
  fence = "`" * [3, longest + 1].max
132
+ return "#{fence}#{language}\n#{fence}\n" if content.empty?
133
+
108
134
  "#{fence}#{language}\n#{content}\n#{fence}\n"
109
135
  end
110
136
 
data/lib/oops/secrets.rb CHANGED
@@ -26,7 +26,9 @@ module Oops
26
26
  # limit) everything after BEGIN is masked.
27
27
  /-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\z)/m,
28
28
  %r{\b[a-z][a-z0-9+.-]*://(?<secret>[^\s/@:]+:[^\s/@]+)@}i, # URL userinfo
29
- %r{[A-Za-z0-9._%+-]+@(?>[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)(?!:[\w~/])}, # email, not scp-style git@host:path
29
+ # Email. The last label must be letters, like a real TLD, so versioned
30
+ # names such as mysql@8.4 stay; the lookahead skips git@host:path.
31
+ %r{[A-Za-z0-9._%+-]+@(?>(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,})(?![A-Za-z0-9-])(?!:[\w~/])},
30
32
  # Public IPv4: not private/loopback/unspecified, and not part of a longer
31
33
  # dotted number such as a version string (1.2.3.4.5).
32
34
  Regexp.new(
@@ -93,11 +93,13 @@ module Oops
93
93
  nil
94
94
  end
95
95
 
96
- # Newest count finished, non-oops records, oldest first.
97
- def last(count, skip_oops: true)
96
+ # Newest count finished, non-oops records, oldest first. With failed_only,
97
+ # only records with a non-zero integer exit status count toward the limit.
98
+ def last(count, skip_oops: true, failed_only: false)
98
99
  found = []
99
100
  each_backward do |r|
100
101
  next if r.exit.nil? || (skip_oops && r.oops_self?)
102
+ next if failed_only && (!r.exit.is_a?(Integer) || r.exit.zero?)
101
103
 
102
104
  found << r
103
105
  :stop if found.size >= count
@@ -106,10 +108,7 @@ module Oops
106
108
  end
107
109
 
108
110
  def last_failed
109
- each_backward do |r|
110
- return r if r.exit.is_a?(Integer) && r.exit != 0 && !r.oops_self?
111
- end
112
- nil
111
+ last(1, failed_only: true).first
113
112
  end
114
113
 
115
114
  def paused?
data/lib/oops/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Oops
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "0.2.0".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oops-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ysb3996-blip
@@ -22,6 +22,8 @@ files:
22
22
  - LICENSE
23
23
  - README.ja.md
24
24
  - README.md
25
+ - SECURITY.ja.md
26
+ - SECURITY.md
25
27
  - exe/oops
26
28
  - lib/oops.rb
27
29
  - lib/oops/ansi.rb