keeplog 0.1.0__tar.gz
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.
- keeplog-0.1.0/.github/workflows/publish.yml +20 -0
- keeplog-0.1.0/.github/workflows/python-app.yml +40 -0
- keeplog-0.1.0/.gitignore +14 -0
- keeplog-0.1.0/LICENSE +21 -0
- keeplog-0.1.0/PKG-INFO +12 -0
- keeplog-0.1.0/README.md +3 -0
- keeplog-0.1.0/install.sh +37 -0
- keeplog-0.1.0/pyproject.toml +18 -0
- keeplog-0.1.0/src/keeplog/__init__.py +0 -0
- keeplog-0.1.0/src/keeplog/__main__.py +3 -0
- keeplog-0.1.0/src/keeplog/capture.py +240 -0
- keeplog-0.1.0/src/keeplog/cli.py +121 -0
- keeplog-0.1.0/src/keeplog/config.py +49 -0
- keeplog-0.1.0/src/keeplog/db.py +253 -0
- keeplog-0.1.0/src/keeplog/hooks/bash.sh +23 -0
- keeplog-0.1.0/src/keeplog/hooks/zsh.sh +24 -0
- keeplog-0.1.0/src/keeplog/install.py +75 -0
- keeplog-0.1.0/src/keeplog/search.py +65 -0
- keeplog-0.1.0/tests/__init__.py +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: actions/setup-python@v5
|
|
13
|
+
with:
|
|
14
|
+
python-version: "3.11"
|
|
15
|
+
- run: python -m pip install build twine
|
|
16
|
+
- run: python -m build
|
|
17
|
+
- run: python -m twine upload dist/*
|
|
18
|
+
env:
|
|
19
|
+
TWINE_USERNAME: __token__
|
|
20
|
+
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# This workflow will install Python dependencies, run tests and lint with a single version of Python
|
|
2
|
+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
|
3
|
+
|
|
4
|
+
name: Python application
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
branches: [ "main" ]
|
|
9
|
+
pull_request:
|
|
10
|
+
branches: [ "main" ]
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
build:
|
|
17
|
+
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
- name: Set up Python 3.10
|
|
23
|
+
uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.10"
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: |
|
|
28
|
+
python -m pip install --upgrade pip
|
|
29
|
+
python -m pip install flake8 pytest
|
|
30
|
+
python -m pip install -e .
|
|
31
|
+
- name: Lint with flake8
|
|
32
|
+
run: |
|
|
33
|
+
# stop the build if there are Python syntax errors or undefined names
|
|
34
|
+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
|
35
|
+
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
|
36
|
+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
|
37
|
+
- name: Test with pytest
|
|
38
|
+
run: |
|
|
39
|
+
python -m keeplog init
|
|
40
|
+
python -m keeplog status
|
keeplog-0.1.0/.gitignore
ADDED
keeplog-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rathina Devan E M
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
keeplog-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keeplog
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terminal session logger with full-text search
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# keeplog
|
|
11
|
+
|
|
12
|
+
Terminal session logger with full-text search.
|
keeplog-0.1.0/README.md
ADDED
keeplog-0.1.0/install.sh
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
REPO="rathinadev/keeplog"
|
|
5
|
+
|
|
6
|
+
echo "==> Installing keeplog..."
|
|
7
|
+
|
|
8
|
+
if ! command -v python3 &>/dev/null; then
|
|
9
|
+
echo "ERROR: python3 is required. Install it first:"
|
|
10
|
+
echo " macOS: brew install python"
|
|
11
|
+
echo " Linux: apt install python3 python3-pip"
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
if ! command -v pip3 &>/dev/null && ! python3 -m pip --version &>/dev/null; then
|
|
16
|
+
echo "ERROR: pip is required."
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
python3 -m pip install --quiet --upgrade keeplog 2>/dev/null || {
|
|
21
|
+
echo "==> Installing from source..."
|
|
22
|
+
TMPDIR=$(mktemp -d)
|
|
23
|
+
cd "$TMPDIR"
|
|
24
|
+
curl -fsSL "https://github.com/$REPO/archive/main.tar.gz" | tar xz --strip=1
|
|
25
|
+
python3 -m pip install --quiet -e .
|
|
26
|
+
cd /
|
|
27
|
+
rm -rf "$TMPDIR"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
echo "==> Initializing database..."
|
|
31
|
+
python3 -m keeplog init
|
|
32
|
+
|
|
33
|
+
echo "==> Adding to shell rc..."
|
|
34
|
+
python3 -m keeplog setup
|
|
35
|
+
|
|
36
|
+
echo ""
|
|
37
|
+
echo "Done! Restart your terminal or run: source ~/.zshrc"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keeplog"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terminal session logger with full-text search"
|
|
9
|
+
license = {text = "MIT"}
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
keeplog = "keeplog.cli:main"
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["src/keeplog"]
|
|
File without changes
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pty
|
|
3
|
+
import select
|
|
4
|
+
import signal
|
|
5
|
+
import struct
|
|
6
|
+
import fcntl
|
|
7
|
+
import sys
|
|
8
|
+
import termios
|
|
9
|
+
import tty
|
|
10
|
+
import time
|
|
11
|
+
import tempfile
|
|
12
|
+
import shutil
|
|
13
|
+
|
|
14
|
+
from keeplog.db import create_session, end_session, save_command
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _find_shell() -> str:
|
|
18
|
+
return os.environ.get("SHELL", "/bin/bash")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _shell_name(path: str) -> str:
|
|
22
|
+
return os.path.basename(path).lower()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _hooks_dir() -> str:
|
|
26
|
+
return os.path.join(os.path.dirname(__file__), "hooks")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_rc(shell_path: str):
|
|
30
|
+
name = _shell_name(shell_path)
|
|
31
|
+
hook_path = os.path.join(_hooks_dir(), f"{name}.sh")
|
|
32
|
+
user_rc = os.path.expanduser(f"~/.{name}rc")
|
|
33
|
+
|
|
34
|
+
lines = ["export __KEEPLOG_READY=0"]
|
|
35
|
+
if os.path.exists(user_rc):
|
|
36
|
+
lines.append(f"source {user_rc}")
|
|
37
|
+
lines.append(f"source {hook_path}")
|
|
38
|
+
lines.append("__KEEPLOG_READY=1")
|
|
39
|
+
|
|
40
|
+
fd, path = tempfile.mkstemp(prefix=f"keeplog_{name}_", suffix=".sh", text=True)
|
|
41
|
+
with os.fdopen(fd, "w") as f:
|
|
42
|
+
f.write("\n".join(lines) + "\n")
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_zsh_zdotdir():
|
|
47
|
+
hook_path = os.path.join(_hooks_dir(), "zsh.sh")
|
|
48
|
+
user_zshrc = os.path.expanduser("~/.zshrc")
|
|
49
|
+
|
|
50
|
+
zdotdir = tempfile.mkdtemp(prefix="keeplog_zsh_")
|
|
51
|
+
zshrc_path = os.path.join(zdotdir, ".zshrc")
|
|
52
|
+
with open(zshrc_path, "w") as f:
|
|
53
|
+
f.write("export __KEEPLOG_READY=0\n")
|
|
54
|
+
if os.path.exists(user_zshrc):
|
|
55
|
+
f.write(f"source {user_zshrc}\n")
|
|
56
|
+
f.write(f"source {hook_path}\n")
|
|
57
|
+
f.write("__KEEPLOG_READY=1\n")
|
|
58
|
+
return zdotdir
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _setwinsize(fd, rows, cols):
|
|
62
|
+
s = struct.pack("HHHH", rows, cols, 0, 0)
|
|
63
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, s)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_term_size():
|
|
67
|
+
s = struct.pack("HHHH", 0, 0, 0, 0)
|
|
68
|
+
try:
|
|
69
|
+
result = fcntl.ioctl(sys.stdin.fileno(), termios.TIOCGWINSZ, s)
|
|
70
|
+
rows, cols = struct.unpack("HHHH", result)[:2]
|
|
71
|
+
return rows or 24, cols or 80
|
|
72
|
+
except OSError:
|
|
73
|
+
return 24, 80
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _handle_sigwinch(master_fd):
|
|
77
|
+
rows, cols = _get_term_size()
|
|
78
|
+
_setwinsize(master_fd, rows, cols)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def record_session(mode: str = "full"):
|
|
82
|
+
if not sys.stdin.isatty():
|
|
83
|
+
print("keeplog record requires a terminal. Run this directly in your shell, not via pipe/script.")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
shell = _find_shell()
|
|
87
|
+
shell_bin = _shell_name(shell)
|
|
88
|
+
|
|
89
|
+
ctrl_r, ctrl_w = os.pipe()
|
|
90
|
+
os.set_inheritable(ctrl_w, True)
|
|
91
|
+
|
|
92
|
+
session_id = create_session()
|
|
93
|
+
rc_path = None
|
|
94
|
+
zdotdir = None
|
|
95
|
+
|
|
96
|
+
if shell_bin == "zsh":
|
|
97
|
+
zdotdir = _build_zsh_zdotdir()
|
|
98
|
+
else:
|
|
99
|
+
rc_path = _build_rc(shell)
|
|
100
|
+
|
|
101
|
+
pid, master_fd = pty.fork()
|
|
102
|
+
|
|
103
|
+
if pid == 0:
|
|
104
|
+
os.environ["KEEPLOG_CTRL_FD"] = str(ctrl_w)
|
|
105
|
+
os.environ["KEEPLOG_SESSION_ID"] = str(session_id)
|
|
106
|
+
os.environ["KEEPLOG_MODE"] = mode
|
|
107
|
+
os.environ["KEEPLOG_ACTIVE"] = "1"
|
|
108
|
+
os.environ["SHELL_SESSIONS_DISABLE"] = "1"
|
|
109
|
+
os.close(ctrl_r)
|
|
110
|
+
|
|
111
|
+
if shell_bin == "zsh":
|
|
112
|
+
os.environ["ZDOTDIR"] = zdotdir
|
|
113
|
+
os.execle(shell, shell, "-i", os.environ)
|
|
114
|
+
else:
|
|
115
|
+
os.execle(shell, shell, "--rcfile", rc_path, os.environ)
|
|
116
|
+
os._exit(1)
|
|
117
|
+
|
|
118
|
+
os.close(ctrl_w)
|
|
119
|
+
|
|
120
|
+
rows, cols = _get_term_size()
|
|
121
|
+
_setwinsize(master_fd, rows, cols)
|
|
122
|
+
|
|
123
|
+
def winch(_sig, _frame):
|
|
124
|
+
_setwinsize(master_fd, *_get_term_size())
|
|
125
|
+
|
|
126
|
+
signal.signal(signal.SIGWINCH, winch)
|
|
127
|
+
|
|
128
|
+
old_term = termios.tcgetattr(sys.stdin.fileno())
|
|
129
|
+
try:
|
|
130
|
+
tty.setraw(sys.stdin.fileno())
|
|
131
|
+
|
|
132
|
+
output_buf = bytearray()
|
|
133
|
+
seq = 0
|
|
134
|
+
current_cmd = None
|
|
135
|
+
current_cwd = ""
|
|
136
|
+
current_start = 0.0
|
|
137
|
+
fds = [master_fd, sys.stdin, ctrl_r]
|
|
138
|
+
|
|
139
|
+
while True:
|
|
140
|
+
try:
|
|
141
|
+
r, _w, _e = select.select(fds, [], [])
|
|
142
|
+
except InterruptedError:
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
if master_fd in r:
|
|
146
|
+
try:
|
|
147
|
+
data = os.read(master_fd, 65536)
|
|
148
|
+
except OSError:
|
|
149
|
+
break
|
|
150
|
+
if not data:
|
|
151
|
+
break
|
|
152
|
+
output_buf.extend(data)
|
|
153
|
+
try:
|
|
154
|
+
sys.stdout.buffer.write(data)
|
|
155
|
+
sys.stdout.buffer.flush()
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
if sys.stdin in r:
|
|
160
|
+
try:
|
|
161
|
+
data = os.read(sys.stdin.fileno(), 65536)
|
|
162
|
+
except OSError:
|
|
163
|
+
break
|
|
164
|
+
if not data:
|
|
165
|
+
break
|
|
166
|
+
try:
|
|
167
|
+
os.write(master_fd, data)
|
|
168
|
+
except OSError:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
if ctrl_r in r:
|
|
172
|
+
try:
|
|
173
|
+
raw = os.read(ctrl_r, 4096)
|
|
174
|
+
except OSError:
|
|
175
|
+
break
|
|
176
|
+
if not raw:
|
|
177
|
+
break
|
|
178
|
+
for line in raw.decode("utf-8", errors="replace").split("\n"):
|
|
179
|
+
line = line.strip()
|
|
180
|
+
if not line:
|
|
181
|
+
continue
|
|
182
|
+
if line.startswith("C:"):
|
|
183
|
+
_flush_cmd(
|
|
184
|
+
session_id, seq, current_cmd,
|
|
185
|
+
current_cwd, mode, output_buf,
|
|
186
|
+
current_start,
|
|
187
|
+
)
|
|
188
|
+
seq += 1
|
|
189
|
+
current_cmd = line[2:]
|
|
190
|
+
current_cwd = os.getcwd()
|
|
191
|
+
current_start = time.time()
|
|
192
|
+
output_buf = bytearray()
|
|
193
|
+
elif line.startswith("E:") and current_cmd is not None:
|
|
194
|
+
try:
|
|
195
|
+
ec = int(line[2:])
|
|
196
|
+
except ValueError:
|
|
197
|
+
ec = -1
|
|
198
|
+
_flush_cmd(
|
|
199
|
+
session_id, seq, current_cmd,
|
|
200
|
+
current_cwd, mode, output_buf,
|
|
201
|
+
current_start, ec,
|
|
202
|
+
)
|
|
203
|
+
seq += 1
|
|
204
|
+
current_cmd = None
|
|
205
|
+
output_buf = bytearray()
|
|
206
|
+
|
|
207
|
+
finally:
|
|
208
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_term)
|
|
209
|
+
try:
|
|
210
|
+
if rc_path:
|
|
211
|
+
os.remove(rc_path)
|
|
212
|
+
if zdotdir:
|
|
213
|
+
shutil.rmtree(zdotdir, ignore_errors=True)
|
|
214
|
+
except OSError:
|
|
215
|
+
pass
|
|
216
|
+
end_session(session_id)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _strip_ansi(text: str) -> str:
|
|
220
|
+
import re
|
|
221
|
+
ansi_re = re.compile(
|
|
222
|
+
r"\x1b\[[0-9;?]*[a-zA-Z]"
|
|
223
|
+
r"|\x1b\].*?\x07"
|
|
224
|
+
r"|\x1b\([0-9a-zA-Z]"
|
|
225
|
+
)
|
|
226
|
+
return ansi_re.sub("", text)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _flush_cmd(session_id, seq, command, cwd, mode, output_buf, start_time, exit_code=None):
|
|
230
|
+
if exit_code is None:
|
|
231
|
+
exit_code = -1
|
|
232
|
+
cmd = (command or "").strip()
|
|
233
|
+
if not cmd:
|
|
234
|
+
return
|
|
235
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
236
|
+
output = _strip_ansi(output_buf.decode("utf-8", errors="replace"))
|
|
237
|
+
save_command(
|
|
238
|
+
session_id, seq, cmd, cwd or os.getcwd(),
|
|
239
|
+
exit_code, duration_ms, mode, output if mode == "full" else None,
|
|
240
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
import signal
|
|
4
|
+
|
|
5
|
+
from keeplog.db import (
|
|
6
|
+
init_db, search as db_search, list_recent, get_command,
|
|
7
|
+
get_stats, get_last_session, export_all, clear_old,
|
|
8
|
+
)
|
|
9
|
+
from keeplog.capture import record_session
|
|
10
|
+
from keeplog.install import setup_hook, remove_hook
|
|
11
|
+
from keeplog.search import search_interactive
|
|
12
|
+
from keeplog.config import load as load_config, save as save_config, get as get_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
if len(sys.argv) < 2:
|
|
17
|
+
print("Usage: keeplog <command>")
|
|
18
|
+
print("Commands:")
|
|
19
|
+
print(" record Start recording session")
|
|
20
|
+
print(" setup Add auto-start hook to shell rc")
|
|
21
|
+
print(" remove Remove auto-start hook from shell rc")
|
|
22
|
+
print(" search <query> Interactive fzf search")
|
|
23
|
+
print(" recent Show recent commands")
|
|
24
|
+
print(" get <id> Show full command details")
|
|
25
|
+
print(" status Show stats")
|
|
26
|
+
print(" last Show last session")
|
|
27
|
+
print(" export Export all data as JSON")
|
|
28
|
+
print(" clear <days> Clear old data (default 30 days)")
|
|
29
|
+
print(" config [key val] Get/set configuration")
|
|
30
|
+
print(" init Initialize database")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
cmd = sys.argv[1]
|
|
34
|
+
|
|
35
|
+
if cmd == "init":
|
|
36
|
+
init_db()
|
|
37
|
+
print("Database initialized")
|
|
38
|
+
|
|
39
|
+
elif cmd == "record":
|
|
40
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
41
|
+
mode = sys.argv[2] if len(sys.argv) > 2 else get_config("mode")
|
|
42
|
+
from keeplog.db import clear_old
|
|
43
|
+
clear_old(get_config("retention_days"))
|
|
44
|
+
record_session(mode)
|
|
45
|
+
|
|
46
|
+
elif cmd == "search":
|
|
47
|
+
if len(sys.argv) < 3:
|
|
48
|
+
print("Usage: keeplog search <query>")
|
|
49
|
+
return
|
|
50
|
+
search_interactive(sys.argv[2])
|
|
51
|
+
|
|
52
|
+
elif cmd == "recent":
|
|
53
|
+
results = list_recent()
|
|
54
|
+
for r in results:
|
|
55
|
+
print(f" [{r['id']}] {r['command']}")
|
|
56
|
+
|
|
57
|
+
elif cmd == "get":
|
|
58
|
+
if len(sys.argv) < 3:
|
|
59
|
+
print("Usage: keeplog get <id>")
|
|
60
|
+
return
|
|
61
|
+
cmd_data = get_command(int(sys.argv[2]))
|
|
62
|
+
if cmd_data:
|
|
63
|
+
print(f"Command: {cmd_data['command']}")
|
|
64
|
+
print(f"CWD: {cmd_data['cwd']}")
|
|
65
|
+
print(f"Exit: {cmd_data['exit_code']}")
|
|
66
|
+
print(f"Time: {cmd_data['timestamp']}")
|
|
67
|
+
if cmd_data.get("output"):
|
|
68
|
+
print(f"Output:\n{cmd_data['output']}")
|
|
69
|
+
else:
|
|
70
|
+
print("Not found")
|
|
71
|
+
|
|
72
|
+
elif cmd == "status":
|
|
73
|
+
s = get_stats()
|
|
74
|
+
print(f"Commands recorded: {s['commands']}")
|
|
75
|
+
print(f"Sessions: {s['sessions']}")
|
|
76
|
+
print(f"Storage: {_fmt_bytes(s['storage_bytes'])}")
|
|
77
|
+
print(f"Last command: {s['last_command'] or 'N/A'}")
|
|
78
|
+
|
|
79
|
+
elif cmd == "last":
|
|
80
|
+
cmds = get_last_session()
|
|
81
|
+
if not cmds:
|
|
82
|
+
print("No sessions yet")
|
|
83
|
+
return
|
|
84
|
+
for c in cmds:
|
|
85
|
+
print(f"[{c['sequence']}] {c['command']} (exit: {c['exit_code']})")
|
|
86
|
+
|
|
87
|
+
elif cmd == "export":
|
|
88
|
+
data = export_all()
|
|
89
|
+
json.dump(data, sys.stdout, indent=2, default=str)
|
|
90
|
+
print()
|
|
91
|
+
|
|
92
|
+
elif cmd == "clear":
|
|
93
|
+
days = int(sys.argv[2]) if len(sys.argv) > 2 else 30
|
|
94
|
+
clear_old(days)
|
|
95
|
+
print(f"Cleared data older than {days} days")
|
|
96
|
+
|
|
97
|
+
elif cmd == "setup":
|
|
98
|
+
setup_hook()
|
|
99
|
+
|
|
100
|
+
elif cmd == "remove":
|
|
101
|
+
remove_hook()
|
|
102
|
+
|
|
103
|
+
elif cmd == "config":
|
|
104
|
+
cfg = load_config()
|
|
105
|
+
if len(sys.argv) >= 4:
|
|
106
|
+
save_config({sys.argv[2]: sys.argv[3]})
|
|
107
|
+
print(f"Set {sys.argv[2]} = {sys.argv[3]}")
|
|
108
|
+
else:
|
|
109
|
+
for k, v in cfg.items():
|
|
110
|
+
print(f" {k} = {v}")
|
|
111
|
+
|
|
112
|
+
else:
|
|
113
|
+
print(f"Unknown command: {cmd}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _fmt_bytes(b: int) -> str:
|
|
117
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
118
|
+
if b < 1024:
|
|
119
|
+
return f"{b:.1f} {unit}"
|
|
120
|
+
b /= 1024
|
|
121
|
+
return f"{b:.1f} TB"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _config_dir() -> Path:
|
|
9
|
+
if sys.platform == "darwin":
|
|
10
|
+
base = Path.home() / "Library" / "Application Support"
|
|
11
|
+
else:
|
|
12
|
+
base = Path.home() / ".config"
|
|
13
|
+
return base / "keeplog"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
CONFIG_PATH = _config_dir() / "config.json"
|
|
17
|
+
|
|
18
|
+
_DEFAULTS = {
|
|
19
|
+
"mode": "full",
|
|
20
|
+
"retention_days": 30,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load() -> dict:
|
|
25
|
+
if not CONFIG_PATH.exists():
|
|
26
|
+
return dict(_DEFAULTS)
|
|
27
|
+
try:
|
|
28
|
+
with open(CONFIG_PATH) as f:
|
|
29
|
+
return {**_DEFAULTS, **json.load(f)}
|
|
30
|
+
except (json.JSONDecodeError, OSError):
|
|
31
|
+
return dict(_DEFAULTS)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save(cfg: dict):
|
|
35
|
+
_config_dir().mkdir(parents=True, exist_ok=True)
|
|
36
|
+
merged = {**_DEFAULTS, **cfg}
|
|
37
|
+
with open(CONFIG_PATH, "w") as f:
|
|
38
|
+
json.dump(merged, f, indent=2)
|
|
39
|
+
f.write("\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get(key: str) -> Any:
|
|
43
|
+
return load().get(key, _DEFAULTS.get(key))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def set_key(key: str, value: Any):
|
|
47
|
+
cfg = load()
|
|
48
|
+
cfg[key] = value
|
|
49
|
+
save(cfg)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _data_dir() -> Path:
|
|
8
|
+
if sys.platform == "darwin":
|
|
9
|
+
base = Path.home() / "Library" / "Application Support"
|
|
10
|
+
else:
|
|
11
|
+
base = Path.home() / ".local" / "share"
|
|
12
|
+
return base / "keeplog"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DB_PATH = _data_dir() / "logs.db"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_conn() -> sqlite3.Connection:
|
|
19
|
+
_data_dir().mkdir(parents=True, exist_ok=True)
|
|
20
|
+
conn = sqlite3.connect(str(DB_PATH))
|
|
21
|
+
conn.row_factory = sqlite3.Row
|
|
22
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
23
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
24
|
+
return conn
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _db_exists() -> bool:
|
|
28
|
+
return DB_PATH.exists()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ensure_db():
|
|
32
|
+
if not _db_exists():
|
|
33
|
+
init_db()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def init_db():
|
|
37
|
+
conn = _get_conn()
|
|
38
|
+
conn.executescript("""
|
|
39
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
40
|
+
id INTEGER PRIMARY KEY,
|
|
41
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
42
|
+
ended_at TEXT,
|
|
43
|
+
hostname TEXT
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS commands (
|
|
47
|
+
id INTEGER PRIMARY KEY,
|
|
48
|
+
session_id INTEGER NOT NULL,
|
|
49
|
+
sequence INTEGER NOT NULL,
|
|
50
|
+
command TEXT NOT NULL,
|
|
51
|
+
cwd TEXT,
|
|
52
|
+
exit_code INTEGER,
|
|
53
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
|
54
|
+
duration_ms INTEGER,
|
|
55
|
+
mode TEXT NOT NULL DEFAULT 'full',
|
|
56
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS commands_fts USING fts5(
|
|
60
|
+
command, cwd, content='commands', content_rowid='id'
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TRIGGER IF NOT EXISTS commands_ai AFTER INSERT ON commands BEGIN
|
|
64
|
+
INSERT INTO commands_fts(rowid, command, cwd) VALUES (new.id, new.command, new.cwd);
|
|
65
|
+
END;
|
|
66
|
+
|
|
67
|
+
CREATE TRIGGER IF NOT EXISTS commands_ad AFTER DELETE ON commands BEGIN
|
|
68
|
+
INSERT INTO commands_fts(commands_fts, rowid, command, cwd) VALUES('delete', old.id, old.command, old.cwd);
|
|
69
|
+
END;
|
|
70
|
+
|
|
71
|
+
CREATE TRIGGER IF NOT EXISTS commands_au AFTER UPDATE ON commands BEGIN
|
|
72
|
+
INSERT INTO commands_fts(commands_fts, rowid, command, cwd) VALUES('delete', old.id, old.command, old.cwd);
|
|
73
|
+
INSERT INTO commands_fts(rowid, command, cwd) VALUES (new.id, new.command, new.cwd);
|
|
74
|
+
END;
|
|
75
|
+
|
|
76
|
+
CREATE TABLE IF NOT EXISTS output (
|
|
77
|
+
command_id INTEGER PRIMARY KEY,
|
|
78
|
+
content TEXT,
|
|
79
|
+
FOREIGN KEY (command_id) REFERENCES commands(id)
|
|
80
|
+
);
|
|
81
|
+
""")
|
|
82
|
+
conn.commit()
|
|
83
|
+
conn.close()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_session(hostname: str = "") -> int:
|
|
87
|
+
ensure_db()
|
|
88
|
+
conn = _get_conn()
|
|
89
|
+
cur = conn.execute(
|
|
90
|
+
"INSERT INTO sessions (hostname) VALUES (?)", (hostname or "",)
|
|
91
|
+
)
|
|
92
|
+
session_id = cur.lastrowid
|
|
93
|
+
conn.commit()
|
|
94
|
+
conn.close()
|
|
95
|
+
return session_id
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def end_session(session_id: int):
|
|
99
|
+
conn = _get_conn()
|
|
100
|
+
conn.execute(
|
|
101
|
+
"UPDATE sessions SET ended_at = datetime('now') WHERE id = ?",
|
|
102
|
+
(session_id,)
|
|
103
|
+
)
|
|
104
|
+
conn.commit()
|
|
105
|
+
conn.close()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def save_command(
|
|
109
|
+
session_id: int,
|
|
110
|
+
sequence: int,
|
|
111
|
+
command: str,
|
|
112
|
+
cwd: str,
|
|
113
|
+
exit_code: int,
|
|
114
|
+
duration_ms: int = 0,
|
|
115
|
+
mode: str = "full",
|
|
116
|
+
output_content: Optional[str] = None,
|
|
117
|
+
) -> int:
|
|
118
|
+
conn = _get_conn()
|
|
119
|
+
cur = conn.execute(
|
|
120
|
+
"""INSERT INTO commands (session_id, sequence, command, cwd, exit_code, duration_ms, mode)
|
|
121
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
122
|
+
(session_id, sequence, command, cwd, exit_code, duration_ms, mode),
|
|
123
|
+
)
|
|
124
|
+
cmd_id = cur.lastrowid
|
|
125
|
+
if output_content is not None and mode == "full":
|
|
126
|
+
conn.execute(
|
|
127
|
+
"INSERT INTO output (command_id, content) VALUES (?, ?)",
|
|
128
|
+
(cmd_id, output_content),
|
|
129
|
+
)
|
|
130
|
+
conn.commit()
|
|
131
|
+
conn.close()
|
|
132
|
+
return cmd_id
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def search(query: str, limit: int = 50) -> list:
|
|
136
|
+
if not _db_exists():
|
|
137
|
+
return []
|
|
138
|
+
conn = _get_conn()
|
|
139
|
+
rows = conn.execute(
|
|
140
|
+
"""SELECT c.id, c.command, c.cwd, c.exit_code, c.timestamp, c.duration_ms, c.mode,
|
|
141
|
+
substr(o.content, 1, 200) AS output_preview
|
|
142
|
+
FROM commands_fts f
|
|
143
|
+
JOIN commands c ON c.id = f.rowid
|
|
144
|
+
LEFT JOIN output o ON o.command_id = c.id
|
|
145
|
+
WHERE commands_fts MATCH ?
|
|
146
|
+
ORDER BY c.timestamp DESC
|
|
147
|
+
LIMIT ?""",
|
|
148
|
+
(query, limit),
|
|
149
|
+
).fetchall()
|
|
150
|
+
conn.close()
|
|
151
|
+
return [dict(r) for r in rows]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def list_recent(limit: int = 20) -> list:
|
|
155
|
+
if not _db_exists():
|
|
156
|
+
return []
|
|
157
|
+
conn = _get_conn()
|
|
158
|
+
rows = conn.execute(
|
|
159
|
+
"""SELECT c.id, c.command, c.cwd, c.exit_code, c.timestamp, c.duration_ms, c.mode,
|
|
160
|
+
substr(o.content, 1, 200) AS output_preview
|
|
161
|
+
FROM commands c
|
|
162
|
+
LEFT JOIN output o ON o.command_id = c.id
|
|
163
|
+
ORDER BY c.timestamp DESC
|
|
164
|
+
LIMIT ?""",
|
|
165
|
+
(limit,),
|
|
166
|
+
).fetchall()
|
|
167
|
+
conn.close()
|
|
168
|
+
return [dict(r) for r in rows]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_command(command_id: int) -> Optional[dict]:
|
|
172
|
+
if not _db_exists():
|
|
173
|
+
return None
|
|
174
|
+
conn = _get_conn()
|
|
175
|
+
row = conn.execute(
|
|
176
|
+
"""SELECT c.*, o.content AS output
|
|
177
|
+
FROM commands c
|
|
178
|
+
LEFT JOIN output o ON o.command_id = c.id
|
|
179
|
+
WHERE c.id = ?""",
|
|
180
|
+
(command_id,),
|
|
181
|
+
).fetchone()
|
|
182
|
+
conn.close()
|
|
183
|
+
return dict(row) if row else None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_stats() -> dict:
|
|
187
|
+
result = {"commands": 0, "sessions": 0, "storage_bytes": 0, "last_command": None}
|
|
188
|
+
if not _db_exists():
|
|
189
|
+
return result
|
|
190
|
+
conn = _get_conn()
|
|
191
|
+
cur = conn.execute("SELECT COUNT(*) FROM commands")
|
|
192
|
+
result["commands"] = cur.fetchone()[0]
|
|
193
|
+
cur = conn.execute("SELECT COUNT(*) FROM sessions")
|
|
194
|
+
result["sessions"] = cur.fetchone()[0]
|
|
195
|
+
cur = conn.execute("SELECT SUM(LENGTH(content)) FROM output")
|
|
196
|
+
result["storage_bytes"] = cur.fetchone()[0] or 0
|
|
197
|
+
cur = conn.execute(
|
|
198
|
+
"SELECT timestamp FROM commands ORDER BY timestamp DESC LIMIT 1"
|
|
199
|
+
)
|
|
200
|
+
row = cur.fetchone()
|
|
201
|
+
result["last_command"] = row[0] if row else None
|
|
202
|
+
conn.close()
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_last_session() -> Optional[list]:
|
|
207
|
+
if not _db_exists():
|
|
208
|
+
return None
|
|
209
|
+
conn = _get_conn()
|
|
210
|
+
row = conn.execute(
|
|
211
|
+
"""SELECT c.*, o.content AS output
|
|
212
|
+
FROM commands c
|
|
213
|
+
LEFT JOIN output o ON o.command_id = c.id
|
|
214
|
+
WHERE c.session_id = (SELECT id FROM sessions ORDER BY id DESC LIMIT 1)
|
|
215
|
+
ORDER BY c.sequence ASC"""
|
|
216
|
+
).fetchall()
|
|
217
|
+
conn.close()
|
|
218
|
+
return [dict(r) for r in row] if row else None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def export_all() -> list:
|
|
222
|
+
if not _db_exists():
|
|
223
|
+
return []
|
|
224
|
+
conn = _get_conn()
|
|
225
|
+
rows = conn.execute(
|
|
226
|
+
"""SELECT c.*, o.content AS output
|
|
227
|
+
FROM commands c
|
|
228
|
+
LEFT JOIN output o ON o.command_id = c.id
|
|
229
|
+
ORDER BY c.timestamp ASC"""
|
|
230
|
+
).fetchall()
|
|
231
|
+
conn.close()
|
|
232
|
+
return [dict(r) for r in rows]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def clear_old(before_days: int = 30):
|
|
236
|
+
if not _db_exists():
|
|
237
|
+
return
|
|
238
|
+
conn = _get_conn()
|
|
239
|
+
conn.execute(
|
|
240
|
+
"""DELETE FROM output WHERE command_id IN (
|
|
241
|
+
SELECT id FROM commands WHERE timestamp < datetime('now', ?)
|
|
242
|
+
)""",
|
|
243
|
+
(f"-{before_days} days",),
|
|
244
|
+
)
|
|
245
|
+
conn.execute(
|
|
246
|
+
"DELETE FROM commands WHERE timestamp < datetime('now', ?)",
|
|
247
|
+
(f"-{before_days} days",),
|
|
248
|
+
)
|
|
249
|
+
conn.execute(
|
|
250
|
+
"DELETE FROM sessions WHERE id NOT IN (SELECT DISTINCT session_id FROM commands)"
|
|
251
|
+
)
|
|
252
|
+
conn.commit()
|
|
253
|
+
conn.close()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
__keeplog_preexec() {
|
|
2
|
+
if [[ "$__KEEPLOG_READY" != "1" ]]; then
|
|
3
|
+
return
|
|
4
|
+
fi
|
|
5
|
+
if [[ -z "$__keeplog_cmd" && -n "$KEEPLOG_CTRL_FD" ]]; then
|
|
6
|
+
__keeplog_cmd="$BASH_COMMAND"
|
|
7
|
+
printf 'C:%s\n' "${BASH_COMMAND::1024}" >&$KEEPLOG_CTRL_FD 2>/dev/null
|
|
8
|
+
fi
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
__keeplog_precmd() {
|
|
12
|
+
if [[ "$__KEEPLOG_READY" != "1" ]]; then
|
|
13
|
+
return
|
|
14
|
+
fi
|
|
15
|
+
local ec=$?
|
|
16
|
+
if [[ -n "$__keeplog_cmd" && -n "$KEEPLOG_CTRL_FD" ]]; then
|
|
17
|
+
printf 'E:%s\n' "$ec" >&$KEEPLOG_CTRL_FD 2>/dev/null
|
|
18
|
+
unset __keeplog_cmd
|
|
19
|
+
fi
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
trap '__keeplog_preexec' DEBUG
|
|
23
|
+
PROMPT_COMMAND="__keeplog_precmd${PROMPT_COMMAND:+;}$PROMPT_COMMAND"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
_keeplog_preexec() {
|
|
2
|
+
if [[ "$__KEEPLOG_READY" != "1" ]]; then
|
|
3
|
+
return
|
|
4
|
+
fi
|
|
5
|
+
if [[ -z "$_keeplog_cmd" && -n "$KEEPLOG_CTRL_FD" ]]; then
|
|
6
|
+
_keeplog_cmd="$1"
|
|
7
|
+
print -rnu "$KEEPLOG_CTRL_FD" "C:${1::1024}"$'\n'
|
|
8
|
+
fi
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
_keeplog_precmd() {
|
|
12
|
+
if [[ "$__KEEPLOG_READY" != "1" ]]; then
|
|
13
|
+
return
|
|
14
|
+
fi
|
|
15
|
+
local ec=$?
|
|
16
|
+
if [[ -n "$_keeplog_cmd" && -n "$KEEPLOG_CTRL_FD" ]]; then
|
|
17
|
+
print -rnu "$KEEPLOG_CTRL_FD" "E:$ec"$'\n'
|
|
18
|
+
unset _keeplog_cmd
|
|
19
|
+
fi
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
autoload -Uz add-zsh-hook
|
|
23
|
+
add-zsh-hook preexec _keeplog_preexec
|
|
24
|
+
add-zsh-hook precmd _keeplog_precmd
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import sysconfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _shell_rc() -> str:
|
|
7
|
+
shell = os.environ.get("SHELL", "")
|
|
8
|
+
if "zsh" in shell:
|
|
9
|
+
return os.path.expanduser("~/.zshrc")
|
|
10
|
+
return os.path.expanduser("~/.bashrc")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _needs_path_fix() -> tuple:
|
|
14
|
+
bin_dir = sysconfig.get_path("scripts")
|
|
15
|
+
path_dirs = os.environ.get("PATH", "").split(":")
|
|
16
|
+
if bin_dir in path_dirs:
|
|
17
|
+
return False, None
|
|
18
|
+
return True, bin_dir
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_HOOK_LINE = '\nif [[ -z "$KEEPLOG_ACTIVE" ]]; then export KEEPLOG_ACTIVE=1; exec keeplog record; fi\n'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def setup_hook():
|
|
25
|
+
rc = _shell_rc()
|
|
26
|
+
lines = []
|
|
27
|
+
|
|
28
|
+
needs_fix, bin_dir = _needs_path_fix()
|
|
29
|
+
if needs_fix and bin_dir:
|
|
30
|
+
path_line = f'\nexport PATH="{bin_dir}:$PATH"'
|
|
31
|
+
if os.path.exists(rc):
|
|
32
|
+
with open(rc) as f:
|
|
33
|
+
content = f.read()
|
|
34
|
+
if bin_dir not in content:
|
|
35
|
+
lines.append(path_line)
|
|
36
|
+
else:
|
|
37
|
+
lines.append(path_line.strip())
|
|
38
|
+
|
|
39
|
+
if os.path.exists(rc):
|
|
40
|
+
with open(rc) as f:
|
|
41
|
+
content = f.read()
|
|
42
|
+
if "KEEPLOG_ACTIVE" in content and not needs_fix:
|
|
43
|
+
print(f"Already set up in {rc}")
|
|
44
|
+
return
|
|
45
|
+
with open(rc, "a") as f:
|
|
46
|
+
for line in lines:
|
|
47
|
+
f.write(line + "\n")
|
|
48
|
+
if "KEEPLOG_ACTIVE" not in content:
|
|
49
|
+
f.write(_HOOK_LINE)
|
|
50
|
+
else:
|
|
51
|
+
with open(rc, "w") as f:
|
|
52
|
+
for line in lines:
|
|
53
|
+
f.write(line + "\n")
|
|
54
|
+
f.write(_HOOK_LINE.strip() + "\n")
|
|
55
|
+
|
|
56
|
+
print(f"Setup complete in {rc}")
|
|
57
|
+
if needs_fix and bin_dir:
|
|
58
|
+
print(f" Added {bin_dir} to PATH")
|
|
59
|
+
print(f" Auto-start hook added")
|
|
60
|
+
print("Restart your terminal or run: source " + rc)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def remove_hook():
|
|
64
|
+
rc = _shell_rc()
|
|
65
|
+
if not os.path.exists(rc):
|
|
66
|
+
return
|
|
67
|
+
with open(rc) as f:
|
|
68
|
+
all_lines = f.readlines()
|
|
69
|
+
new_lines = [l for l in all_lines if "KEEPLOG_ACTIVE" not in l]
|
|
70
|
+
if len(new_lines) == len(all_lines):
|
|
71
|
+
print("Not set up")
|
|
72
|
+
return
|
|
73
|
+
with open(rc, "w") as f:
|
|
74
|
+
f.writelines(new_lines)
|
|
75
|
+
print(f"Removed keeplog hook from {rc}")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from keeplog.db import search as db_search, get_command
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _has_fzf() -> bool:
|
|
9
|
+
return shutil.which("fzf") is not None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def search_interactive(query: str):
|
|
13
|
+
results = db_search(query)
|
|
14
|
+
if not results:
|
|
15
|
+
print("No results found")
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
if not _has_fzf():
|
|
19
|
+
_print_results(results)
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
_fzf_pick(results)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _print_results(results: list):
|
|
26
|
+
for r in results:
|
|
27
|
+
preview = (r["output_preview"] or "")[:80].replace("\n", " | ")
|
|
28
|
+
print(f" [{r['id']}] {r['command']}")
|
|
29
|
+
if preview:
|
|
30
|
+
print(f" {preview}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fzf_pick(results: list):
|
|
34
|
+
lines = [f"{r['id']} {r['command']}" for r in results]
|
|
35
|
+
|
|
36
|
+
preview_cmd = (
|
|
37
|
+
f"keeplog get {{1}}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
proc = subprocess.Popen(
|
|
41
|
+
[
|
|
42
|
+
"fzf", "--preview", preview_cmd,
|
|
43
|
+
"--preview-window", "right:60%:wrap",
|
|
44
|
+
"--with-nth", "2..",
|
|
45
|
+
"--reverse",
|
|
46
|
+
],
|
|
47
|
+
stdin=subprocess.PIPE,
|
|
48
|
+
stdout=subprocess.PIPE,
|
|
49
|
+
stderr=subprocess.PIPE,
|
|
50
|
+
text=True,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
out, _err = proc.communicate("\n".join(lines))
|
|
54
|
+
if not out:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
cmd_id = out.strip().split(" ")[0]
|
|
58
|
+
cmd_data = get_command(int(cmd_id))
|
|
59
|
+
if cmd_data:
|
|
60
|
+
print(f"Command: {cmd_data['command']}")
|
|
61
|
+
print(f"CWD: {cmd_data['cwd']}")
|
|
62
|
+
print(f"Exit: {cmd_data['exit_code']}")
|
|
63
|
+
print(f"Time: {cmd_data['timestamp']}")
|
|
64
|
+
if cmd_data.get("output"):
|
|
65
|
+
print(f"Output:\n{cmd_data['output']}")
|
|
File without changes
|