From c7d42696dfe8a06cbaae95d9199e72e0bed38305 Mon Sep 17 00:00:00 2001 From: exlier Date: Sat, 6 Jun 2026 13:22:48 +0000 Subject: [PATCH 1/4] fix: resolve critical windows compatibility and core stability issues --- pyproject.toml | 1 + trushell/__init__.py | 2 +- trushell/cli.py | 4 +++- trushell/core/trukernel.py | 7 +++++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aed8569..dbd92f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "pyjokes>=0.6.0", "cowsay>=4.0", "pytz>=2024.1", + "tzdata>=2024.1", "platformdirs>=3.0.0", "textual>=0.86.0", "psutil>=5.9.0", diff --git a/trushell/__init__.py b/trushell/__init__.py index 231865a..cf47ca5 100644 --- a/trushell/__init__.py +++ b/trushell/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "0.1.0" +__version__ = "0.1.2" diff --git a/trushell/cli.py b/trushell/cli.py index 79beb6a..49b4288 100644 --- a/trushell/cli.py +++ b/trushell/cli.py @@ -8,6 +8,7 @@ import time import typer from pathlib import Path +from rich.console import Console from textual.app import App, ComposeResult from textual.binding import Binding from textual.widgets import Footer, Header, TextArea @@ -21,6 +22,7 @@ from .core.trukernel import EXIT_SENTINEL, get_kernel app = typer.Typer(name="trushell", help="TruShell manifest-driven launcher.") +console = Console() def app_with_lower() -> None: @@ -270,7 +272,7 @@ def _handle_cd_command(raw_command: str) -> bool: try: os.chdir(target) - typer.echo(os.getcwd()) + console.print(f"[green]→ {os.getcwd()}[/green]") except (FileNotFoundError, NotADirectoryError, PermissionError) as error: typer.secho(f"❌ Cannot navigate: {error}", fg=typer.colors.RED) except OSError as error: diff --git a/trushell/core/trukernel.py b/trushell/core/trukernel.py index 9a1929b..80b6e44 100644 --- a/trushell/core/trukernel.py +++ b/trushell/core/trukernel.py @@ -4,6 +4,7 @@ import os import subprocess import sys +import threading from pathlib import Path from typing import Any @@ -16,6 +17,7 @@ _kernel_instance: TruKernel | None = None +_kernel_lock = threading.Lock() class TruKernel: """Central manifest-driven dispatch engine for TruShell.""" @@ -306,6 +308,7 @@ def get_kernel() -> TruKernel: Used by commands like help that need a live registry. """ global _kernel_instance - if _kernel_instance is None: - _kernel_instance = TruKernel() + with _kernel_lock: + if _kernel_instance is None: + _kernel_instance = TruKernel() return _kernel_instance \ No newline at end of file From 833a146db7b9e783ff110e2d5674f7393b3916b8 Mon Sep 17 00:00:00 2001 From: exlier Date: Sat, 6 Jun 2026 17:09:21 +0000 Subject: [PATCH 2/4] testcommit --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index ebd8784..b2dece1 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,6 @@ A lightweight, context‑aware shell for developers --- -TruShell is not a full replacement for bash or zsh. It is a small -utility shell that sits next to your normal terminal and helps you -track tasks, check times, set alarms, and run ordinary commands. - It is written in Python and uses a SQLite database for todos. When you type a command TruShell does not recognise, it passes it directly to the host system’s shell (bash, cmd, etc.). From be78087cd37f1b2ca2f926f8645a957dbb75443a Mon Sep 17 00:00:00 2001 From: exlier Date: Sat, 6 Jun 2026 17:18:19 +0000 Subject: [PATCH 3/4] fix: resolve infinite recursion in database initialization and implement security hardening Database Layer (Issue #24): - Refactored _ensure_initialized() to use direct sqlite3.connect() instead of get_db_connection() - Added global _INITIALIZED flag to prevent redundant table creation - Separated _get_db_path() function to avoid circular dependencies - Resolves RecursionError during shell startup or first DB access OS Passthrough Security (Issue #19): - Added 'import shlex' for safe command parsing - Replaced subprocess.run(shell=True) with subprocess.run(shlex.split(), shell=False) - Added validation to block pipes (|) and redirects (>, <) before execution - Prevents shell injection vulnerabilities Plugin System (Issue #10): - Added @classmethod reset() to PluginManager for singleton reset during testing - Created tests/conftest.py with autouse fixture for proper test isolation Configuration Cleanup (Issue #6): - Removed broken theme_engine plugin reference from plugins.md (path doesn't exist) --- tests/conftest.py | 11 ++++++ trushell/config/plugins.md | 1 - trushell/core/database.py | 63 ++++++++++++++++++++++++--------- trushell/core/plugin_manager.py | 6 ++++ trushell/core/trukernel.py | 12 +++++-- 5 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7328514 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +"""Pytest configuration and shared fixtures for TruShell tests.""" + +import pytest +from trushell.core.plugin_manager import PluginManager + + +@pytest.fixture(autouse=True) +def reset_plugin_manager(): + """Reset PluginManager singleton after each test to ensure isolation.""" + yield + PluginManager.reset() diff --git a/trushell/config/plugins.md b/trushell/config/plugins.md index 393315a..d62e8ba 100644 --- a/trushell/config/plugins.md +++ b/trushell/config/plugins.md @@ -2,4 +2,3 @@ # Plugins can optionally run immediately with {lifecycle: on_load}. {cmd: gstatus}; "plugin_init()"; [trushell/plugins/git_enhancer/main.py]; {lifecycle: on_load}; -{cmd: theme}; "load_theme()"; [~/.trushell/plugins/theme_engine/loader.py]; {version:1.2}; diff --git a/trushell/core/database.py b/trushell/core/database.py index 4e819ad..4fe9ed6 100644 --- a/trushell/core/database.py +++ b/trushell/core/database.py @@ -2,26 +2,46 @@ import sqlite3 from pathlib import Path -from typing import List +from typing import List, Optional from platformdirs import user_data_dir from trushell.core.models import Todo -APP_NAME = "TruShell" -APP_AUTHOR = "AkshajSinghal" -DATA_DIR = Path(user_data_dir(APP_NAME, APP_AUTHOR)) -DATA_DIR.mkdir(parents=True, exist_ok=True) -DB_PATH = DATA_DIR / "todos.db" - - -def get_db_connection() -> sqlite3.Connection: - return sqlite3.connect(DB_PATH, check_same_thread=False) - - -def _create_table() -> None: - with get_db_connection() as conn: - conn.execute( +# Global state to track initialization +_INITIALIZED = False +_DB_PATH: Optional[Path] = None + + +def _get_db_path() -> Path: + """Get the path to the database file.""" + global _DB_PATH + if _DB_PATH is None: + data_dir = Path(user_data_dir("TruShell", "AkshajSinghal")) + data_dir.mkdir(parents=True, exist_ok=True) + _DB_PATH = data_dir / "todos.db" + return _DB_PATH + + +def _ensure_initialized() -> None: + """ + Create the database and tables if they don't exist. + This function is idempotent and safe to call multiple times. + Does NOT call get_db_connection() to avoid infinite recursion. + """ + global _INITIALIZED + + if _INITIALIZED: + return + + db_path = _get_db_path() + + # Open a direct connection to initialize. + # We do NOT use get_db_connection() here to avoid recursion. + conn = sqlite3.connect(str(db_path), check_same_thread=False) + try: + cursor = conn.cursor() + cursor.execute( """CREATE TABLE IF NOT EXISTS todos ( task TEXT, category TEXT, @@ -31,9 +51,20 @@ def _create_table() -> None: position INTEGER )""" ) + conn.commit() + _INITIALIZED = True + finally: + conn.close() -_create_table() +def get_db_connection() -> sqlite3.Connection: + """ + Return a connection to the SQLite database. + Ensures the database is initialized before returning the connection. + """ + _ensure_initialized() + db_path = _get_db_path() + return sqlite3.connect(str(db_path), check_same_thread=False) def insert_todo(todo: Todo) -> None: diff --git a/trushell/core/plugin_manager.py b/trushell/core/plugin_manager.py index 84860e9..714bc86 100644 --- a/trushell/core/plugin_manager.py +++ b/trushell/core/plugin_manager.py @@ -43,6 +43,12 @@ def instance(cls, kernel=None) -> "PluginManager": _instance = PluginManager(kernel) return _instance + @classmethod + def reset(cls) -> None: + """Reset the singleton instance. Used for testing.""" + global _instance + _instance = None + def discover(self) -> List[Path]: candidates: List[Path] = [] # user plugin directory (~/.trushell/plugins) diff --git a/trushell/core/trukernel.py b/trushell/core/trukernel.py index 80b6e44..5ff90b5 100644 --- a/trushell/core/trukernel.py +++ b/trushell/core/trukernel.py @@ -2,6 +2,7 @@ import importlib.util import os +import shlex import subprocess import sys import threading @@ -244,16 +245,23 @@ def _os_passthrough(self, command: str, args: str) -> bool: """Try to run an unregistered command via the host OS shell. Special-cases 'cd' because it must change the Python process CWD. + Blocks shell metacharacters for security. """ full_cmd = f"{command} {args}".strip() if command == "cd": return self._handle_cd(args) + # Block dangerous shell metacharacters + if "|" in full_cmd or ">" in full_cmd or "<" in full_cmd: + print("Error: pipes and redirects are not allowed") + self.logger.warning("Blocked shell metacharacter in command: %s", full_cmd) + return False + try: result = subprocess.run( - full_cmd, - shell=True, + shlex.split(full_cmd), + shell=False, capture_output=True, text=True, ) From 2bad0bac8eba807ef238db6879f1b8227667ca09 Mon Sep 17 00:00:00 2001 From: exlier Date: Sat, 6 Jun 2026 17:21:24 +0000 Subject: [PATCH 4/4] polish: improve UI, fix process management, and enhance help system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Process Management (Issue #11): - Wrapped _run_external_command polling loop in try/finally block - Ensures process.wait() is called in finally block to prevent zombie processes - Gracefully handles psutil exceptions without losing process cleanup UI/UX Improvements (Issues #12, #13): - Fixed argv mutation in app_with_lower() - now creates sys.argv.copy() instead of mutating global - Fixed Windows emoji crash in _prompt_command() - added try/except for UnicodeEncodeError - Fallback to plain ASCII 'trushell> ' prompt when UTF-8 emojis fail on limited terminals Readline & History Support (Issue #22): - Added prompt_toolkit>=3.0.0 to dependencies in pyproject.toml - Replaced input() with toolkit_prompt() in _prompt_command() - Enables command history and tab completion on all platforms Settings Data Integrity (Issue #15): - Implemented dirty_settings dict to track all setting changes - Added watch_value() handlers on Input/Select/Switch widgets - _save_settings() now safely persists all changes without querying unmounted widgets - Prevents data loss when switching categories without saving Help System Enhancements (Issues #25, #27): - run_help() now accepts command name argument - Shows specific command docstrings when 'help ' is used - Fixed typo in help text: 'showtask' → 'showtasks' - Updated help message to indicate 'help ' is now available --- pyproject.toml | 1 + trushell/cli.py | 81 +++++++++++++++++++++-------------- trushell/commands/core.py | 29 +++++++++++-- trushell/commands/settings.py | 67 +++++++++++++++++------------ 4 files changed, 114 insertions(+), 64 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dbd92f3..baf3a30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "platformdirs>=3.0.0", "textual>=0.86.0", "psutil>=5.9.0", + "prompt_toolkit>=3.0.0", ] [project.urls] diff --git a/trushell/cli.py b/trushell/cli.py index 49b4288..ecb2a6c 100644 --- a/trushell/cli.py +++ b/trushell/cli.py @@ -12,6 +12,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.widgets import Footer, Header, TextArea +from prompt_toolkit import prompt as toolkit_prompt try: import psutil @@ -28,9 +29,11 @@ def app_with_lower() -> None: """Entry point that normalizes the first argument to lowercase for case-insensitive invocation.""" if len(sys.argv) > 1: - sys.argv[1] = sys.argv[1].lower() - if sys.argv[1] not in {"--help", "-h", "version"}: - raw = " ".join(sys.argv[1:]) + # Create a local copy to avoid mutating the global sys.argv + argv_copy = sys.argv.copy() + argv_copy[1] = argv_copy[1].lower() + if argv_copy[1] not in {"--help", "-h", "version"}: + raw = " ".join(argv_copy[1:]) get_kernel().execute_command(raw) return app() @@ -46,7 +49,17 @@ def _split_command(user_input: str) -> tuple[str, str]: def _prompt_command() -> tuple[str, str, str]: - raw_command = input(f"trushell {os.getcwd()} ❯ ").strip() + prompt_text = f"trushell {os.getcwd()} ❯ " + try: + # Try to use the emoji prompt with UTF-8 encoding support + raw_command = toolkit_prompt(prompt_text, enable_history_search=True).strip() + except (UnicodeEncodeError, UnicodeDecodeError): + # Fallback to plain ASCII prompt if emojis fail (e.g., on Windows with limited encoding) + raw_command = input("trushell> ").strip() + except Exception: + # Further fallback to standard input if prompt_toolkit unavailable or fails + raw_command = input("trushell> ").strip() + command, argument = _split_command(raw_command) return raw_command, command, argument @@ -108,38 +121,40 @@ def _run_external_command( ) -> subprocess.CompletedProcess[str]: process = subprocess.Popen(command, shell=shell, cwd=cwd) monitor = None - if psutil is not None: - try: - monitor = psutil.Process(process.pid) - monitor.cpu_percent(None) - except Exception: - monitor = None - peak_rss = 0 peak_cpu = 0.0 start = time.perf_counter() + + try: + if psutil is not None: + try: + monitor = psutil.Process(process.pid) + monitor.cpu_percent(None) + except Exception: + monitor = None - while True: - try: - process.wait(timeout=0.05) - break - except subprocess.TimeoutExpired: - if monitor is not None: - try: - peak_rss = max(peak_rss, monitor.memory_info().rss) - peak_cpu = max(peak_cpu, monitor.cpu_percent(None)) - except (Exception, OSError): - break - - if process.returncode is None: - process.wait() - - if monitor is not None: - try: - peak_rss = max(peak_rss, monitor.memory_info().rss) - peak_cpu = max(peak_cpu, monitor.cpu_percent(None)) - except (Exception, OSError): - pass + while True: + try: + process.wait(timeout=0.05) + break + except subprocess.TimeoutExpired: + if monitor is not None: + try: + peak_rss = max(peak_rss, monitor.memory_info().rss) + peak_cpu = max(peak_cpu, monitor.cpu_percent(None)) + except (Exception, OSError): + break + + if monitor is not None: + try: + peak_rss = max(peak_rss, monitor.memory_info().rss) + peak_cpu = max(peak_cpu, monitor.cpu_percent(None)) + except (Exception, OSError): + pass + finally: + # Ensure the process is waited on, even if exceptions occur + if process.returncode is None: + process.wait() elapsed = time.perf_counter() - start if peak_rss or peak_cpu: @@ -244,7 +259,7 @@ def _handle_local_command(command: str, argument: str) -> str: launch_settings() return "handled" if command == "help": - typer.echo("Available commands: joke, joke_trex, addtask, deletetask, updatetask, completetask, showtask, now, time, world, tz, alarm, sw, settings, exit, help") + typer.echo("Available commands: joke, joke_trex, addtask, deletetask, updatetask, completetask, showtasks, now, time, world, tz, alarm, sw, settings, exit, help") return "handled" return "unhandled" diff --git a/trushell/commands/core.py b/trushell/commands/core.py index 42807f1..d5ce92c 100644 --- a/trushell/commands/core.py +++ b/trushell/commands/core.py @@ -4,11 +4,34 @@ import subprocess -def run_help(_: str) -> None: - """Display available commands by reading the manifest registry.""" +def run_help(args: str) -> None: + """Display available commands by reading the manifest registry. + + If args contains a command name, print its docstring if available. + Otherwise, list all available commands. + """ # Import here to avoid circular dependency if trukernel imports core from trushell.core.trukernel import get_kernel kernel = get_kernel() + + command_name = args.strip().lower() if args else None + + # If a specific command is requested, show its help + if command_name and command_name in kernel.registry: + entry = kernel.registry[command_name] + try: + module = kernel._import_module(entry["path"]) + func = getattr(module, entry["function"], None) + if func and func.__doc__: + print(f"Help for '{command_name}':") + print(func.__doc__) + return + except Exception: + pass + print(f"No detailed help available for '{command_name}'.") + return + + # Otherwise, list all commands cmds = sorted(kernel.registry.keys()) print("Available commands:") # Print in columns @@ -20,7 +43,7 @@ def run_help(_: str) -> None: print() if len(cmds) % cols != 0: print() - print("\nType 'help ' for more info (coming soon).") + print("\nType 'help ' for more info.") def run_exit(_: str) -> str: diff --git a/trushell/commands/settings.py b/trushell/commands/settings.py index c8736ec..ed20c65 100644 --- a/trushell/commands/settings.py +++ b/trushell/commands/settings.py @@ -75,6 +75,8 @@ def __init__(self, **kwargs) -> None: self.manager = SettingsManager() self.settings = self.manager.load() self.selected_category = "General" + # Track changes to settings without losing data when switching categories + self.dirty_settings: dict = {} def compose(self) -> ComposeResult: yield Header(show_clock=False) @@ -111,6 +113,10 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif event.button.id == "exit_button": self.exit() + def _track_change(self, key: str, value) -> None: + """Track changes to settings without losing data when switching categories.""" + self.dirty_settings[key] = value + def action_save_settings(self) -> None: self._save_settings() @@ -134,40 +140,41 @@ def add_field(label_text: str, widget: Static | Input | Select | Switch) -> None current_cat = self.selected_category if current_cat == "General": - add_field( - "Prompt symbol:", - Input(value=str(self.settings.get("prompt_symbol", "➜")), id="prompt_symbol"), - ) + input_widget = Input(value=str(self.settings.get("prompt_symbol", "➜")), id="prompt_symbol") + input_widget.watch_value(lambda value: self._track_change("prompt_symbol", value)) + add_field("Prompt symbol:", input_widget) elif current_cat == "Appearance": - add_field( - "Theme:", - Select( - options=self.THEME_OPTIONS, - value=str(self.settings.get("theme", "dark")), - id="theme_selector", - ), + select_widget = Select( + options=self.THEME_OPTIONS, + value=str(self.settings.get("theme", "dark")), + id="theme_selector", ) + select_widget.watch_value(lambda value: self._track_change("theme", value)) + add_field("Theme:", select_widget) elif current_cat == "Navigation": - add_field( - "Show git status:", - Switch(value=bool(self.settings.get("show_git_status", True)), id="show_git_status"), - ) - add_field( - "Auto complete:", - Switch(value=bool(self.settings.get("auto_complete", True)), id="auto_complete"), - ) + git_switch = Switch(value=bool(self.settings.get("show_git_status", True)), id="show_git_status") + git_switch.watch_value(lambda value: self._track_change("show_git_status", value)) + add_field("Show git status:", git_switch) + + complete_switch = Switch(value=bool(self.settings.get("auto_complete", True)), id="auto_complete") + complete_switch.watch_value(lambda value: self._track_change("auto_complete", value)) + add_field("Auto complete:", complete_switch) elif current_cat == "Data": - add_field( - "CSV max rows:", - Input(value=str(self.settings.get("csv_max_rows", 50)), id="csv_max_rows"), - ) + csv_input = Input(value=str(self.settings.get("csv_max_rows", 50)), id="csv_max_rows") + csv_input.watch_value(lambda value: self._track_change("csv_max_rows", value)) + add_field("CSV max rows:", csv_input) def _save_settings(self) -> None: - theme = self.query_one("#theme_selector", Select).value if self.selected_category == "Appearance" else self.settings.get("theme", "dark") - prompt_symbol = self.query_one("#prompt_symbol", Input).value if self.selected_category == "General" else self.settings.get("prompt_symbol", "➜") - show_git_status = self.query_one("#show_git_status", Switch).value if self.selected_category == "Navigation" else self.settings.get("show_git_status", True) - auto_complete = self.query_one("#auto_complete", Switch).value if self.selected_category == "Navigation" else self.settings.get("auto_complete", True) - csv_max_rows_value = self.query_one("#csv_max_rows", Input).value if self.selected_category == "Data" else str(self.settings.get("csv_max_rows", 50)) + # Collect all values from dirty_settings and current widgets + theme = self.dirty_settings.get("theme") or self.settings.get("theme", "dark") + prompt_symbol = self.dirty_settings.get("prompt_symbol") or self.settings.get("prompt_symbol", "➜") + show_git_status = self.dirty_settings.get("show_git_status") + if show_git_status is None: + show_git_status = self.settings.get("show_git_status", True) + auto_complete = self.dirty_settings.get("auto_complete") + if auto_complete is None: + auto_complete = self.settings.get("auto_complete", True) + csv_max_rows_value = self.dirty_settings.get("csv_max_rows") or str(self.settings.get("csv_max_rows", 50)) try: csv_max_rows = int(csv_max_rows_value) @@ -175,12 +182,14 @@ def _save_settings(self) -> None: self.notify("CSV max rows must be an integer.", severity="error") return + # Update settings dict with all collected values self.settings["theme"] = theme self.settings["prompt_symbol"] = prompt_symbol self.settings["show_git_status"] = show_git_status self.settings["auto_complete"] = auto_complete self.settings["csv_max_rows"] = csv_max_rows + # Persist to disk via manager self.manager.set("theme", theme) self.manager.set("prompt_symbol", prompt_symbol) self.manager.set("show_git_status", show_git_status) @@ -188,6 +197,8 @@ def _save_settings(self) -> None: self.manager.set("csv_max_rows", csv_max_rows) self.manager.save() + # Clear dirty settings after save + self.dirty_settings.clear() self._apply_theme(theme) self.notify("Settings saved.", severity="success")