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 +4 -4
- data/CHANGELOG.ja.md +16 -0
- data/CHANGELOG.md +16 -0
- data/README.ja.md +89 -17
- data/README.md +89 -17
- data/SECURITY.ja.md +34 -0
- data/SECURITY.md +34 -0
- data/lib/oops/cli.rb +72 -16
- data/lib/oops/config.rb +14 -2
- data/lib/oops/formatter.rb +32 -6
- data/lib/oops/recorder.rb +2 -1
- data/lib/oops/secrets.rb +3 -1
- data/lib/oops/session_log.rb +47 -19
- data/lib/oops/shell/oops.fish +99 -0
- data/lib/oops/shell_init.rb +15 -9
- data/lib/oops/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e1003df0d3be3f85135c9f36b34aaed5d5ce02002a22d1c75baafd235180b15
|
|
4
|
+
data.tar.gz: b67d4984e973482dc9c4f4938d14e65dfc4e99c7c149a2deea7a6aee0e2c2297
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
[](https://rubygems.org/gems/oops-cli) [](https://github.com/ysb3996-blip/oops/actions/workflows/ci.yml) [](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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
215
|
+

|
|
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
|
|
149
|
-
4. コマンドが終わるたびに、中継は 1 行の JSON(コマンド、ディレクトリ、時刻、終了コード、生の出力)をタブごとのログに追記します。置き場所は `${XDG_STATE_HOME:-~/.local/state}/oops/sessions/` です。出力は 1 コマンドあたり 1 MB まで(末尾を残す)。各タブのログは 50 MB までで、超えると新しいコマンドだけを残して書き直されます(約 37 MiB
|
|
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
|
|
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
|
-
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
+
[](https://rubygems.org/gems/oops-cli) [](https://github.com/ysb3996-blip/oops/actions/workflows/ci.yml) [](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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
215
|
+

|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
55
|
-
#
|
|
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 =
|
|
74
|
-
raise Failure, (target
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
data/lib/oops/formatter.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
data/lib/oops/session_log.rb
CHANGED
|
@@ -25,19 +25,19 @@ module Oops
|
|
|
25
25
|
false
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# Deletes
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/oops/shell_init.rb
CHANGED
|
@@ -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,
|
|
4
|
-
# on PATH and as early as possible after
|
|
5
|
-
# lib/oops/shell/; the absolute path of the oops
|
|
6
|
-
# them, so a later change to PATH cannot break
|
|
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
|
|
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
|
-
|
|
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
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.
|
|
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
|