oops-cli 0.1.0 → 0.3.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: 6e1003df0d3be3f85135c9f36b34aaed5d5ce02002a22d1c75baafd235180b15
4
+ data.tar.gz: b67d4984e973482dc9c4f4938d14e65dfc4e99c7c149a2deea7a6aee0e2c2297
5
5
  SHA512:
6
- metadata.gz: dcae845a6bc939a775258eba59a360dfff475dc91af52be47d7495eea915de21cd04ae1c82afc1ea157df0db3e84b0b576e029d1731e6347c3faa429cd18e0d9
7
- data.tar.gz: 14472ae5e385e10142be06be2264eff1d828415fca08388fba48d66d8a7c258b64f8bde75da0f86f94b2ef511cd2b331de972563267f597595aadb7eac074d9c
6
+ metadata.gz: e6754adb99ca3b4ae7f0294eb84547f630fd6fcb9bbe9191cf9112e77d9590b6dd5561aa94300e2c19e683d1a9e50072358d5814485de8c355ece40e349ca360
7
+ data.tar.gz: ede05535447c896dca5e443e883c9c90d7b225a7ccea09ee0628601d8650f9b1642aa2098c7c0ebedb6d0c8433645134402352140d45cb2dffc25eac07425fba
data/CHANGELOG.ja.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  [English](CHANGELOG.md) | 日本語
4
4
 
5
+ ## 0.3.0 - 2026-11-02
6
+
7
+ - `oops clear` で閉じたタブのログを削除できます。`oops clear --all` は今のタブのログも空にします([#42](https://github.com/ysb3996-blip/oops/issues/42))。
8
+ - 変更: 閉じたタブのログは、最後のコマンドから 7 日たつと、タブが記録を始めるときに削除されるようになりました。日数は設定の `retention_days` で変えられます。`retention_days: false` にすると、50 MB の上限までは残ります([#42](https://github.com/ysb3996-blip/oops/issues/42))。
9
+ - fish に対応しました。`~/.config/fish/config.fish` に `oops init fish | source` を足します([#9](https://github.com/ysb3996-blip/oops/issues/9))。
10
+ - zsh のプラグインマネージャー(antidote・zinit・Oh My Zsh など)向けに `oops.plugin.zsh` を追加。clone したリポジトリの oops を動かすので、gem のインストールはいりません。
11
+ - Homebrew tap を用意しました: `brew install ysb3996-blip/oops/oops`。Homebrew の Ruby で oops を動かします。
12
+
13
+ ## 0.2.0 - 2026-11-01
14
+
15
+ - `oops status` を追加。`recording`・`paused`・`off` のどれかを 1 行で出力し、記録中のときだけ終了コード 0 を返します。プロンプトやスクリプトから、このシェルが記録されているか確認できます。
16
+ - `--time` オプションを追加。各 `$ コマンド` の前に開始時刻(ローカル時刻)を `# 2026-10-31 19:30:09 +09:00` の行として入れ、`--github` の要約にも足します。
17
+ - `--output-only` オプションを追加。`$ コマンド` の行を省き、出力と `(exit N)` だけをコピーします。
18
+ - `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))。
19
+ - メールアドレスのマスクが、`/opt/homebrew/opt/mysql@8.4` や `go@1.27` のような版付きの名前を伏せないように修正。ドメインの最後の部分が、実際のトップレベルドメインのように英字のときだけ伏せます([#24](https://github.com/ysb3996-blip/oops/issues/24))。
20
+
5
21
  ## 0.1.0 - 2026-10-30
6
22
 
7
23
  最初のバージョン。
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  English | [日本語](CHANGELOG.ja.md)
4
4
 
5
+ ## 0.3.0 - 2026-11-02
6
+
7
+ - `oops clear` deletes the logs of closed tabs, and `oops clear --all` also empties the current tab's log ([#42](https://github.com/ysb3996-blip/oops/issues/42)).
8
+ - Changed: logs of closed tabs are now deleted 7 days after their last command, when a tab starts recording. Set `retention_days` in the config to change the number of days, or `retention_days: false` to keep them until the 50 MB limit ([#42](https://github.com/ysb3996-blip/oops/issues/42)).
9
+ - fish support: add `oops init fish | source` to `~/.config/fish/config.fish` ([#9](https://github.com/ysb3996-blip/oops/issues/9)).
10
+ - `oops.plugin.zsh` for zsh plugin managers (antidote, zinit, Oh My Zsh, ...). It runs oops from the cloned repository, so no gem install is needed.
11
+ - Homebrew tap: `brew install ysb3996-blip/oops/oops`. The formula runs oops with Homebrew's Ruby.
12
+
13
+ ## 0.2.0 - 2026-11-01
14
+
15
+ - `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.
16
+ - `--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.
17
+ - `--output-only` option: leaves out the `$ command` line and copies only the output and `(exit N)`.
18
+ - `-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).
19
+ - 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)).
20
+
5
21
  ## 0.1.0 - 2026-10-30
6
22
 
7
23
  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`)と打つ。
@@ -17,9 +21,41 @@
17
21
  gem install oops-cli
18
22
  ```
19
23
 
20
- Homebrew tap: 準備中です。
24
+ Homebrew でも入れられます。
25
+
26
+ ```sh
27
+ brew install ysb3996-blip/oops/oops
28
+ ```
29
+
30
+ Homebrew 版は Homebrew の Ruby で oops を動かすので、`PATH` の先に古い Ruby があっても動きます。
31
+
32
+ macOS または Linux(WSL は Linux 扱い)、Ruby 3.3 以降、zsh・bash・fish のどれかが必要です。実行時に依存する gem はありません。
33
+
34
+ `gem install` は、そのとき有効な Ruby にだけ oops を入れます。gem は Ruby のバージョンごとに別々です。rbenv・asdf・mise などで別の Ruby を使うタブでは、`oops` が `command not found` のエラーになります(シェルの起動時にも出ます)。いつでも使える直し方は、有効な Ruby で `gem install oops-cli` をもう一度実行することです(rbenv なら続けて `rbenv rehash`)。`.ruby-version`・`.tool-versions`・`mise.toml` で Ruby を指定しているディレクトリの外では、代わりにインストールした Ruby を標準(グローバル)の Ruby に設定することもできます(例: `rbenv global 3.3.5`)。
35
+
36
+ ### zsh のプラグインマネージャーで入れる
37
+
38
+ zsh なら、プラグインとして読み込むこともできます。clone したリポジトリの oops をそのまま動かすので、gem のインストールはいりません(Ruby 3.3 以上は必要です)。下の「シェルの設定」の 1 行の代わりになります。
39
+
40
+ ```sh
41
+ # antidote: ~/.zsh_plugins.txt に追加
42
+ ysb3996-blip/oops
43
+
44
+ # zinit: ~/.zshrc に。`wait`(turbo モード)は付けない
45
+ zinit light ysb3996-blip/oops
46
+
47
+ # Oh My Zsh: clone してから ~/.zshrc の plugins=(...) に oops を追加
48
+ git clone https://github.com/ysb3996-blip/oops "${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/oops"
49
+
50
+ # プラグインマネージャーなし: clone してから ~/.zshrc に source の行を追加
51
+ git clone https://github.com/ysb3996-blip/oops ~/.oops
52
+ source ~/.oops/oops.plugin.zsh
53
+ ```
21
54
 
22
- macOS または Linux(WSL は Linux 扱い)、Ruby 3.3 以降、zsh または bash が必要です。実行時に依存する gem はありません。
55
+ - 読み込みを後回しにしないでください(zinit の `wait`、zsh-defer など)。プラグインはシェルを記録用に起動し直すので、最初のプロンプトより前に済ませる必要があります。
56
+ - 下の「シェルの設定」にある、行を置く場所の注意は、プラグインを読み込む場所にそのまま当てはまります。powerlevel10k の instant prompt を使っている場合、プラグインは instant prompt のブロックより上で読み込む必要がありますが、プラグインマネージャーはふつうそうなりません。その場合は rc ファイルの 1 行を使ってください。
57
+ - `PATH` の Ruby が古いと、`oops: ... init zsh failed (oops needs Ruby 3.3 or later); recording disabled` と出て、記録なしで普通に起動します。
58
+ - プラグインマネージャーは `main` ブランチから更新するので、最新のリリースより先の内容になることがあります。
23
59
 
24
60
  ## シェルの設定
25
61
 
@@ -30,14 +66,21 @@ eval "$(oops init zsh)" # zsh
30
66
  eval "$(oops init bash)" # bash
31
67
  ```
32
68
 
33
- そのあと新しいタブを開いてください。開いているタブでは `exec zsh`(bash なら `exec bash`)で反映されます。
69
+ fish では、`~/.config/fish/config.fish` に次の 1 行を足します。
70
+
71
+ ```fish
72
+ oops init fish | source
73
+ ```
74
+
75
+ そのあと新しいタブを開いてください。開いているタブでは `exec zsh`(bash なら `exec bash`、fish なら `exec fish`)で反映されます。
34
76
 
35
77
  この行を置く場所が重要です。
36
78
 
37
79
  - oops の入った Ruby を `PATH` に通す設定(rbenv、asdf、mise、Homebrew の `shellenv` など)より**後ろ**に置きます。macOS ではログインシェルが `PATH` をリセットするため、この行が先にあるとシステムの Ruby 2.6 が選ばれて oops が起動しないことがあります。oops が動かなくてもシェル自体は普通に起動します。記録されないだけです。
38
80
  - 2 回実行したくない重い処理や出力の多い処理(nvm、プロンプトテーマ、バナーなど)より**前**に置きます。このスニペットは、小さな記録用プログラム(`oops record`、pty 中継)の下でシェルをもう一度起動します。そのため、この行より上は外側のシェルと内側のシェルで 2 回実行され、下は内側のシェルでだけ実行されます。
39
81
  - powerlevel10k を使う場合は、`~/.zshrc` の先頭にある instant prompt のブロックより**上**に置きます。instant prompt は `~/.zshrc` の残りを読む間、シェルの入出力をリダイレクトし、その間スニペットは何もしません(実際の端末でだけ動作するため)。すると oops は黙って記録しなくなります。`~/.zshrc` に行があるのにタブが記録されないときは `oops doctor` を実行してください。`recording: no` と表示されます。
40
- - zsh ではシェルがログインシェルではない形で再起動されます。`~/.zlogin` にだけ書いた設定は、記録されるシェルには適用されません。`~/.zprofile` か `~/.zshrc`(この行より上)に置いてください。bash ではログインシェルは `-l` 付きで再起動されます。
82
+ - zsh ではシェルがログインシェルではない形で再起動されます。`~/.zlogin` にだけ書いた設定は、記録されるシェルには適用されません。`~/.zprofile` か `~/.zshrc`(この行より上)に置いてください。bash ではログインシェルは `-l` 付きで、fish では `--login` 付きで再起動されます。
83
+ - fish は最初のプロンプトを出す直前に再起動されます。そのため、行をどこに置いても、`config.fish` 全体が外側と記録される側の両方で実行されます(もっと早く再起動すると、プロンプトを出さない `fish -i -c` や `fish -i script.fish` まで再起動してしまうためです)。
41
84
 
42
85
  おかしいと感じたら `oops doctor` を実行します。このタブが記録されているか、シェルとフックの読み込み状況、使われるクリップボードの方法、設定ファイル、セッションログのサイズ、Ruby のバージョンを報告します。
43
86
 
@@ -48,11 +91,14 @@ eval "$(oops init bash)" # bash
48
91
  | コマンド | 内容 |
49
92
  |---|---|
50
93
  | `oops` | 直前のコマンドとその出力をコピーする |
51
- | `oops 3` | 直前の 3 コマンドを(古い順に)1 つのコードブロックとしてコピーする |
94
+ | `oops 3` / `oops -n 3` | 直前の 3 コマンドを(古い順に)1 つのコードブロックとしてコピーする |
52
95
  | `oops err` | 終了コードが 0 以外だった直近のコマンドをコピーする(成功したコマンドは飛ばす) |
53
- | `oops init zsh` / `oops init bash` | シェルのスニペットを出力する(`eval` 用) |
96
+ | `oops err -n 2` | 直近の失敗した 2 コマンドを古い順にコピーする(成功したコマンドは飛ばす) |
97
+ | `oops init zsh` / `oops init bash` / `oops init fish` | シェルのスニペットを出力する(`eval` 用。fish は `\| source`) |
54
98
  | `oops doctor` | 記録・フック・クリップボード・設定・ログサイズを診断する |
55
99
  | `oops pause` / `oops resume` | 記録を止める/再開する。停止中は何もディスクに書かれない |
100
+ | `oops status` | `recording`・`paused`・`off` のどれかを 1 行で出力する(スクリプトやプロンプト向け) |
101
+ | `oops clear` / `oops clear --all` | 閉じたタブのログを削除する。`--all` を付けると、今のタブのログも空にする |
56
102
  | `oops record -- <shell>` | 内部用: 記録用プログラムの下でシェルを起動する。スニペットが呼び出す |
57
103
  | `oops --version`, `oops --help` | バージョンと使い方 |
58
104
 
@@ -60,11 +106,16 @@ eval "$(oops init bash)" # bash
60
106
 
61
107
  | オプション | 内容 |
62
108
  |---|---|
109
+ | `-n N` | 直前の N コマンドをコピーする(`err` と併用すると直近の失敗した N コマンド) |
63
110
  | `--github` | GitHub の issue / PR コメント向けの書式にする |
64
111
  | `--tail N` | 出力の最後の N 行だけ残す |
65
112
  | `--head N` | 出力の最初の N 行だけ残す(`--tail` と併用すると両端を残す) |
113
+ | `--time` | 各コマンドの開始時刻を `# 2026-10-31 19:30:09 +09:00` の行として付ける |
114
+ | `--output-only` | `$ コマンド` の行を省き、出力だけをコピーする |
66
115
  | `--show-secrets` | 秘密情報をマスクせず、置換テーブルも適用しない |
67
116
 
117
+ `N` は正の整数で指定します。件数は位置引数の `N` または `-n N` のどちらかで 1 回だけ指定でき、併用や `-n` の繰り返しは使い方の誤りになります。対象のコマンドが指定件数より少なければ、見つかった分をコピーします。`-n` は上記の書式オプションと併用できます。
118
+
68
119
  `oops` 自身はコピー対象になりません。直前のコマンドが `oops` だった場合は、その前のコマンドがコピーされます。
69
120
 
70
121
  終了コード: `0` コピーした、`1` コピーしなかった(記録していない、まだコマンドがない、失敗したコマンドがない、一時停止中、クリップボードのコマンドが失敗)、`2` 使い方の誤り。
@@ -118,6 +169,22 @@ $ bundle exec rspec
118
169
 
119
170
  `oops --tail 50` は出力の最後の 50 行を残し、その前に `... 2,950 lines omitted ...` を入れます。`--head 20` は最初の 20 行を残し、その後ろに同じ印を入れます。両方指定すると、中間が印 1 行に置き換わります。`oops 3` では、行数の制限は各コマンドごとに適用されます。
120
171
 
172
+ ### `--time` と `--output-only`
173
+
174
+ `oops --time` は、各 `$ コマンド` の前にコマンドを始めたローカル時刻を `#` の行として入れ、`--github` の要約にも時刻を足します。`oops --output-only` は `$ コマンド` の行を省いて出力と `(exit N)` だけを残します。画面に出る `copied: $ ...` の行と `--github` の要約には、コマンドの代わりに終了コードと行数だけが出ます。どちらも他のオプションと併用できます。
175
+
176
+ ````
177
+ ```
178
+ # 2026-10-31 19:30:09 +09:00
179
+ ....F
180
+ (exit 1)
181
+ ```
182
+ ````
183
+
184
+ ### `oops status`
185
+
186
+ `oops status` は `recording`・`paused`・`off`(このシェルが記録されていない、またはログが削除された)のどれかを出力し、記録中のときだけ終了コード `0` を返します。エラーは出さないので、プロンプトやスクリプトから呼べます(例: [starship のカスタムモジュール](https://starship.rs/config/#custom-commands))。呼ぶたびに Ruby が起動するため 0.1〜0.2 秒ほどかかります。`off` のときの詳しい理由は `oops doctor` で分かります。
187
+
121
188
  ## 設定
122
189
 
123
190
  任意の設定ファイルは `${XDG_CONFIG_HOME:-~/.config}/oops/config.yml` です。
@@ -129,12 +196,14 @@ replace: # コピー時に適用する単純な文字列置換
129
196
  secrets: # マスクする追加の正規表現
130
197
  - "sk_live_[A-Za-z0-9]+"
131
198
  clipboard: auto # auto | pbcopy | wl-copy | xclip | xsel | clip.exe | osc52 | 任意のコマンド(標準入力から読む)
199
+ retention_days: 7 # 閉じたタブのログをこの日数で削除する。false なら残す
132
200
  ```
133
201
 
134
202
  - 置換とマスクはコピーする瞬間に適用されます。記録済みのログは変更されないため、設定を変えると以前のコマンドにも反映されます。
135
203
  - `replace` のキーは長いものから順に、大文字小文字を区別して適用されます。
136
204
  - 未知のキーは警告されて無視されます。YAML が壊れている場合も警告が出て、設定なしで続行します。
137
205
  - `clipboard: auto` のとき、oops は macOS では `pbcopy`、WSL では `clip.exe`、Wayland では `wl-copy`、X11 では `xclip` か `xsel` を試し、だめなら OSC 52 端末エスケープシーケンスにフォールバックします(SSH 越しでもこれが使われます)。どれを選んだかは `oops doctor` で確認できます。
206
+ - `retention_days`(標準は 7)は、閉じたタブがログに最後に書き込んだ時点から数えます。古いログは、タブが記録を始めるときに削除されます。`false` にすると、閉じたタブのログは 50 MB の上限で削除されるまで残ります。
138
207
  - 自分で指定した `clipboard:` のコマンドは、独自のプロセスグループで 5 秒のタイムアウト付きで実行されます。そのため端末の読み取りや設定(パスワード入力、`stty` など)はできません。コピー中の Ctrl-C もそのコマンドには届きません。
139
208
 
140
209
  組み込みのマスク対象: AWS アクセスキー、GitHub / GitLab / Slack / npm / OpenAI・Anthropic(`sk-...`)/ Stripe / Google の API トークン、Slack の webhook URL、JWT、`Authorization` ヘッダーの値、秘密鍵ブロック(END 行がなくても)、`key`・`token`・`secret`・`password`・`passwd`・`pwd` を含む名前に代入された値、`--password <値>`・`curl -u user:pass`・`mysql -p<値>`・`docker login -p <値>` で渡されたパスワード、メールアドレス、URL 中の認証情報、公開 IPv4 アドレス(ループバックとプライベート範囲は対象外)。出力だけでなくコマンドラインにも適用されます。
@@ -143,15 +212,18 @@ clipboard: auto # auto | pbcopy | wl-copy | xclip | xsel | clip.exe | osc52
143
212
 
144
213
  ## 仕組み
145
214
 
146
- 1. `oops init` は、rc ファイルから `eval` するスニペットを出力します。まだ記録されていない端末の対話シェルでは、シェルを `oops record` の下で再起動します。
215
+ ![oops の仕組み: 録音機があなたとシェルの間にいて、コマンドはディスク上のノートに書かれ、oops と打つと最後のページを秘密を隠して写し、どこにでも貼れる](docs/how-it-works.ja.svg)
216
+
217
+ 1. `oops init` は、rc ファイルから `eval` する(fish では `| source` する)スニペットを出力します。まだ記録されていない端末の対話シェルでは、シェルを `oops record` の下で再起動します。
147
218
  2. `oops record` は、Ruby 製の小さな pty 中継です。キー入力とシェルの出力をそのまま通し、ウィンドウのリサイズをシェルに伝えます。
148
- 3. シェルのフック(zsh は `preexec`/`precmd`、bash は `DEBUG` トラップと `PROMPT_COMMAND`)が、各コマンドの前後に目に見えない APC マーカーを出力します。中継がそれを取り除くので、端末には見えません。
149
- 4. コマンドが終わるたびに、中継は 1 行の JSON(コマンド、ディレクトリ、時刻、終了コード、生の出力)をタブごとのログに追記します。置き場所は `${XDG_STATE_HOME:-~/.local/state}/oops/sessions/` です。出力は 1 コマンドあたり 1 MB まで(末尾を残す)。各タブのログは 50 MB までで、超えると新しいコマンドだけを残して書き直されます(約 37 MiB まで縮む)。タブが記録を始めるとき、閉じたタブのログは古い順に、全ログの合計が 50 MB 以下になるまで削除されます。開いているタブのログは削除されないので、忙しいタブが複数あると合計がそれを超えることがあります。
219
+ 3. シェルのフック(zsh は `preexec`/`precmd`、bash は `DEBUG` トラップと `PROMPT_COMMAND`、fish は `fish_preexec`/`fish_postexec` イベント)が、各コマンドの前後に目に見えない APC マーカーを出力します。中継がそれを取り除くので、端末には見えません。
220
+ 4. コマンドが終わるたびに、中継は 1 行の JSON(コマンド、ディレクトリ、時刻、終了コード、生の出力)をタブごとのログに追記します。置き場所は `${XDG_STATE_HOME:-~/.local/state}/oops/sessions/` です。出力は 1 コマンドあたり 1 MB まで(末尾を残す)。各タブのログは 50 MB までで、超えると新しいコマンドだけを残して書き直されます(約 37 MiB まで縮む)。タブが記録を始めるとき、閉じたタブのログのうち `retention_days`(標準は 7 日)を過ぎたものが削除され、続いて残りが古い順に、全ログの合計が 50 MB 以下になるまで削除されます。開いているタブのログは削除されないので、忙しいタブが複数あると合計がそれを超えることがあります。
150
221
  5. `oops` は現在のタブのログの末尾を読み、テキストを整えてマスクし、コードブロックを組み立ててクリップボードに渡します。コマンドを再実行することはありません。
151
222
 
152
223
  ## プライバシーとセキュリティ
153
224
 
154
- - セッションログには、記録されたすべてのコマンドの生の出力が**マスクされないまま**入っています。マスクと置換テーブルはコピーするときにだけ適用されます。ログは `${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` を使ってください。
225
+ - セッションログには、記録されたすべてのコマンドの生の出力が**マスクされないまま**入っています。マスクと置換テーブルはコピーするときにだけ適用されます。ログは `${XDG_STATE_HOME:-~/.local/state}/oops/sessions/` にあり、あなただけが読めます(ファイルは 0600、ディレクトリは 0700)。閉じたタブのログは 7 日後に削除されます(`retention_days`)。`oops clear` ですぐに削除でき、`oops clear --all` なら今のタブのログも空にします。開いている他のタブのログも含めてすべて削除するには `rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/oops"` を実行します(標準では `rm -rf ~/.local/state/oops`)。ディスクに残したくない秘密情報を表示する前には `oops pause` を使ってください。
226
+ - セキュリティ上の問題を非公開で報告する方法は [SECURITY.ja.md](SECURITY.ja.md) を見てください。公開の issue には書かないでください。
155
227
  - 記録される出力は信頼できません。実行したプログラムは、oops のマーカーに見せかけたテキストを出力できます。そのため oops は、`oops pause` / `oops resume` が作るフラグファイルと一致する一時停止・再開マーカーにだけ従います。出力によって黙って記録を止められることはありません。ただし、プログラムがログ上でコマンドの開始と終了の位置を偽装することはできるので、貼り付ける内容は確認してください。
156
228
 
157
229
  ## 既知の制限
@@ -164,16 +236,16 @@ clipboard: auto # auto | pbcopy | wl-copy | xclip | xsel | clip.exe | osc52
164
236
  - vim・less・tmux などの全画面アプリの出力は保存されず、`[full-screen app output omitted]` に置き換わります。
165
237
  - スペースで始まるコマンドラインは、zsh では記録されますが bash では記録されません(bash はそれを履歴に保存せず、oops はコマンドを履歴から読むため)。
166
238
  - [bash-preexec](https://github.com/rcaloras/bash-preexec) を使う bash(atuin や Ghostty の bash 統合の背後にあるものも含む): oops は自前の `DEBUG` トラップを使わず、bash-preexec にフックを登録します。rc ファイルでどちらが先でも同じです。bash が履歴に保存しなかった行(`HISTCONTROL=ignorespace`、`HISTIGNORE`)は、この場合 `(not saved in history)` と記録されます。行のテキストを履歴から読むためです。bash-preexec がない場合、oops はそのような行の最初のコマンドを記録します。
167
- - Ghostty 上の bash: Ghostty のシェル統合(プロンプトマーク、同じディレクトリでの新しいタブ、タイトル)は外側のシェルにだけ入るため、記録される側のシェルには入りません。足し戻すには、`~/.bashrc` の oops の行の下に次の行を置きます(bash 3.2 で確認済み): `[ -n "$GHOSTTY_RESOURCES_DIR" ] && source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"`。zsh は何もしなくて構いません。スニペットがやります。
239
+ - Ghostty 上の bash: Ghostty のシェル統合(プロンプトマーク、同じディレクトリでの新しいタブ、タイトル)は外側のシェルにだけ入るため、記録される側のシェルには入りません。足し戻すには、`~/.bashrc` の oops の行の下に次の行を置きます(bash 3.2 で確認済み): `[ -n "$GHOSTTY_RESOURCES_DIR" ] && source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"`。zsh と fish は何もしなくて構いません。スニペットがやります。
168
240
  - bash: パイプラインの次の行では、`PIPESTATUS` に最後の終了ステータスしか入りません(`false | true` の後に `echo ${PIPESTATUS[*]}` を実行すると、`1 0` ではなく `0` と表示されます)。oops の `PROMPT_COMMAND` の項目が先に実行されるためです。`$?` と `$_` は保たれます。パイプラインの各ステータスを表示するプロンプト(starship の `pipestatus`)は、bash では表示が少なくなります。zsh は `pipestatus` を保ちます。
169
241
  - macOS: `pbcopy` は文字エンコーディングを `LANG` / `LC_*` から決めます。日本語などの非 ASCII 文字が文字化けして届くときは、UTF-8 のロケール(例: `LANG=en_US.UTF-8`)が設定されているか確認してください。SSH 越しで起動したシェルでは設定されていないことがあります。
170
- - fish とネイティブ Windows にはまだ対応していません。
242
+ - ネイティブ Windows にはまだ対応していません。
171
243
 
172
244
  ## 開発
173
245
 
174
246
  ### 手元の環境を整える
175
247
 
176
- Git、Ruby 3.3 以降、[Bundler](https://bundler.io/)(Ruby に付属)、そして zsh と bash の両方が必要です(エンドツーエンドのテストが本物のシェルを起動するため)。macOS には zsh と bash が入っています。Debian / Ubuntu では `sudo apt-get install zsh` を実行します。
248
+ Git、Ruby 3.3 以降、[Bundler](https://bundler.io/)(Ruby に付属)、そして zsh と bash の両方が必要です(エンドツーエンドのテストが本物のシェルを起動するため)。macOS には zsh と bash が入っています。Debian / Ubuntu では `sudo apt-get install zsh` を実行します。fish はなくても構いません。入っていなければ fish のエンドツーエンドのテストは飛ばされます(CI では入れています)。
177
249
 
178
250
  ```sh
179
251
  git clone https://github.com/ysb3996-blip/oops.git
@@ -207,18 +279,18 @@ bundle exec exe/oops doctor
207
279
 
208
280
  ```sh
209
281
  gem build oops-cli.gemspec
210
- gem install ./oops-cli-0.1.0.gem # ファイル名は lib/oops/version.rb に合わせる
282
+ gem install ./oops-cli-0.3.0.gem # ファイル名は lib/oops/version.rb に合わせる
211
283
  rbenv rehash # rbenv を使う場合のみ
212
284
  ```
213
285
 
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`)。
286
+ - gem は Ruby のバージョンごとに入ります。新しいタブが、インストールした Ruby とは別の Ruby を使っていると(たとえばリポジトリは 4.0.9 を指定しているのに、標準の Ruby が 3.3.5 の場合)、シェルの起動時に oops が見つからなくなります(bash では `bash: oops: command not found`、zsh では `command not found: oops` と出ます)。その Ruby でも `gem install` を実行してください(例: `RBENV_VERSION=3.3.5 gem install ./oops-cli-0.3.0.gem`)。
215
287
  - 変更のたびに入れ直してください。インストールされた gem はコピーで、チェックアウトへのリンクではありません。
216
288
  - 削除するには `gem uninstall oops-cli` です。
217
289
  - `eval "$(oops init zsh)"` は、プロンプトで打つだけでなく rc ファイルに書いてください。記録されるシェルは rc ファイルを読み直すため、行がないとフックが入りません(`oops doctor` に `hooks: missing` と出ます)。
218
290
 
219
291
  作業中の端末で `oops record` を実行しないでください。テストは自前の pty を通して動かします。試すときは、新しい端末タブを使います。試した結果として残るログを消すには、「プライバシーとセキュリティ」を参照してください。
220
292
 
221
- テストは、書式、秘密情報のマスク、ログの読み取り、ストリームパーサー、そして実際の zsh と bash を使った中継のエンドツーエンド実行を対象にしています。
293
+ テストは、書式、秘密情報のマスク、ログの読み取り、ストリームパーサー、そして実際の zsh・bash・fish を使った中継のエンドツーエンド実行を対象にしています。
222
294
 
223
295
  リリース前の手動チェックリスト:
224
296
 
@@ -229,7 +301,7 @@ rbenv rehash # rbenv を使う場合のみ
229
301
 
230
302
  ## コントリビュート
231
303
 
232
- バグ報告、アイデア、プルリクエストを歓迎します。[CONTRIBUTING.ja.md](CONTRIBUTING.ja.md) を読んでください。`good first issue` のラベルが付いた issue が、始めやすい入口です。
304
+ バグ報告、アイデア、プルリクエストを歓迎します。[CONTRIBUTING.ja.md](CONTRIBUTING.ja.md) を読んでください。`good first issue` のラベルが付いた issue が、始めやすい入口です。プルリクエストには、テストと rubocop の結果、新しいタブでの動作確認を必ず貼ってください([プルリクエストに必ず貼るもの](CONTRIBUTING.ja.md#プルリクエストに必ず貼るもの))。
233
305
 
234
306
  ## ライセンス
235
307
 
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).
@@ -17,9 +21,41 @@ What lands on your clipboard is a code block with the command, its output, and t
17
21
  gem install oops-cli
18
22
  ```
19
23
 
20
- Homebrew tap: coming soon.
24
+ Or with Homebrew:
25
+
26
+ ```sh
27
+ brew install ysb3996-blip/oops/oops
28
+ ```
29
+
30
+ The Homebrew formula runs oops with Homebrew's own Ruby, so it works even if an older Ruby comes first on your `PATH`.
31
+
32
+ Requires Ruby 3.3 or newer on macOS or Linux (WSL counts as Linux), and zsh, bash or fish. There are no runtime gem dependencies.
33
+
34
+ `gem install` puts oops into the Ruby that is active at that moment; each Ruby version has its own gems. With rbenv, asdf or mise, a tab that uses another Ruby gives a `command not found` error for `oops`, also when the shell starts. The fix that always works is to run `gem install oops-cli` again under the active Ruby (with rbenv, follow with `rbenv rehash`). Outside directories that pin a Ruby with `.ruby-version`, `.tool-versions` or `mise.toml`, you can instead set the Ruby you installed into as your global Ruby (for example `rbenv global 3.3.5`).
35
+
36
+ ### With a zsh plugin manager
37
+
38
+ For zsh you can load oops as a plugin instead. It runs oops from the cloned repository, so there is no gem to install (Ruby 3.3 or newer is still needed), and the plugin replaces the rc line below.
39
+
40
+ ```sh
41
+ # antidote: add to ~/.zsh_plugins.txt
42
+ ysb3996-blip/oops
43
+
44
+ # zinit: in ~/.zshrc, without `wait` (turbo mode)
45
+ zinit light ysb3996-blip/oops
46
+
47
+ # Oh My Zsh: clone, then add oops to plugins=(...) in ~/.zshrc
48
+ git clone https://github.com/ysb3996-blip/oops "${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/oops"
49
+
50
+ # No plugin manager: clone, then add the source line to ~/.zshrc
51
+ git clone https://github.com/ysb3996-blip/oops ~/.oops
52
+ source ~/.oops/oops.plugin.zsh
53
+ ```
21
54
 
22
- Requires Ruby 3.3 or newer on macOS or Linux (WSL counts as Linux), and zsh or bash. There are no runtime gem dependencies.
55
+ - Load it right away, not deferred (zinit `wait`, zsh-defer). The plugin starts your shell again under the recorder, which should happen before the first prompt.
56
+ - The notes on where the line goes (below) apply to where the plugin is loaded. With powerlevel10k's instant prompt, the plugin has to be loaded above the instant prompt block, which plugin managers usually are not; use the rc line there.
57
+ - If the Ruby on `PATH` is too old, the shell prints `oops: ... init zsh failed (oops needs Ruby 3.3 or later); recording disabled` and starts as usual, without recording.
58
+ - Plugin managers update from the `main` branch, which can be ahead of the latest release.
23
59
 
24
60
  ## Set up your shell
25
61
 
@@ -30,14 +66,21 @@ eval "$(oops init zsh)" # zsh
30
66
  eval "$(oops init bash)" # bash
31
67
  ```
32
68
 
33
- Then open a new tab. Existing tabs pick it up after `exec zsh` (or `exec bash`).
69
+ For fish, add this line to `~/.config/fish/config.fish`:
70
+
71
+ ```fish
72
+ oops init fish | source
73
+ ```
74
+
75
+ Then open a new tab. Existing tabs pick it up after `exec zsh` (or `exec bash`, `exec fish`).
34
76
 
35
77
  Where to put the line matters:
36
78
 
37
79
  - Put it **after** whatever puts the Ruby that has oops on your `PATH` (rbenv, asdf, mise, Homebrew `shellenv`, and so on). On macOS, login shells reset `PATH`, so if the line comes first, the system Ruby 2.6 may be picked and oops will not start. If oops cannot run, your shell still starts normally, just without recording.
38
80
  - Put it **before** slow or noisy setup that you do not want to run twice (nvm, prompt themes, banners). The snippet starts your shell again under a small recorder (`oops record`, a pty relay), so anything above the line runs once in the outer shell and again in the inner one. Anything below it runs only in the inner shell.
39
81
  - With powerlevel10k, put the line **above** the instant prompt block at the top of `~/.zshrc`. The instant prompt redirects the shell's input and output while the rest of `~/.zshrc` runs, and the snippet does nothing then (it only acts on a real terminal), so oops would silently not record. If a tab is not recorded although the line is in `~/.zshrc`, run `oops doctor`: it shows `recording: no`.
40
- - In zsh the shell is restarted as a non-login shell, so setup that lives only in `~/.zlogin` is not applied to the recorded shell. Keep it in `~/.zprofile` or `~/.zshrc` (above the line). In bash, a login shell is restarted with `-l`.
82
+ - In zsh the shell is restarted as a non-login shell, so setup that lives only in `~/.zlogin` is not applied to the recorded shell. Keep it in `~/.zprofile` or `~/.zshrc` (above the line). In bash, a login shell is restarted with `-l`, and in fish with `--login`.
83
+ - fish is restarted just before its first prompt, so all of `config.fish` runs in both the outer and the recorded shell, wherever the line is. (Starting it earlier would also catch `fish -i -c` and `fish -i script.fish`, which never show a prompt and must not be restarted.)
41
84
 
42
85
  If something looks wrong, run `oops doctor`. It reports whether this tab is recorded, the shell and whether the hooks are loaded, which clipboard method will be used, the config file, the size of the session logs, and the Ruby version.
43
86
 
@@ -48,11 +91,14 @@ To turn oops off for one shell, start it with `OOPS_DISABLE=1` (for example `OOP
48
91
  | Command | What it does |
49
92
  |---|---|
50
93
  | `oops` | Copy the last command and its output |
51
- | `oops 3` | Copy the last 3 commands (oldest first) as one code block |
94
+ | `oops 3` / `oops -n 3` | Copy the last 3 commands (oldest first) as one code block |
52
95
  | `oops err` | Copy the last command that exited non-zero, skipping over successful ones |
53
- | `oops init zsh` / `oops init bash` | Print the shell snippet (for `eval`) |
96
+ | `oops err -n 2` | Copy the last 2 failed commands (oldest first), skipping over successful ones |
97
+ | `oops init zsh` / `oops init bash` / `oops init fish` | Print the shell snippet (for `eval`, or `\| source` in fish) |
54
98
  | `oops doctor` | Diagnose recording, hooks, clipboard, config and log size |
55
99
  | `oops pause` / `oops resume` | Stop and restart recording. While paused, nothing is written to disk |
100
+ | `oops status` | Print `recording`, `paused` or `off` on one line, for scripts and prompts |
101
+ | `oops clear` / `oops clear --all` | Delete the logs of closed tabs; `--all` also empties this tab's log |
56
102
  | `oops record -- <shell>` | Internal: runs a shell under the recorder. The snippet calls this |
57
103
  | `oops --version`, `oops --help` | Version and usage |
58
104
 
@@ -60,11 +106,16 @@ Options for `oops`, `oops N` and `oops err`:
60
106
 
61
107
  | Option | What it does |
62
108
  |---|---|
109
+ | `-n N` | Copy the last N commands (with `err`, the last N failed commands) |
63
110
  | `--github` | Format for a GitHub issue or PR comment |
64
111
  | `--tail N` | Keep only the last N output lines |
65
112
  | `--head N` | Keep only the first N output lines (with `--tail`, keeps both ends) |
113
+ | `--time` | Add the time each command started as a `# 2026-10-31 19:30:09 +09:00` line |
114
+ | `--output-only` | Leave out the `$ command` line and copy only the output |
66
115
  | `--show-secrets` | Do not mask secrets and do not apply the replace table |
67
116
 
117
+ `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.
118
+
68
119
  `oops` never copies itself: if the previous command was `oops`, you get the command before it.
69
120
 
70
121
  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 +169,22 @@ $ bundle exec rspec
118
169
 
119
170
  `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
171
 
172
+ ### `--time` and `--output-only`
173
+
174
+ `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:
175
+
176
+ ````
177
+ ```
178
+ # 2026-10-31 19:30:09 +09:00
179
+ ....F
180
+ (exit 1)
181
+ ```
182
+ ````
183
+
184
+ ### `oops status`
185
+
186
+ `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`.
187
+
121
188
  ## Configuration
122
189
 
123
190
  Optional file at `${XDG_CONFIG_HOME:-~/.config}/oops/config.yml`:
@@ -129,12 +196,14 @@ replace: # plain string replacements applied when copying
129
196
  secrets: # extra regular expressions to mask
130
197
  - "sk_live_[A-Za-z0-9]+"
131
198
  clipboard: auto # auto | pbcopy | wl-copy | xclip | xsel | clip.exe | osc52 | any command (reads stdin)
199
+ retention_days: 7 # delete closed tabs' logs after this many days; false keeps them
132
200
  ```
133
201
 
134
202
  - Replacements and masking are applied at the moment you copy; the recorded log stays untouched, so a config change also affects earlier commands.
135
203
  - `replace` keys are applied longest first and are case-sensitive.
136
204
  - Unknown keys produce a warning and are ignored. A broken YAML file produces a warning and oops carries on without a config.
137
205
  - With `clipboard: auto`, oops tries `pbcopy` on macOS, `clip.exe` on WSL, `wl-copy` on Wayland, `xclip` or `xsel` on X11, and falls back to the OSC 52 terminal escape sequence (also used over SSH). `oops doctor` shows which one it picked.
206
+ - `retention_days` (default 7) counts from the last time a closed tab wrote its log. Old logs are deleted when a tab starts recording. With `false`, closed tabs' logs stay until the 50 MB limit removes them.
138
207
  - A custom `clipboard:` command runs in its own process group with a 5 second timeout, so it must not read from or configure the terminal (no password prompts, no `stty`); Ctrl-C during a copy does not reach it.
139
208
 
140
209
  Built-in masking covers AWS access keys, GitHub, GitLab, Slack, npm, OpenAI/Anthropic (`sk-...`), Stripe and Google API tokens, Slack webhook URLs, JWTs, `Authorization` header values, private key blocks (also when the END line is missing), values assigned to names containing `key`, `token`, `secret`, `password`, `passwd` or `pwd`, passwords given as `--password <value>`, `curl -u user:pass`, `mysql -p<value>` or `docker login -p <value>`, email addresses, credentials inside URLs, and public IPv4 addresses (loopback and private ranges are left alone). It is applied to the command line as well as the output.
@@ -143,15 +212,18 @@ Masking is pattern based, so it is a safety net, not a guarantee: a secret in a
143
212
 
144
213
  ## How it works
145
214
 
146
- 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`.
215
+ ![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)
216
+
217
+ 1. `oops init` prints a snippet that you `eval` from your rc file (`| source` in fish). In an interactive shell on a terminal that is not recorded yet, it restarts the shell under `oops record`.
147
218
  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
- 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.
149
- 4. For each finished command the relay appends one JSON line (command, directory, times, exit code, raw output) to a per-tab log under `${XDG_STATE_HOME:-~/.local/state}/oops/sessions/`. Output is capped at 1 MB per command (the tail is kept). Each tab's log is capped at 50 MB: when it passes that, it is rewritten to keep only its newest commands (down to about 37 MiB). When a tab starts recording, logs of closed tabs are deleted, oldest first, until all logs together take at most 50 MB; logs of open tabs are never deleted, so several busy tabs can together use more.
219
+ 3. Hooks in the shell (zsh `preexec`/`precmd`, bash `DEBUG` trap and `PROMPT_COMMAND`, fish `fish_preexec`/`fish_postexec` events) emit invisible APC markers around each command. The relay strips them, so your terminal never sees them.
220
+ 4. For each finished command the relay appends one JSON line (command, directory, times, exit code, raw output) to a per-tab log under `${XDG_STATE_HOME:-~/.local/state}/oops/sessions/`. Output is capped at 1 MB per command (the tail is kept). Each tab's log is capped at 50 MB: when it passes that, it is rewritten to keep only its newest commands (down to about 37 MiB). When a tab starts recording, logs of closed tabs older than `retention_days` (7 by default) are deleted, and then the oldest of the rest until all logs together take at most 50 MB; logs of open tabs are never deleted, so several busy tabs can together use more.
150
221
  5. `oops` reads the end of the current tab's log, cleans and masks the text, builds the code block and hands it to the clipboard. Nothing is ever re-run.
151
222
 
152
223
  ## Privacy and security
153
224
 
154
- - 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.
225
+ - 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). Logs of closed tabs are deleted after 7 days (`retention_days`). `oops clear` deletes them now, and `oops clear --all` also empties the current tab's log. To delete everything, including the logs of other open tabs, 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.
226
+ - To report a security problem privately, see [SECURITY.md](SECURITY.md). Please do not open a public issue for it.
155
227
  - 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
228
 
157
229
  ## Known limitations
@@ -164,16 +236,16 @@ Masking is pattern based, so it is a safety net, not a guarantee: a secret in a
164
236
  - Output of full-screen apps (vim, less, tmux and so on) is not kept; it is replaced by `[full-screen app output omitted]`.
165
237
  - A command line that starts with a space is recorded in zsh but dropped in bash (bash does not save it to history, which is where oops reads the command).
166
238
  - bash with [bash-preexec](https://github.com/rcaloras/bash-preexec) (also behind atuin and Ghostty's bash integration): oops registers its hooks with it instead of using its own `DEBUG` trap, whichever of the two comes first in your rc file. A line that bash did not save to history (`HISTCONTROL=ignorespace`, `HISTIGNORE`) is then recorded as `(not saved in history)`, because the text of the line is read from history there. Without bash-preexec, oops records the first command of such a line instead.
167
- - bash in Ghostty: Ghostty adds its shell integration (prompt marks, new tabs in the same directory, titles) only to the outer shell, so the recorded shell does not get it. To add it back, put this below the oops line in `~/.bashrc` (checked with bash 3.2): `[ -n "$GHOSTTY_RESOURCES_DIR" ] && source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"`. zsh needs nothing: the snippet does it.
239
+ - bash in Ghostty: Ghostty adds its shell integration (prompt marks, new tabs in the same directory, titles) only to the outer shell, so the recorded shell does not get it. To add it back, put this below the oops line in `~/.bashrc` (checked with bash 3.2): `[ -n "$GHOSTTY_RESOURCES_DIR" ] && source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"`. zsh and fish need nothing: the snippet does it.
168
240
  - bash: the line after a pipeline sees `PIPESTATUS` with only the last status (`false | true`, then `echo ${PIPESTATUS[*]}` prints `0`, not `1 0`), because oops's `PROMPT_COMMAND` entry runs first. `$?` and `$_` are kept. A prompt that shows pipeline statuses (starship's `pipestatus`) shows less in bash. zsh keeps `pipestatus`.
169
241
  - macOS: `pbcopy` chooses its text encoding from `LANG` / `LC_*`. If Japanese or other non-ASCII text arrives garbled, check that a UTF-8 locale is set (for example `LANG=en_US.UTF-8`), which can be missing in shells started over SSH.
170
- - fish and native Windows are not supported yet.
242
+ - Native Windows is not supported yet.
171
243
 
172
244
  ## Development
173
245
 
174
246
  ### Set up your machine
175
247
 
176
- You need Git, Ruby 3.3 or newer, [Bundler](https://bundler.io/) (it ships with Ruby), and both zsh and bash (the end-to-end tests start real shells). macOS has zsh and bash; on Debian or Ubuntu run `sudo apt-get install zsh`.
248
+ You need Git, Ruby 3.3 or newer, [Bundler](https://bundler.io/) (it ships with Ruby), and both zsh and bash (the end-to-end tests start real shells). macOS has zsh and bash; on Debian or Ubuntu run `sudo apt-get install zsh`. fish is optional: its end-to-end tests are skipped when it is not installed (CI installs it).
177
249
 
178
250
  ```sh
179
251
  git clone https://github.com/ysb3996-blip/oops.git
@@ -207,18 +279,18 @@ To try it as your shell does, build and install the gem into the Ruby you are us
207
279
 
208
280
  ```sh
209
281
  gem build oops-cli.gemspec
210
- gem install ./oops-cli-0.1.0.gem # the file name follows lib/oops/version.rb
282
+ gem install ./oops-cli-0.3.0.gem # the file name follows lib/oops/version.rb
211
283
  rbenv rehash # only with rbenv
212
284
  ```
213
285
 
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`.
286
+ - 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 cannot find oops when it starts (bash prints `bash: oops: command not found`, zsh prints `command not found: oops`). Run `gem install` again under that Ruby, e.g. `RBENV_VERSION=3.3.5 gem install ./oops-cli-0.3.0.gem`.
215
287
  - Reinstall after every change. The installed gem is a copy, not a link to your checkout.
216
288
  - To remove it: `gem uninstall oops-cli`.
217
289
  - 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`).
218
290
 
219
291
  Never run `oops record` in the terminal you are working in; the tests drive it through their own pty. Try it in a new terminal tab instead. To delete the logs your experiments leave behind, see "Privacy and security".
220
292
 
221
- The tests cover formatting, secret masking, the log reader, the stream parser and an end-to-end run of the relay with real zsh and bash.
293
+ The tests cover formatting, secret masking, the log reader, the stream parser and an end-to-end run of the relay with real zsh, bash and fish.
222
294
 
223
295
  Manual checklist before a release:
224
296
 
@@ -229,7 +301,7 @@ Manual checklist before a release:
229
301
 
230
302
  ## Contributing
231
303
 
232
- Bug reports, ideas and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md); issues labeled `good first issue` are a good place to start.
304
+ Bug reports, ideas and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md); issues labeled `good first issue` are a good place to start. Every pull request must include the test and rubocop results and a check in a new terminal tab; see [What every pull request must include](CONTRIBUTING.md#what-every-pull-request-must-include).
233
305
 
234
306
  ## License
235
307
 
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,25 @@ 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)
25
+ when "clear" then return clear(argv.drop(1), env: env, stdout: stdout)
21
26
  end
22
27
 
23
- options = { head: nil, tail: nil, github: false, show_secrets: false }
28
+ options = { head: nil, tail: nil, github: false, show_secrets: false, time: false, output_only: false }
29
+ count = nil
24
30
  parser = OptionParser.new do |o|
25
- o.banner = "Usage: oops [N | err] [options]\n oops init <shell> | pause | resume | doctor"
31
+ o.banner = "Usage: oops [N | err] [-n N] [options]\n " \
32
+ "oops init <shell> | pause | resume | status | clear [--all] | doctor"
33
+ o.on("-n N", "Copy the last N commands (with err, last N failures)") do |v|
34
+ raise UsageError, COUNT_REPEATED if count
35
+
36
+ count = positive(v, "-n")
37
+ end
26
38
  o.on("--github", "Format for a GitHub issue or PR comment") { options[:github] = true }
27
39
  o.on("--tail N", "Keep only the last N output lines") { |v| options[:tail] = positive(v, "--tail") }
28
40
  o.on("--head N", "Keep only the first N output lines") { |v| options[:head] = positive(v, "--head") }
41
+ o.on("--time", "Add the time each command started") { options[:time] = true }
42
+ o.on("--output-only", "Leave out the command line, keep the output") { options[:output_only] = true }
29
43
  o.on("--show-secrets", "Do not mask secrets or apply the replace table") { options[:show_secrets] = true }
30
44
  o.on("-v", "--version", "Print version and exit") do
31
45
  stdout.puts "oops #{VERSION}"
@@ -36,7 +50,7 @@ module Oops
36
50
  return 0
37
51
  end
38
52
  end
39
- copy(target(parser.parse(argv)), options, env: env, stdout: stdout, stderr: stderr)
53
+ copy(target(parser.parse(argv), count: count), options, env: env, stdout: stdout, stderr: stderr)
40
54
  rescue OptionParser::ParseError, UsageError => e
41
55
  stderr.puts "oops: #{e.message}"
42
56
  2
@@ -51,17 +65,18 @@ module Oops
51
65
  value.to_i
52
66
  end
53
67
 
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)
68
+ # Count comes from either a positional N or -n N. "err" filters the
69
+ # selection to failed commands; without a count, one command is copied.
70
+ def self.target(args, count:)
57
71
  raise UsageError, "unexpected argument: #{args[1]}" if args.size > 1
58
72
 
59
73
  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/)
74
+ return CopyTarget.new(count: count || 1, failed_only: arg == "err") if arg.nil? || arg == "err"
63
75
 
64
- raise UsageError, "unknown command: #{arg}"
76
+ raise UsageError, "unknown command: #{arg}" unless arg.match?(/\A[+-]?\d/)
77
+ raise UsageError, COUNT_REPEATED if count
78
+
79
+ CopyTarget.new(count: positive(arg, "N"), failed_only: false)
65
80
  end
66
81
 
67
82
  def self.copy(target, options, env:, stdout:, stderr:)
@@ -70,8 +85,8 @@ module Oops
70
85
 
71
86
  config = Config.load(env: env)
72
87
  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?
88
+ records = log.last(target.count, failed_only: target.failed_only)
89
+ raise Failure, (target.failed_only ? "no failed command found." : "no command found yet.") if records.empty?
75
90
 
76
91
  home = env["HOME"].to_s.empty? ? Dir.home : env["HOME"]
77
92
  result = Formatter.new(config: config, home: home, **options).format(records)
@@ -103,8 +118,12 @@ module Oops
103
118
  path = env["OOPS_SESSION"].to_s
104
119
  return SessionLog.new(path) if !path.empty? && File.file?(path)
105
120
 
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})\" " \
121
+ line, rc = case env["SHELL"].to_s
122
+ when /bash\z/ then [%(eval "$(oops init bash)"), "~/.bashrc"]
123
+ when /fish\z/ then ["oops init fish | source", "~/.config/fish/config.fish"]
124
+ else [%(eval "$(oops init zsh)"), "~/.zshrc"]
125
+ end
126
+ raise Failure, "not recording in this shell. Add #{line} " \
108
127
  "to #{rc} (after whatever puts oops's Ruby on PATH, as early as possible) " \
109
128
  "and open a new tab."
110
129
  end
@@ -144,6 +163,43 @@ module Oops
144
163
  stdout.flush
145
164
  end
146
165
 
166
+ # `oops status`: one word on stdout. "recording" exits 0; "paused" and
167
+ # "off" (this shell is not recorded, or its log is gone) exit 1 without a
168
+ # message, so a prompt can call it.
169
+ def self.status(args, env:, stdout:, stderr:)
170
+ unless args.empty?
171
+ stderr.puts "oops: usage: oops status"
172
+ return 2
173
+ end
174
+
175
+ path = env["OOPS_SESSION"].to_s
176
+ word = if path.empty? || !File.file?(path) then "off"
177
+ elsif SessionLog.new(path).paused? then "paused"
178
+ else "recording"
179
+ end
180
+ stdout.puts word
181
+ word == "recording" ? 0 : 1
182
+ end
183
+
184
+ # `oops clear [--all]`: deletes the logs of closed tabs; with --all, also
185
+ # empties this tab's log. Logs of other open tabs are never touched.
186
+ def self.clear(args, env:, stdout:)
187
+ raise UsageError, "usage: oops clear [--all]" unless args.empty? || args == ["--all"]
188
+
189
+ log = recording_log(env) if args == ["--all"]
190
+ begin
191
+ removed = SessionLog.clear!(Paths.sessions_dir(env: env))
192
+ stdout.puts "removed #{removed} #{removed == 1 ? 'log' : 'logs'} of closed tabs"
193
+ return 0 unless log
194
+
195
+ log.empty!
196
+ rescue SystemCallError => e
197
+ raise Failure, "could not delete the logs: #{e.message}"
198
+ end
199
+ stdout.puts "emptied this tab's log"
200
+ 0
201
+ end
202
+
147
203
  # `oops doctor`: diagnostics on stdout; always exits 0.
148
204
  def self.doctor(args, env:, stdin:, stdout:)
149
205
  raise UsageError, "usage: oops doctor" unless args.empty?
@@ -165,7 +221,7 @@ module Oops
165
221
  Recorder.new(args, env: env, stdin: stdin, stdout: stdout, stderr: stderr).run
166
222
  end
167
223
 
168
- # `oops init zsh|bash`: prints the snippet for `eval "$(oops init zsh)"`.
224
+ # `oops init zsh|bash|fish`: prints the snippet for `eval "$(oops init zsh)"`.
169
225
  def self.init(args, stdout:, stderr:)
170
226
  supported = ShellInit::SHELLS.join(", ")
171
227
  if args.size != 1
@@ -181,6 +237,6 @@ module Oops
181
237
  end
182
238
 
183
239
  private_class_method :positive, :target, :copy, :report_command, :report_counts, :recording_log, :toggle,
184
- :write_marker, :doctor
240
+ :write_marker, :status, :clear, :doctor
185
241
  end
186
242
  end
data/lib/oops/config.rb CHANGED
@@ -3,9 +3,10 @@ require_relative "paths"
3
3
  module Oops
4
4
  # User configuration loaded from a YAML file; problems become warnings.
5
5
  class Config
6
- KNOWN_KEYS = %w[replace secrets clipboard].freeze
6
+ KNOWN_KEYS = %w[replace secrets clipboard retention_days].freeze
7
+ DEFAULT_RETENTION_DAYS = 7
7
8
 
8
- attr_reader :replace, :secrets, :clipboard, :warnings
9
+ attr_reader :replace, :secrets, :clipboard, :retention_days, :warnings
9
10
 
10
11
  def self.load(path = nil, env: ENV)
11
12
  new(path || Paths.config_file(env: env))
@@ -15,6 +16,7 @@ module Oops
15
16
  @replace = {}
16
17
  @secrets = []
17
18
  @clipboard = "auto"
19
+ @retention_days = DEFAULT_RETENTION_DAYS
18
20
  @warnings = []
19
21
  read(path)
20
22
  end
@@ -41,6 +43,7 @@ module Oops
41
43
  apply_replace(data["replace"]) if data.key?("replace")
42
44
  apply_secrets(data["secrets"]) if data.key?("secrets")
43
45
  apply_clipboard(data["clipboard"]) if data.key?("clipboard")
46
+ apply_retention_days(data["retention_days"]) if data.key?("retention_days")
44
47
  end
45
48
 
46
49
  # YAML reads `clipboard: true` (or false) as a boolean; as a command it
@@ -50,6 +53,15 @@ module Oops
50
53
  @clipboard = text.empty? || %w[true false].include?(text) ? "auto" : value.to_s
51
54
  end
52
55
 
56
+ # false keeps closed tabs' logs until the size cap removes them.
57
+ def apply_retention_days(value)
58
+ return @retention_days = nil if value == false
59
+ return @retention_days = value if value.is_a?(Integer) && value >= 1
60
+
61
+ @warnings << "retention_days must be a whole number of days (at least 1) or false; " \
62
+ "using #{DEFAULT_RETENTION_DAYS}"
63
+ end
64
+
53
65
  def apply_replace(value)
54
66
  return @warnings << "replace must be a mapping; ignored" unless value.is_a?(Hash)
55
67
 
@@ -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/recorder.rb CHANGED
@@ -172,7 +172,8 @@ module Oops
172
172
 
173
173
  # Rotation only frees space; failing to rotate must not stop recording.
174
174
  def rotate_logs(dir)
175
- SessionLog.rotate!(dir)
175
+ days = Config.load(env: @env).retention_days
176
+ SessionLog.rotate!(dir, max_age: days && (days * 86_400))
176
177
  rescue SystemCallError
177
178
  nil
178
179
  end
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(
@@ -25,19 +25,19 @@ module Oops
25
25
  false
26
26
  end
27
27
 
28
- # Deletes the oldest logs of finished sessions until the total size of
29
- # all session logs is at most max_bytes. Logs of live sessions are kept.
30
- def self.rotate!(dir, max_bytes: DEFAULT_MAX_BYTES, alive: method(:process_alive?))
31
- logs = Dir.children(dir).filter_map do |name|
32
- m = LOG_NAME.match(name)
33
- next unless m
34
-
35
- size = begin
36
- File.size(File.join(dir, name))
37
- rescue SystemCallError
38
- next
28
+ # Deletes logs of finished sessions: with max_age, those last written more
29
+ # than max_age seconds ago; then the oldest, until the total size of all
30
+ # session logs is at most max_bytes. Logs of live sessions are kept.
31
+ def self.rotate!(dir, max_bytes: DEFAULT_MAX_BYTES, max_age: nil, now: Time.now,
32
+ alive: method(:process_alive?))
33
+ logs = list(dir)
34
+ if max_age
35
+ logs = logs.reject do |log|
36
+ next false if log[:mtime] >= now - max_age || alive.call(log[:pid])
37
+
38
+ remove_log(File.join(dir, log[:name]))
39
+ true
39
40
  end
40
- { name: name, pid: m[2].to_i, size: size }
41
41
  end
42
42
  total = logs.sum { |l| l[:size] }
43
43
  logs.sort_by { |l| l[:name] }.each do |log|
@@ -51,6 +51,29 @@ module Oops
51
51
  nil
52
52
  end
53
53
 
54
+ # Deletes the logs of all finished sessions. Returns how many it deleted.
55
+ def self.clear!(dir, alive: method(:process_alive?))
56
+ dead = list(dir).reject { |log| alive.call(log[:pid]) }
57
+ dead.each { |log| remove_log(File.join(dir, log[:name])) }
58
+ dead.size
59
+ rescue Errno::ENOENT
60
+ 0
61
+ end
62
+
63
+ def self.list(dir)
64
+ Dir.children(dir).filter_map do |name|
65
+ m = LOG_NAME.match(name)
66
+ next unless m
67
+
68
+ stat = begin
69
+ File.stat(File.join(dir, name))
70
+ rescue SystemCallError
71
+ next
72
+ end
73
+ { name: name, pid: m[2].to_i, size: stat.size, mtime: stat.mtime }
74
+ end
75
+ end
76
+
54
77
  def self.remove_log(file)
55
78
  [file, "#{file}.paused", "#{file}.status", "#{file}.trim"].each do |f|
56
79
  File.delete(f)
@@ -58,7 +81,7 @@ module Oops
58
81
  nil
59
82
  end
60
83
  end
61
- private_class_method :remove_log
84
+ private_class_method :list, :remove_log
62
85
 
63
86
  def initialize(path, max_bytes: DEFAULT_MAX_BYTES)
64
87
  @path = path
@@ -93,11 +116,13 @@ module Oops
93
116
  nil
94
117
  end
95
118
 
96
- # Newest count finished, non-oops records, oldest first.
97
- def last(count, skip_oops: true)
119
+ # Newest count finished, non-oops records, oldest first. With failed_only,
120
+ # only records with a non-zero integer exit status count toward the limit.
121
+ def last(count, skip_oops: true, failed_only: false)
98
122
  found = []
99
123
  each_backward do |r|
100
124
  next if r.exit.nil? || (skip_oops && r.oops_self?)
125
+ next if failed_only && (!r.exit.is_a?(Integer) || r.exit.zero?)
101
126
 
102
127
  found << r
103
128
  :stop if found.size >= count
@@ -106,10 +131,7 @@ module Oops
106
131
  end
107
132
 
108
133
  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
134
+ last(1, failed_only: true).first
113
135
  end
114
136
 
115
137
  def paused?
@@ -126,6 +148,12 @@ module Oops
126
148
  nil
127
149
  end
128
150
 
151
+ # The recorder opens the log for each record it appends, so the file can
152
+ # be cut to nothing while the session goes on.
153
+ def empty!
154
+ File.truncate(@path, 0)
155
+ end
156
+
129
157
  private
130
158
 
131
159
  def pause_path
@@ -0,0 +1,99 @@
1
+ # oops: fish hooks. Put this line in ~/.config/fish/config.fish, after
2
+ # whatever puts oops's Ruby on PATH and as early as possible after that:
3
+ #
4
+ # oops init fish | source
5
+ #
6
+ # On a terminal that is not recorded yet, the shell is started again under
7
+ # `oops record` (a pty relay). Inside the recorded shell the hooks write
8
+ # invisible APC markers around every command:
9
+ #
10
+ # ESC _ oops;start;<seq>;<epoch>;<base64 cwd>;<base64 cmd> ESC \
11
+ # ESC _ oops;end;<seq>;<exit> ESC \
12
+ #
13
+ # "Recorded" means $OOPS_TTY (set by the relay) names this very terminal, so
14
+ # shells started from a recorded one on another tty (tmux, GUI terminals)
15
+ # get recorded on their own, and `exec fish` on the same tty is not recorded
16
+ # twice. Markers go straight to the terminal device, so a redirected stdout
17
+ # never receives them.
18
+ # This file is read from a pipe (`| source`), so stdin is not the terminal:
19
+ # the checks look at stdout and stderr instead.
20
+ if status is-interactive; and test -t 1; and test -t 2; and not set -q OOPS_DISABLE
21
+ and set -g __oops_tty (tty <&2 2>/dev/null)
22
+ set -g __oops_bin __OOPS_BIN__
23
+ if test "$OOPS_TTY" != "$__oops_tty"
24
+ # A failed exec ends fish, so check first; without the binary the
25
+ # shell simply runs unrecorded.
26
+ if command -q $__oops_bin
27
+ set -g __oops_login
28
+ status is-login; and set __oops_login --login
29
+ # Ghostty injects its shell integration through XDG_DATA_DIRS and
30
+ # removes the injection again, so the restarted shell would not
31
+ # get it. Note whether this one has it (its setup function waits
32
+ # for the first prompt), so the restarted shell can load it again.
33
+ set -g __oops_ghostty
34
+ functions -q __ghostty_setup; and set __oops_ghostty 1
35
+ # Restart at the first prompt, not now: `fish -i -c cmd` and
36
+ # `fish -i script.fish` count as interactive here but never show a
37
+ # prompt, so they run their command unrecorded instead of losing it.
38
+ function __oops_restart --on-event fish_prompt
39
+ functions -e __oops_restart
40
+ if test -n "$__oops_ghostty"
41
+ set -gx __OOPS_GHOSTTY 1
42
+ else
43
+ set -e __OOPS_GHOSTTY
44
+ end
45
+ exec $__oops_bin record -- (status fish-path) $__oops_login
46
+ end
47
+ else
48
+ echo "oops: cannot run $__oops_bin; recording disabled" >&2
49
+ end
50
+ else
51
+ function __oops_b64
52
+ printf '%s' $argv[1] | base64 | tr -d '\n'
53
+ end
54
+
55
+ # $argv[1] is the whole command line, several lines for a block. Very
56
+ # long lines are cut so the marker stays small. A line of spaces runs
57
+ # nothing, and the other shells make no record for it either.
58
+ function __oops_preexec --on-event fish_preexec
59
+ string trim -- $argv[1] | string length -q; or return
60
+ set -gx __OOPS_SEQ (math $__OOPS_SEQ + 1)
61
+ set -g __oops_open $__OOPS_SEQ
62
+ set -l cmd (string sub -l 8192 -- $argv[1] | string collect)
63
+ printf '\e_oops;start;%s;%s;%s;%s\e\\' $__OOPS_SEQ (date +%s) \
64
+ (__oops_b64 $PWD) (__oops_b64 "$cmd") 2>/dev/null >$__oops_tty
65
+ end
66
+
67
+ # $status must be read before anything else runs.
68
+ function __oops_postexec --on-event fish_postexec
69
+ set -l exit_status $status
70
+ if test -n "$__oops_open"
71
+ printf '\e_oops;end;%s;%s\e\\' $__oops_open $exit_status 2>/dev/null >$__oops_tty
72
+ set -g __oops_open
73
+ end
74
+ end
75
+
76
+ # Once per shell process; sourcing config.fish again must not redo this.
77
+ if not set -q __oops_hooked
78
+ set -g __oops_hooked 1
79
+ set -g __oops_open
80
+ string match -qr '^[0-9]+$' -- "$__OOPS_SEQ"; or set -gx __OOPS_SEQ 0
81
+ # Same pid as the shell that set __OOPS_PID: this shell replaced it
82
+ # with `exec`, so that command (still open in the relay) is done.
83
+ if test "$__OOPS_PID" = "$fish_pid"; and test "$__OOPS_SEQ" != 0
84
+ printf '\e_oops;end;%s;%s\e\\' $__OOPS_SEQ 0 2>/dev/null >$__oops_tty
85
+ end
86
+ # Exported so a nested shell on this tty continues the numbering.
87
+ set -gx __OOPS_SEQ $__OOPS_SEQ
88
+ set -gx __OOPS_PID $fish_pid
89
+ end
90
+
91
+ # The shell this one replaced had Ghostty's integration (see above);
92
+ # load it here too, as Ghostty documents for a manual setup.
93
+ set -l ghostty "$GHOSTTY_RESOURCES_DIR/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish"
94
+ if set -q __OOPS_GHOSTTY; and test -n "$GHOSTTY_RESOURCES_DIR"; and test -r $ghostty
95
+ and not functions -q __ghostty_setup
96
+ source $ghostty
97
+ end
98
+ end
99
+ end
@@ -1,11 +1,12 @@
1
1
  module Oops
2
- # The snippets printed by `oops init zsh|bash`, meant for
3
- # `eval "$(oops init zsh)"` in the rc file, after whatever puts oops's Ruby
4
- # on PATH and as early as possible after that. The templates live in
5
- # lib/oops/shell/; the absolute path of the oops executable is written into
6
- # them, so a later change to PATH cannot break the hooks.
2
+ # The snippets printed by `oops init zsh|bash|fish`, meant for
3
+ # `eval "$(oops init zsh)"` (or `oops init fish | source`) in the rc file,
4
+ # after whatever puts oops's Ruby on PATH and as early as possible after
5
+ # that. The templates live in lib/oops/shell/; the absolute path of the oops
6
+ # executable is written into them, so a later change to PATH cannot break
7
+ # the hooks.
7
8
  module ShellInit
8
- SHELLS = %w[zsh bash].freeze
9
+ SHELLS = %w[zsh bash fish].freeze
9
10
  TEMPLATE_DIR = File.expand_path("shell", __dir__)
10
11
  BIN_PLACEHOLDER = "__OOPS_BIN__".freeze
11
12
 
@@ -13,14 +14,19 @@ module Oops
13
14
  raise ArgumentError, "unsupported shell: #{shell}" unless SHELLS.include?(shell)
14
15
 
15
16
  template = File.read(File.join(TEMPLATE_DIR, "oops.#{shell}"), encoding: Encoding::UTF_8)
16
- template.gsub(BIN_PLACEHOLDER) { shell_quote(bin) }
17
+ template.gsub(BIN_PLACEHOLDER) { shell == "fish" ? fish_quote(bin) : shell_quote(bin) }
17
18
  end
18
19
 
19
- # One single-quoted word for both shells: ' becomes '\''.
20
+ # One single-quoted word for zsh and bash: ' becomes '\''.
20
21
  def self.shell_quote(text)
21
22
  "'#{text.to_s.gsub("'") { "'\\''" }}'"
22
23
  end
23
24
 
24
- private_class_method :shell_quote
25
+ # fish single quotes take \' and \\ instead.
26
+ def self.fish_quote(text)
27
+ "'#{text.to_s.gsub(/[\\']/) { "\\#{_1}" }}'"
28
+ end
29
+
30
+ private_class_method :shell_quote, :fish_quote
25
31
  end
26
32
  end
data/lib/oops/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Oops
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "0.3.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.3.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
@@ -38,6 +40,7 @@ files:
38
40
  - lib/oops/secrets.rb
39
41
  - lib/oops/session_log.rb
40
42
  - lib/oops/shell/oops.bash
43
+ - lib/oops/shell/oops.fish
41
44
  - lib/oops/shell/oops.zsh
42
45
  - lib/oops/shell_init.rb
43
46
  - lib/oops/stream_parser.rb