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.
@@ -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
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ *.egg
8
+ .env
9
+ .venv
10
+ venv/
11
+ *.sqlite
12
+ *.db
13
+ MEMORY.md
14
+ AGENTS.md
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.
@@ -0,0 +1,3 @@
1
+ # keeplog
2
+
3
+ Terminal session logger with full-text search.
@@ -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,3 @@
1
+ from keeplog.cli import main
2
+
3
+ main()
@@ -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