From 4268ead6a49f20d8b639c66831c5383fad65b5ea Mon Sep 17 00:00:00 2001 From: DexterFromLab Date: Thu, 5 Mar 2026 11:54:24 +0100 Subject: [PATCH] Add ctx context manager, installer, and auto-detect Claude path - ctx: SQLite-based cross-session context manager for Claude Code - install.sh: automated installer (deps, files, symlinks, DB, .desktop) - bterminal.py: replace hardcoded CLAUDE_PATH with auto-detection - README.md: rewrite in English, document ctx and installation - .gitignore: add CLAUDE.md Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + README.md | 105 ++++++++---- bterminal.py | 14 +- ctx | 461 +++++++++++++++++++++++++++++++++++++++++++++++++++ install.sh | 93 +++++++++++ 5 files changed, 640 insertions(+), 34 deletions(-) create mode 100755 ctx create mode 100755 install.sh diff --git a/.gitignore b/.gitignore index 3bbe7b6..1546dda 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ *.pyc *.pyo +CLAUDE.md diff --git a/README.md b/README.md index d543aea..d2c1f88 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,98 @@ # BTerminal -Terminal z panelem sesji w stylu MobaXterm, zbudowany w GTK 3 + VTE. Catppuccin Mocha theme. +Terminal with session panel (MobaXterm-style), built with GTK 3 + VTE. Catppuccin Mocha theme. ![BTerminal](screenshot.png) -## Funkcje +## Features -- **Sesje SSH** — zapisywane konfiguracje (host, port, user, klucz, folder, kolor), CRUD z panelem bocznym -- **Claude Code** — zapisywane konfiguracje Claude Code z opcjami sudo, resume, skip-permissions i initial prompt -- **Makra SSH** — wielokrokowe makra (text, key, delay) przypisane do sesji, uruchamiane z sidebara -- **Zakładki** — wiele terminali w tabach, Ctrl+T nowy, Ctrl+Shift+W zamknij, Ctrl+PageUp/Down przełączaj -- **Sudo askpass** — Claude Code z sudo: hasło podawane raz, tymczasowy askpass helper, automatyczne czyszczenie -- **Grupowanie folderami** — sesje SSH i Claude Code mogą być grupowane w foldery na sidebarze -- **Catppuccin Mocha** — pełny theme: terminal, sidebar, taby, kolory sesji +- **SSH sessions** — saved configs (host, port, user, key, folder, color), CRUD with side panel +- **Claude Code sessions** — saved Claude Code configs with sudo, resume, skip-permissions and initial prompt +- **SSH macros** — multi-step macros (text, key, delay) assigned to sessions, run from sidebar +- **Tabs** — multiple terminals in tabs, Ctrl+T new, Ctrl+Shift+W close, Ctrl+PageUp/Down switch +- **Sudo askpass** — Claude Code with sudo: password entered once, temporary askpass helper, auto-cleanup +- **Folder grouping** — SSH and Claude Code sessions can be grouped in folders on the sidebar +- **ctx — Context manager** — SQLite-based cross-session context database for Claude Code projects +- **Catppuccin Mocha** — full theme: terminal, sidebar, tabs, session colors -## Wymagania +## Installation -``` -python3 >= 3.8 -python3-gi -gir1.2-gtk-3.0 -gir1.2-vte-2.91 +```bash +git clone https://github.com/DexterFromLab/BTerminal.git +cd BTerminal +./install.sh ``` -### Instalacja zależności (Debian/Ubuntu/Pop!_OS) +The installer will: +1. Install system dependencies (python3-gi, GTK3, VTE) +2. Copy files to `~/.local/share/bterminal/` +3. Create symlinks: `bterminal` and `ctx` in `~/.local/bin/` +4. Initialize context database at `~/.claude-context/context.db` +5. Add desktop entry to application menu + +### Manual dependency install (Debian/Ubuntu/Pop!_OS) ```bash sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-vte-2.91 ``` -## Uruchomienie +## Usage ```bash -python3 bterminal.py +bterminal ``` -## Konfiguracja +## Context Manager (ctx) -Pliki konfiguracyjne w `~/.config/bterminal/`: +`ctx` is a SQLite-based tool for managing persistent context across Claude Code sessions. -| Plik | Opis | -|------|------| -| `sessions.json` | Zapisane sesje SSH + makra | -| `claude_sessions.json` | Zapisane konfiguracje Claude Code | +```bash +ctx init myproject "Project description" /path/to/project +ctx get myproject # Load full context (shared + project) +ctx set myproject key "value" # Save a context entry +ctx shared set preferences "value" # Save shared context (available in all projects) +ctx summary myproject "What was done" # Save session summary +ctx search "query" # Full-text search across everything +ctx list # List all projects +ctx history myproject # Show session history +ctx --help # All commands +``` -## Skróty klawiszowe +### Integration with Claude Code -| Skrót | Akcja | -|-------|-------| -| `Ctrl+T` | Nowa zakładka (local shell) | -| `Ctrl+Shift+W` | Zamknij zakładkę | -| `Ctrl+Shift+C` | Kopiuj | -| `Ctrl+Shift+V` | Wklej | -| `Ctrl+PageUp/Down` | Poprzednia/następna zakładka | +Add a `CLAUDE.md` to your project root: -## Licencja +```markdown +On session start, load context: + ctx get myproject + +Save important discoveries: ctx set myproject +Before ending session: ctx summary myproject "" +``` + +Claude Code reads `CLAUDE.md` automatically and will maintain the context database. + +## Configuration + +Config files in `~/.config/bterminal/`: + +| File | Description | +|------|-------------| +| `sessions.json` | Saved SSH sessions + macros | +| `claude_sessions.json` | Saved Claude Code configs | + +Context database: `~/.claude-context/context.db` + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `Ctrl+T` | New tab (local shell) | +| `Ctrl+Shift+W` | Close tab | +| `Ctrl+Shift+C` | Copy | +| `Ctrl+Shift+V` | Paste | +| `Ctrl+PageUp/Down` | Previous/next tab | + +## License MIT diff --git a/bterminal.py b/bterminal.py index b844288..356dbbd 100755 --- a/bterminal.py +++ b/bterminal.py @@ -20,7 +20,19 @@ CONFIG_DIR = os.path.expanduser("~/.config/bterminal") SESSIONS_FILE = os.path.join(CONFIG_DIR, "sessions.json") CLAUDE_SESSIONS_FILE = os.path.join(CONFIG_DIR, "claude_sessions.json") SSH_PATH = "/usr/bin/ssh" -CLAUDE_PATH = "/home/bartek/.local/bin/claude" + +def _find_claude_path(): + for p in [ + os.path.expanduser("~/.local/bin/claude"), + "/usr/local/bin/claude", + "/usr/bin/claude", + ]: + if os.path.isfile(p) and os.access(p, os.X_OK): + return p + import shutil + return shutil.which("claude") or "claude" + +CLAUDE_PATH = _find_claude_path() FONT = "Monospace 11" SCROLLBACK_LINES = 10000 diff --git a/ctx b/ctx new file mode 100755 index 0000000..b54115d --- /dev/null +++ b/ctx @@ -0,0 +1,461 @@ +#!/usr/bin/env python3 +""" +ctx — Cross-session context manager for Claude Code. + +Stores project contexts and shared data in SQLite for instant access. +Usage: ctx [args] +""" + +import sqlite3 +import sys +import os +import json +from datetime import datetime, timezone +from pathlib import Path + +DB_PATH = Path.home() / ".claude-context" / "context.db" + + +def get_db(): + db = sqlite3.connect(str(DB_PATH)) + db.row_factory = sqlite3.Row + db.execute("PRAGMA journal_mode=WAL") + return db + + +def init_db(): + db = get_db() + db.executescript(""" + CREATE TABLE IF NOT EXISTS sessions ( + name TEXT PRIMARY KEY, + description TEXT, + work_dir TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS contexts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(project, key) + ); + + CREATE TABLE IF NOT EXISTS shared ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project TEXT NOT NULL, + summary TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS contexts_fts USING fts5( + project, key, value, content=contexts, content_rowid=id + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS shared_fts USING fts5( + key, value, content=shared + ); + + -- Triggers to keep FTS in sync + CREATE TRIGGER IF NOT EXISTS contexts_ai AFTER INSERT ON contexts BEGIN + INSERT INTO contexts_fts(rowid, project, key, value) + VALUES (new.id, new.project, new.key, new.value); + END; + + CREATE TRIGGER IF NOT EXISTS contexts_ad AFTER DELETE ON contexts BEGIN + INSERT INTO contexts_fts(contexts_fts, rowid, project, key, value) + VALUES ('delete', old.id, old.project, old.key, old.value); + END; + + CREATE TRIGGER IF NOT EXISTS contexts_au AFTER UPDATE ON contexts BEGIN + INSERT INTO contexts_fts(contexts_fts, rowid, project, key, value) + VALUES ('delete', old.id, old.project, old.key, old.value); + INSERT INTO contexts_fts(rowid, project, key, value) + VALUES (new.id, new.project, new.key, new.value); + END; + """) + db.close() + + +# ─── Commands ─────────────────────────────────────────────────────────── + +def cmd_init(args): + """Register a new project. Usage: ctx init [work_dir]""" + if len(args) < 2: + print("Usage: ctx init [work_dir]") + sys.exit(1) + name, desc = args[0], args[1] + work_dir = args[2] if len(args) > 2 else None + db = get_db() + db.execute( + "INSERT OR REPLACE INTO sessions (name, description, work_dir) VALUES (?, ?, ?)", + (name, desc, work_dir), + ) + db.commit() + db.close() + print(f"Project '{name}' registered.") + + +def cmd_get(args): + """Get full context for a project (shared + project-specific + recent summaries).""" + if len(args) < 1: + print("Usage: ctx get ") + sys.exit(1) + project = args[0] + db = get_db() + + # Session info + session = db.execute("SELECT * FROM sessions WHERE name = ?", (project,)).fetchone() + + # Shared context + shared = db.execute("SELECT key, value FROM shared ORDER BY key").fetchall() + + # Project context + contexts = db.execute( + "SELECT key, value FROM contexts WHERE project = ? ORDER BY key", (project,) + ).fetchall() + + # Recent summaries (last 5) + summaries = db.execute( + "SELECT summary, created_at FROM summaries WHERE project = ? ORDER BY created_at DESC LIMIT 5", + (project,), + ).fetchall() + + # Output + print("=" * 60) + if session: + print(f"PROJECT: {session['name']} — {session['description']}") + if session["work_dir"]: + print(f"DIR: {session['work_dir']}") + else: + print(f"PROJECT: {project} (not registered, use: ctx init)") + print("=" * 60) + + if shared: + print("\n--- Shared Context ---") + for row in shared: + print(f"\n[{row['key']}]") + print(row["value"]) + + if contexts: + print(f"\n--- {project} Context ---") + for row in contexts: + print(f"\n[{row['key']}]") + print(row["value"]) + + if summaries: + print("\n--- Recent Sessions ---") + for row in reversed(summaries): + print(f"\n[{row['created_at']}]") + print(row["summary"]) + + if not shared and not contexts and not summaries: + print("\nNo context stored yet. Use 'ctx set' or 'ctx shared set' to add.") + + db.close() + + +def cmd_set(args): + """Set a project context entry. Usage: ctx set """ + if len(args) < 3: + print("Usage: ctx set ") + sys.exit(1) + project, key, value = args[0], args[1], " ".join(args[2:]) + db = get_db() + db.execute( + """INSERT INTO contexts (project, key, value, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(project, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at""", + (project, key, value), + ) + db.commit() + db.close() + print(f"[{project}] {key} = saved.") + + +def cmd_append(args): + """Append to an existing context entry. Usage: ctx append """ + if len(args) < 3: + print("Usage: ctx append ") + sys.exit(1) + project, key, new_value = args[0], args[1], " ".join(args[2:]) + db = get_db() + existing = db.execute( + "SELECT value FROM contexts WHERE project = ? AND key = ?", (project, key) + ).fetchone() + if existing: + value = existing["value"] + "\n" + new_value + else: + value = new_value + db.execute( + """INSERT INTO contexts (project, key, value, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(project, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at""", + (project, key, value), + ) + db.commit() + db.close() + print(f"[{project}] {key} += appended.") + + +def cmd_shared(args): + """Manage shared context. Usage: ctx shared get | ctx shared set """ + if len(args) < 1: + print("Usage: ctx shared get | ctx shared set ") + sys.exit(1) + + if args[0] == "get": + db = get_db() + rows = db.execute("SELECT key, value FROM shared ORDER BY key").fetchall() + db.close() + if rows: + for row in rows: + print(f"\n[{row['key']}]") + print(row["value"]) + else: + print("No shared context yet.") + + elif args[0] == "set": + if len(args) < 3: + print("Usage: ctx shared set ") + sys.exit(1) + key, value = args[1], " ".join(args[2:]) + db = get_db() + db.execute( + """INSERT INTO shared (key, value, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at""", + (key, value), + ) + db.commit() + db.close() + print(f"[shared] {key} = saved.") + + elif args[0] == "delete": + if len(args) < 2: + print("Usage: ctx shared delete ") + sys.exit(1) + db = get_db() + db.execute("DELETE FROM shared WHERE key = ?", (args[1],)) + db.commit() + db.close() + print(f"[shared] {args[1]} deleted.") + else: + print("Usage: ctx shared get | ctx shared set | ctx shared delete ") + + +def cmd_summary(args): + """Save a session summary. Usage: ctx summary """ + if len(args) < 2: + print("Usage: ctx summary ") + sys.exit(1) + project, summary = args[0], " ".join(args[1:]) + db = get_db() + db.execute( + "INSERT INTO summaries (project, summary) VALUES (?, ?)", (project, summary) + ) + # Keep last 20 summaries per project + db.execute( + """DELETE FROM summaries WHERE project = ? AND id NOT IN ( + SELECT id FROM summaries WHERE project = ? ORDER BY created_at DESC LIMIT 20 + )""", + (project, project), + ) + db.commit() + db.close() + print(f"[{project}] Summary saved.") + + +def cmd_history(args): + """Show session history. Usage: ctx history [limit]""" + if len(args) < 1: + print("Usage: ctx history [limit]") + sys.exit(1) + project = args[0] + limit = int(args[1]) if len(args) > 1 else 10 + db = get_db() + rows = db.execute( + "SELECT summary, created_at FROM summaries WHERE project = ? ORDER BY created_at DESC LIMIT ?", + (project, limit), + ).fetchall() + db.close() + if rows: + for row in reversed(rows): + print(f"\n[{row['created_at']}]") + print(row["summary"]) + else: + print(f"No history for '{project}'.") + + +def cmd_search(args): + """Full-text search across all contexts. Usage: ctx search """ + if len(args) < 1: + print("Usage: ctx search ") + sys.exit(1) + query = " ".join(args) + db = get_db() + + # Search project contexts + results_ctx = db.execute( + "SELECT project, key, value FROM contexts_fts WHERE contexts_fts MATCH ?", + (query,), + ).fetchall() + + # Search shared contexts + results_shared = db.execute( + "SELECT key, value FROM shared_fts WHERE shared_fts MATCH ?", (query,) + ).fetchall() + + # Search summaries (simple LIKE since no FTS on summaries) + results_sum = db.execute( + "SELECT project, summary, created_at FROM summaries WHERE summary LIKE ?", + (f"%{query}%",), + ).fetchall() + + db.close() + + total = len(results_ctx) + len(results_shared) + len(results_sum) + print(f"Found {total} result(s) for '{query}':\n") + + if results_shared: + print("--- Shared ---") + for row in results_shared: + print(f" [{row['key']}] {row['value'][:100]}") + + if results_ctx: + print("--- Project Contexts ---") + for row in results_ctx: + print(f" [{row['project']}:{row['key']}] {row['value'][:100]}") + + if results_sum: + print("--- Summaries ---") + for row in results_sum: + print(f" [{row['project']} @ {row['created_at']}] {row['summary'][:100]}") + + +def cmd_list(args): + """List all registered projects.""" + db = get_db() + sessions = db.execute("SELECT * FROM sessions ORDER BY name").fetchall() + + # Also find projects with context but no session registration + orphans = db.execute( + """SELECT DISTINCT project FROM contexts + WHERE project NOT IN (SELECT name FROM sessions) + ORDER BY project""" + ).fetchall() + + db.close() + + if sessions: + print("Registered projects:") + for s in sessions: + ctx_count = _count_contexts(s["name"]) + print(f" {s['name']:25s} — {s['description']} ({ctx_count} entries)") + if orphans: + print("\nUnregistered (have context but no init):") + for o in orphans: + print(f" {o['project']}") + if not sessions and not orphans: + print("No projects yet. Use 'ctx init ' to start.") + + +def cmd_delete(args): + """Delete a project or specific key. Usage: ctx delete [key]""" + if len(args) < 1: + print("Usage: ctx delete [key]") + sys.exit(1) + project = args[0] + db = get_db() + if len(args) >= 2: + key = args[1] + db.execute( + "DELETE FROM contexts WHERE project = ? AND key = ?", (project, key) + ) + db.commit() + print(f"[{project}] {key} deleted.") + else: + db.execute("DELETE FROM contexts WHERE project = ?", (project,)) + db.execute("DELETE FROM summaries WHERE project = ?", (project,)) + db.execute("DELETE FROM sessions WHERE name = ?", (project,)) + db.commit() + print(f"Project '{project}' and all its data deleted.") + db.close() + + +def cmd_export(args): + """Export all data as JSON. Usage: ctx export""" + db = get_db() + data = { + "sessions": [dict(r) for r in db.execute("SELECT * FROM sessions").fetchall()], + "shared": [dict(r) for r in db.execute("SELECT * FROM shared").fetchall()], + "contexts": [dict(r) for r in db.execute("SELECT * FROM contexts").fetchall()], + "summaries": [dict(r) for r in db.execute("SELECT * FROM summaries").fetchall()], + } + db.close() + print(json.dumps(data, indent=2, ensure_ascii=False)) + + +def _count_contexts(project): + db = get_db() + row = db.execute( + "SELECT COUNT(*) as c FROM contexts WHERE project = ?", (project,) + ).fetchone() + db.close() + return row["c"] + + +# ─── Main ─────────────────────────────────────────────────────────────── + +COMMANDS = { + "init": cmd_init, + "get": cmd_get, + "set": cmd_set, + "append": cmd_append, + "shared": cmd_shared, + "summary": cmd_summary, + "history": cmd_history, + "search": cmd_search, + "list": cmd_list, + "delete": cmd_delete, + "export": cmd_export, +} + + +def print_help(): + print("ctx — Cross-session context manager for Claude Code\n") + print("Commands:") + print(" init [dir] Register a new project") + print(" get Load full context (shared + project)") + print(" set Set project context entry") + print(" append Append to existing entry") + print(" shared get|set|delete Manage shared context") + print(" summary Save session work summary") + print(" history [limit] Show session history") + print(" search Full-text search across everything") + print(" list List all projects") + print(" delete [key] Delete project or entry") + print(" export Export all data as JSON") + + +if __name__ == "__main__": + init_db() + + if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"): + print_help() + sys.exit(0) + + cmd = sys.argv[1] + if cmd not in COMMANDS: + print(f"Unknown command: {cmd}") + print_help() + sys.exit(1) + + COMMANDS[cmd](sys.argv[2:]) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..7d5dc12 --- /dev/null +++ b/install.sh @@ -0,0 +1,93 @@ +#!/bin/bash +set -euo pipefail + +# BTerminal installer +# Installs BTerminal + ctx (Claude Code context manager) + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_DIR="$HOME/.local/share/bterminal" +BIN_DIR="$HOME/.local/bin" +CONFIG_DIR="$HOME/.config/bterminal" +CTX_DIR="$HOME/.claude-context" +DESKTOP_DIR="$HOME/.local/share/applications" + +echo "=== BTerminal Installer ===" +echo "" + +# ─── System dependencies ─────────────────────────────────────────────── + +echo "[1/5] Checking system dependencies..." + +MISSING=() +python3 -c "import gi" 2>/dev/null || MISSING+=("python3-gi") +python3 -c "import gi; gi.require_version('Gtk', '3.0'); from gi.repository import Gtk" 2>/dev/null || MISSING+=("gir1.2-gtk-3.0") +python3 -c "import gi; gi.require_version('Vte', '2.91'); from gi.repository import Vte" 2>/dev/null || MISSING+=("gir1.2-vte-2.91") + +if [ ${#MISSING[@]} -gt 0 ]; then + echo " Missing: ${MISSING[*]}" + echo " Installing..." + sudo apt install -y "${MISSING[@]}" +else + echo " All dependencies OK." +fi + +# ─── Install files ───────────────────────────────────────────────────── + +echo "[2/5] Installing BTerminal..." + +mkdir -p "$INSTALL_DIR" "$BIN_DIR" "$CONFIG_DIR" "$CTX_DIR" + +cp "$SCRIPT_DIR/bterminal.py" "$INSTALL_DIR/bterminal.py" +cp "$SCRIPT_DIR/ctx" "$INSTALL_DIR/ctx" +chmod +x "$INSTALL_DIR/bterminal.py" "$INSTALL_DIR/ctx" + +# ─── Symlinks ────────────────────────────────────────────────────────── + +echo "[3/5] Creating symlinks in $BIN_DIR..." + +ln -sf "$INSTALL_DIR/bterminal.py" "$BIN_DIR/bterminal" +ln -sf "$INSTALL_DIR/ctx" "$BIN_DIR/ctx" + +echo " bterminal -> $INSTALL_DIR/bterminal.py" +echo " ctx -> $INSTALL_DIR/ctx" + +# ─── Init ctx database ──────────────────────────────────────────────── + +echo "[4/5] Initializing context database..." + +if [ -f "$CTX_DIR/context.db" ]; then + echo " Database already exists, skipping." +else + "$BIN_DIR/ctx" list >/dev/null 2>&1 + echo " Created $CTX_DIR/context.db" +fi + +# ─── Desktop file ────────────────────────────────────────────────────── + +echo "[5/5] Creating desktop entry..." + +mkdir -p "$DESKTOP_DIR" +cat > "$DESKTOP_DIR/bterminal.desktop" << EOF +[Desktop Entry] +Name=BTerminal +Comment=Terminal with SSH & Claude Code session management +Exec=$BIN_DIR/bterminal +Icon=utilities-terminal +Type=Application +Categories=System;TerminalEmulator; +Terminal=false +StartupNotify=true +EOF + +echo "" +echo "=== Installation complete ===" +echo "" +echo "Run BTerminal:" +echo " bterminal" +echo "" +echo "Context manager:" +echo " ctx --help" +echo "" +echo "Make sure $BIN_DIR is in your PATH." +echo "If not, add to ~/.bashrc:" +echo " export PATH=\"\$HOME/.local/bin:\$PATH\""