oops-cli 0.2.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: f8a84c3fefd0069ad9b8f6be16f6a14957ed36d3449232c0b8bdbf69f1fe817b
4
- data.tar.gz: 8f12fe23babadb66f8c1e156a966c850d564c62c78781df1faef3366a1569b09
3
+ metadata.gz: 6e1003df0d3be3f85135c9f36b34aaed5d5ce02002a22d1c75baafd235180b15
4
+ data.tar.gz: b67d4984e973482dc9c4f4938d14e65dfc4e99c7c149a2deea7a6aee0e2c2297
5
5
  SHA512:
6
- metadata.gz: 429be7bd8136a0a8d3b1a128a485bb58f6eeb71bd478743e728f2a6c5e9335b8d4362db86c72b5b8c177949d4400b54e239c81e9a77528a5c28c430ce005df88
7
- data.tar.gz: a4448becb14db389e6bb58da82a9958135603d93f42fb5de46d2193e45fb7b277c0dd72f1584be76da4d2ad35348f45d9703651d92ab6a3ae6ce523534a8fc9f
6
+ metadata.gz: e6754adb99ca3b4ae7f0294eb84547f630fd6fcb9bbe9191cf9112e77d9590b6dd5561aa94300e2c19e683d1a9e50072358d5814485de8c355ece40e349ca360
7
+ data.tar.gz: ede05535447c896dca5e443e883c9c90d7b225a7ccea09ee0628601d8650f9b1642aa2098c7c0ebedb6d0c8433645134402352140d45cb2dffc25eac07425fba
data/CHANGELOG.ja.md CHANGED
@@ -2,6 +2,14 @@
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
+
5
13
  ## 0.2.0 - 2026-11-01
6
14
 
7
15
  - `oops status` を追加。`recording`・`paused`・`off` のどれかを 1 行で出力し、記録中のときだけ終了コード 0 を返します。プロンプトやスクリプトから、このシェルが記録されているか確認できます。
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
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
+
5
13
  ## 0.2.0 - 2026-11-01
6
14
 
7
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.
data/README.ja.md CHANGED
@@ -21,9 +21,41 @@
21
21
  gem install oops-cli
22
22
  ```
23
23
 
24
- Homebrew tap: 準備中です。
24
+ Homebrew でも入れられます。
25
25
 
26
- macOS または Linux(WSL は Linux 扱い)、Ruby 3.3 以降、zsh または bash が必要です。実行時に依存する gem はありません。
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
+ ```
54
+
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` ブランチから更新するので、最新のリリースより先の内容になることがあります。
27
59
 
28
60
  ## シェルの設定
29
61
 
@@ -34,14 +66,21 @@ eval "$(oops init zsh)" # zsh
34
66
  eval "$(oops init bash)" # bash
35
67
  ```
36
68
 
37
- そのあと新しいタブを開いてください。開いているタブでは `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`)で反映されます。
38
76
 
39
77
  この行を置く場所が重要です。
40
78
 
41
79
  - oops の入った Ruby を `PATH` に通す設定(rbenv、asdf、mise、Homebrew の `shellenv` など)より**後ろ**に置きます。macOS ではログインシェルが `PATH` をリセットするため、この行が先にあるとシステムの Ruby 2.6 が選ばれて oops が起動しないことがあります。oops が動かなくてもシェル自体は普通に起動します。記録されないだけです。
42
80
  - 2 回実行したくない重い処理や出力の多い処理(nvm、プロンプトテーマ、バナーなど)より**前**に置きます。このスニペットは、小さな記録用プログラム(`oops record`、pty 中継)の下でシェルをもう一度起動します。そのため、この行より上は外側のシェルと内側のシェルで 2 回実行され、下は内側のシェルでだけ実行されます。
43
81
  - powerlevel10k を使う場合は、`~/.zshrc` の先頭にある instant prompt のブロックより**上**に置きます。instant prompt は `~/.zshrc` の残りを読む間、シェルの入出力をリダイレクトし、その間スニペットは何もしません(実際の端末でだけ動作するため)。すると oops は黙って記録しなくなります。`~/.zshrc` に行があるのにタブが記録されないときは `oops doctor` を実行してください。`recording: no` と表示されます。
44
- - 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` まで再起動してしまうためです)。
45
84
 
46
85
  おかしいと感じたら `oops doctor` を実行します。このタブが記録されているか、シェルとフックの読み込み状況、使われるクリップボードの方法、設定ファイル、セッションログのサイズ、Ruby のバージョンを報告します。
47
86
 
@@ -55,10 +94,11 @@ eval "$(oops init bash)" # bash
55
94
  | `oops 3` / `oops -n 3` | 直前の 3 コマンドを(古い順に)1 つのコードブロックとしてコピーする |
56
95
  | `oops err` | 終了コードが 0 以外だった直近のコマンドをコピーする(成功したコマンドは飛ばす) |
57
96
  | `oops err -n 2` | 直近の失敗した 2 コマンドを古い順にコピーする(成功したコマンドは飛ばす) |
58
- | `oops init zsh` / `oops init bash` | シェルのスニペットを出力する(`eval` 用) |
97
+ | `oops init zsh` / `oops init bash` / `oops init fish` | シェルのスニペットを出力する(`eval` 用。fish は `\| source`) |
59
98
  | `oops doctor` | 記録・フック・クリップボード・設定・ログサイズを診断する |
60
99
  | `oops pause` / `oops resume` | 記録を止める/再開する。停止中は何もディスクに書かれない |
61
100
  | `oops status` | `recording`・`paused`・`off` のどれかを 1 行で出力する(スクリプトやプロンプト向け) |
101
+ | `oops clear` / `oops clear --all` | 閉じたタブのログを削除する。`--all` を付けると、今のタブのログも空にする |
62
102
  | `oops record -- <shell>` | 内部用: 記録用プログラムの下でシェルを起動する。スニペットが呼び出す |
63
103
  | `oops --version`, `oops --help` | バージョンと使い方 |
64
104
 
@@ -156,12 +196,14 @@ replace: # コピー時に適用する単純な文字列置換
156
196
  secrets: # マスクする追加の正規表現
157
197
  - "sk_live_[A-Za-z0-9]+"
158
198
  clipboard: auto # auto | pbcopy | wl-copy | xclip | xsel | clip.exe | osc52 | 任意のコマンド(標準入力から読む)
199
+ retention_days: 7 # 閉じたタブのログをこの日数で削除する。false なら残す
159
200
  ```
160
201
 
161
202
  - 置換とマスクはコピーする瞬間に適用されます。記録済みのログは変更されないため、設定を変えると以前のコマンドにも反映されます。
162
203
  - `replace` のキーは長いものから順に、大文字小文字を区別して適用されます。
163
204
  - 未知のキーは警告されて無視されます。YAML が壊れている場合も警告が出て、設定なしで続行します。
164
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 の上限で削除されるまで残ります。
165
207
  - 自分で指定した `clipboard:` のコマンドは、独自のプロセスグループで 5 秒のタイムアウト付きで実行されます。そのため端末の読み取りや設定(パスワード入力、`stty` など)はできません。コピー中の Ctrl-C もそのコマンドには届きません。
166
208
 
167
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 アドレス(ループバックとプライベート範囲は対象外)。出力だけでなくコマンドラインにも適用されます。
@@ -172,15 +214,15 @@ clipboard: auto # auto | pbcopy | wl-copy | xclip | xsel | clip.exe | osc52
172
214
 
173
215
  ![oops の仕組み: 録音機があなたとシェルの間にいて、コマンドはディスク上のノートに書かれ、oops と打つと最後のページを秘密を隠して写し、どこにでも貼れる](docs/how-it-works.ja.svg)
174
216
 
175
- 1. `oops init` は、rc ファイルから `eval` するスニペットを出力します。まだ記録されていない端末の対話シェルでは、シェルを `oops record` の下で再起動します。
217
+ 1. `oops init` は、rc ファイルから `eval` する(fish では `| source` する)スニペットを出力します。まだ記録されていない端末の対話シェルでは、シェルを `oops record` の下で再起動します。
176
218
  2. `oops record` は、Ruby 製の小さな pty 中継です。キー入力とシェルの出力をそのまま通し、ウィンドウのリサイズをシェルに伝えます。
177
- 3. シェルのフック(zsh は `preexec`/`precmd`、bash は `DEBUG` トラップと `PROMPT_COMMAND`)が、各コマンドの前後に目に見えない APC マーカーを出力します。中継がそれを取り除くので、端末には見えません。
178
- 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 以下になるまで削除されます。開いているタブのログは削除されないので、忙しいタブが複数あると合計がそれを超えることがあります。
179
221
  5. `oops` は現在のタブのログの末尾を読み、テキストを整えてマスクし、コードブロックを組み立ててクリップボードに渡します。コマンドを再実行することはありません。
180
222
 
181
223
  ## プライバシーとセキュリティ
182
224
 
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` を使ってください。
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` を使ってください。
184
226
  - セキュリティ上の問題を非公開で報告する方法は [SECURITY.ja.md](SECURITY.ja.md) を見てください。公開の issue には書かないでください。
185
227
  - 記録される出力は信頼できません。実行したプログラムは、oops のマーカーに見せかけたテキストを出力できます。そのため oops は、`oops pause` / `oops resume` が作るフラグファイルと一致する一時停止・再開マーカーにだけ従います。出力によって黙って記録を止められることはありません。ただし、プログラムがログ上でコマンドの開始と終了の位置を偽装することはできるので、貼り付ける内容は確認してください。
186
228
 
@@ -194,16 +236,16 @@ clipboard: auto # auto | pbcopy | wl-copy | xclip | xsel | clip.exe | osc52
194
236
  - vim・less・tmux などの全画面アプリの出力は保存されず、`[full-screen app output omitted]` に置き換わります。
195
237
  - スペースで始まるコマンドラインは、zsh では記録されますが bash では記録されません(bash はそれを履歴に保存せず、oops はコマンドを履歴から読むため)。
196
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 はそのような行の最初のコマンドを記録します。
197
- - 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 は何もしなくて構いません。スニペットがやります。
198
240
  - bash: パイプラインの次の行では、`PIPESTATUS` に最後の終了ステータスしか入りません(`false | true` の後に `echo ${PIPESTATUS[*]}` を実行すると、`1 0` ではなく `0` と表示されます)。oops の `PROMPT_COMMAND` の項目が先に実行されるためです。`$?` と `$_` は保たれます。パイプラインの各ステータスを表示するプロンプト(starship の `pipestatus`)は、bash では表示が少なくなります。zsh は `pipestatus` を保ちます。
199
241
  - macOS: `pbcopy` は文字エンコーディングを `LANG` / `LC_*` から決めます。日本語などの非 ASCII 文字が文字化けして届くときは、UTF-8 のロケール(例: `LANG=en_US.UTF-8`)が設定されているか確認してください。SSH 越しで起動したシェルでは設定されていないことがあります。
200
- - fish とネイティブ Windows にはまだ対応していません。
242
+ - ネイティブ Windows にはまだ対応していません。
201
243
 
202
244
  ## 開発
203
245
 
204
246
  ### 手元の環境を整える
205
247
 
206
- 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 では入れています)。
207
249
 
208
250
  ```sh
209
251
  git clone https://github.com/ysb3996-blip/oops.git
@@ -237,18 +279,18 @@ bundle exec exe/oops doctor
237
279
 
238
280
  ```sh
239
281
  gem build oops-cli.gemspec
240
- gem install ./oops-cli-0.2.0.gem # ファイル名は lib/oops/version.rb に合わせる
282
+ gem install ./oops-cli-0.3.0.gem # ファイル名は lib/oops/version.rb に合わせる
241
283
  rbenv rehash # rbenv を使う場合のみ
242
284
  ```
243
285
 
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`)。
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`)。
245
287
  - 変更のたびに入れ直してください。インストールされた gem はコピーで、チェックアウトへのリンクではありません。
246
288
  - 削除するには `gem uninstall oops-cli` です。
247
289
  - `eval "$(oops init zsh)"` は、プロンプトで打つだけでなく rc ファイルに書いてください。記録されるシェルは rc ファイルを読み直すため、行がないとフックが入りません(`oops doctor` に `hooks: missing` と出ます)。
248
290
 
249
291
  作業中の端末で `oops record` を実行しないでください。テストは自前の pty を通して動かします。試すときは、新しい端末タブを使います。試した結果として残るログを消すには、「プライバシーとセキュリティ」を参照してください。
250
292
 
251
- テストは、書式、秘密情報のマスク、ログの読み取り、ストリームパーサー、そして実際の zsh と bash を使った中継のエンドツーエンド実行を対象にしています。
293
+ テストは、書式、秘密情報のマスク、ログの読み取り、ストリームパーサー、そして実際の zsh・bash・fish を使った中継のエンドツーエンド実行を対象にしています。
252
294
 
253
295
  リリース前の手動チェックリスト:
254
296
 
@@ -259,7 +301,7 @@ rbenv rehash # rbenv を使う場合のみ
259
301
 
260
302
  ## コントリビュート
261
303
 
262
- バグ報告、アイデア、プルリクエストを歓迎します。[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#プルリクエストに必ず貼るもの))。
263
305
 
264
306
  ## ライセンス
265
307
 
data/README.md CHANGED
@@ -21,9 +21,41 @@ What lands on your clipboard is a code block with the command, its output, and t
21
21
  gem install oops-cli
22
22
  ```
23
23
 
24
- Homebrew tap: coming soon.
24
+ Or with Homebrew:
25
25
 
26
- Requires Ruby 3.3 or newer on macOS or Linux (WSL counts as Linux), and zsh or bash. There are no runtime gem dependencies.
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
+ ```
54
+
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.
27
59
 
28
60
  ## Set up your shell
29
61
 
@@ -34,14 +66,21 @@ eval "$(oops init zsh)" # zsh
34
66
  eval "$(oops init bash)" # bash
35
67
  ```
36
68
 
37
- 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`).
38
76
 
39
77
  Where to put the line matters:
40
78
 
41
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.
42
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.
43
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`.
44
- - 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.)
45
84
 
46
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.
47
86
 
@@ -55,10 +94,11 @@ To turn oops off for one shell, start it with `OOPS_DISABLE=1` (for example `OOP
55
94
  | `oops 3` / `oops -n 3` | Copy the last 3 commands (oldest first) as one code block |
56
95
  | `oops err` | Copy the last command that exited non-zero, skipping over successful ones |
57
96
  | `oops err -n 2` | Copy the last 2 failed commands (oldest first), skipping over successful ones |
58
- | `oops init zsh` / `oops init bash` | Print the shell snippet (for `eval`) |
97
+ | `oops init zsh` / `oops init bash` / `oops init fish` | Print the shell snippet (for `eval`, or `\| source` in fish) |
59
98
  | `oops doctor` | Diagnose recording, hooks, clipboard, config and log size |
60
99
  | `oops pause` / `oops resume` | Stop and restart recording. While paused, nothing is written to disk |
61
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 |
62
102
  | `oops record -- <shell>` | Internal: runs a shell under the recorder. The snippet calls this |
63
103
  | `oops --version`, `oops --help` | Version and usage |
64
104
 
@@ -156,12 +196,14 @@ replace: # plain string replacements applied when copying
156
196
  secrets: # extra regular expressions to mask
157
197
  - "sk_live_[A-Za-z0-9]+"
158
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
159
200
  ```
160
201
 
161
202
  - Replacements and masking are applied at the moment you copy; the recorded log stays untouched, so a config change also affects earlier commands.
162
203
  - `replace` keys are applied longest first and are case-sensitive.
163
204
  - Unknown keys produce a warning and are ignored. A broken YAML file produces a warning and oops carries on without a config.
164
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.
165
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.
166
208
 
167
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.
@@ -172,15 +214,15 @@ Masking is pattern based, so it is a safety net, not a guarantee: a secret in a
172
214
 
173
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)
174
216
 
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`.
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`.
176
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.
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.
178
- 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.
179
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.
180
222
 
181
223
  ## Privacy and security
182
224
 
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.
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.
184
226
  - To report a security problem privately, see [SECURITY.md](SECURITY.md). Please do not open a public issue for it.
185
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.
186
228
 
@@ -194,16 +236,16 @@ Masking is pattern based, so it is a safety net, not a guarantee: a secret in a
194
236
  - Output of full-screen apps (vim, less, tmux and so on) is not kept; it is replaced by `[full-screen app output omitted]`.
195
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).
196
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.
197
- - 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.
198
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`.
199
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.
200
- - fish and native Windows are not supported yet.
242
+ - Native Windows is not supported yet.
201
243
 
202
244
  ## Development
203
245
 
204
246
  ### Set up your machine
205
247
 
206
- 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).
207
249
 
208
250
  ```sh
209
251
  git clone https://github.com/ysb3996-blip/oops.git
@@ -237,18 +279,18 @@ To try it as your shell does, build and install the gem into the Ruby you are us
237
279
 
238
280
  ```sh
239
281
  gem build oops-cli.gemspec
240
- gem install ./oops-cli-0.2.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
241
283
  rbenv rehash # only with rbenv
242
284
  ```
243
285
 
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`.
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`.
245
287
  - Reinstall after every change. The installed gem is a copy, not a link to your checkout.
246
288
  - To remove it: `gem uninstall oops-cli`.
247
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`).
248
290
 
249
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".
250
292
 
251
- 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.
252
294
 
253
295
  Manual checklist before a release:
254
296
 
@@ -259,7 +301,7 @@ Manual checklist before a release:
259
301
 
260
302
  ## Contributing
261
303
 
262
- 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).
263
305
 
264
306
  ## License
265
307
 
data/lib/oops/cli.rb CHANGED
@@ -22,13 +22,14 @@ module Oops
22
22
  when "doctor" then return doctor(argv.drop(1), env: env, stdin: stdin, stdout: stdout)
23
23
  when "pause", "resume" then return toggle(argv.first, argv.drop(1), env: env, stdout: stdout)
24
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)
25
26
  end
26
27
 
27
28
  options = { head: nil, tail: nil, github: false, show_secrets: false, time: false, output_only: false }
28
29
  count = nil
29
30
  parser = OptionParser.new do |o|
30
31
  o.banner = "Usage: oops [N | err] [-n N] [options]\n " \
31
- "oops init <shell> | pause | resume | status | doctor"
32
+ "oops init <shell> | pause | resume | status | clear [--all] | doctor"
32
33
  o.on("-n N", "Copy the last N commands (with err, last N failures)") do |v|
33
34
  raise UsageError, COUNT_REPEATED if count
34
35
 
@@ -117,8 +118,12 @@ module Oops
117
118
  path = env["OOPS_SESSION"].to_s
118
119
  return SessionLog.new(path) if !path.empty? && File.file?(path)
119
120
 
120
- shell, rc = env["SHELL"].to_s.end_with?("bash") ? %w[bash ~/.bashrc] : %w[zsh ~/.zshrc]
121
- 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} " \
122
127
  "to #{rc} (after whatever puts oops's Ruby on PATH, as early as possible) " \
123
128
  "and open a new tab."
124
129
  end
@@ -176,6 +181,25 @@ module Oops
176
181
  word == "recording" ? 0 : 1
177
182
  end
178
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
+
179
203
  # `oops doctor`: diagnostics on stdout; always exits 0.
180
204
  def self.doctor(args, env:, stdin:, stdout:)
181
205
  raise UsageError, "usage: oops doctor" unless args.empty?
@@ -197,7 +221,7 @@ module Oops
197
221
  Recorder.new(args, env: env, stdin: stdin, stdout: stdout, stderr: stderr).run
198
222
  end
199
223
 
200
- # `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)"`.
201
225
  def self.init(args, stdout:, stderr:)
202
226
  supported = ShellInit::SHELLS.join(", ")
203
227
  if args.size != 1
@@ -213,6 +237,6 @@ module Oops
213
237
  end
214
238
 
215
239
  private_class_method :positive, :target, :copy, :report_command, :report_counts, :recording_log, :toggle,
216
- :write_marker, :status, :doctor
240
+ :write_marker, :status, :clear, :doctor
217
241
  end
218
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
 
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
@@ -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
@@ -125,6 +148,12 @@ module Oops
125
148
  nil
126
149
  end
127
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
+
128
157
  private
129
158
 
130
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.2.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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ysb3996-blip
@@ -40,6 +40,7 @@ files:
40
40
  - lib/oops/secrets.rb
41
41
  - lib/oops/session_log.rb
42
42
  - lib/oops/shell/oops.bash
43
+ - lib/oops/shell/oops.fish
43
44
  - lib/oops/shell/oops.zsh
44
45
  - lib/oops/shell_init.rb
45
46
  - lib/oops/stream_parser.rb