Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ dist/
build/
.idea/
.vscode/

# Ignore runtime TruShell user configuration and local runtime state
trushell/config/my_command_config.md
.trushell/
15 changes: 0 additions & 15 deletions .npmignore

This file was deleted.

10 changes: 5 additions & 5 deletions tests/test_database.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from trushell.database import _create_table, get_all_todos, get_db_connection, insert_todo
from trushell.model import Todo
from trushell.core.database import _create_table, get_all_todos, get_db_connection, insert_todo
from trushell.core.models import Todo


def test_get_db_connection_returns_fresh_connection(monkeypatch, tmp_path) -> None:
db_path = tmp_path / "todos.db"
monkeypatch.setattr("trushell.database.DB_PATH", db_path)
monkeypatch.setattr("trushell.core.database.DB_PATH", db_path)

conn_one = get_db_connection()
conn_two = get_db_connection()
Expand All @@ -17,7 +17,7 @@ def test_get_db_connection_returns_fresh_connection(monkeypatch, tmp_path) -> No

def test_insert_todo_assigns_sequential_positions(monkeypatch, tmp_path) -> None:
db_path = tmp_path / "todos.db"
monkeypatch.setattr("trushell.database.DB_PATH", db_path)
monkeypatch.setattr("trushell.core.database.DB_PATH", db_path)

_create_table()
insert_todo(Todo(task="first", category="work"))
Expand All @@ -31,7 +31,7 @@ def test_insert_todo_assigns_sequential_positions(monkeypatch, tmp_path) -> None

def test_get_all_todos_works_with_local_connections(monkeypatch, tmp_path) -> None:
db_path = tmp_path / "todos.db"
monkeypatch.setattr("trushell.database.DB_PATH", db_path)
monkeypatch.setattr("trushell.core.database.DB_PATH", db_path)

_create_table()
insert_todo(Todo(task="alpha", category="study"))
Expand Down
4 changes: 2 additions & 2 deletions trushell/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .cli import app
from .cli import app_with_lower

if __name__ == "__main__":
app()
app_with_lower()
128 changes: 12 additions & 116 deletions trushell/cli.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,30 @@
from __future__ import annotations

import shlex
import sys
import typer

from . import __version__
from .core.trukernel import TruKernel
from .project import run_interactive_shell
from .pyfunny import joke, joke_trex
from .settings import launch_settings
from .todocli import addtask, completetask, deletetask, showtask, updatetask
from .chronoterm.shell import app as chronoterm_app

app = typer.Typer(name="trushell", help="TruShell: jokes, todos, time, and more.")
app = typer.Typer(name="trushell", help="TruShell manifest-driven launcher.")
kernel = TruKernel()


def app_with_lower() -> None:
"""Entry point that normalizes command name to lowercase for case-insensitive invocation."""
# Normalize the command name to lowercase for case-insensitive behavior
if len(sys.argv) > 0:
sys.argv[0] = sys.argv[0].lower()
# Invoke the main Typer app
"""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:])
kernel.execute_command(raw)
return
app()

def _invoke_chronoterm_command(command: str) -> None:
try:
chronoterm_app(shlex.split(command))
except SystemExit:
return
except Exception as error:
typer.secho(f"Error running ChronoTerm command: {error}", fg=typer.colors.RED)


@app.callback(invoke_without_command=True)
def main(ctx: typer.Context) -> None:
"""Launch the TruShell interactive REPL when no command is provided."""
"""Launch the REPL when no command is provided."""
if ctx.invoked_subcommand is None:
run_interactive_shell()

Expand All @@ -41,99 +33,3 @@ def main(ctx: typer.Context) -> None:
def version() -> None:
"""Show the installed TruShell version."""
typer.echo(__version__)


@app.command("joke")
def cli_joke() -> None:
"""Tell a random joke with ASCII art."""
typer.echo(joke())


@app.command("joke-trex")
def cli_joke_trex() -> None:
"""Tell a T-Rex joke with sound."""
typer.echo(joke_trex())


@app.command("addtask")
def cli_addtask(task: str, category: str) -> None:
"""Add a todo task."""
addtask(task, category)


@app.command("deletetask")
def cli_deletetask(position: int) -> None:
"""Delete a todo task by position."""
deletetask(position)


@app.command("updatetask")
def cli_updatetask(position: int, task: str | None = None, category: str | None = None) -> None:
"""Update the text or category of a todo task."""
updatetask(position, task, category)


@app.command("completetask")
def cli_completetask(position: int) -> None:
"""Mark a todo task as complete."""
completetask(position)


@app.command("showtasks")
def cli_showtasks() -> None:
"""Show all todo tasks."""
showtask()


@app.command("settings")
def cli_settings() -> None:
"""Open the interactive settings manager."""
launch_settings()


@app.command("now")
def cli_now() -> None:
"""Show the current local time."""
_invoke_chronoterm_command("now")


@app.command("time")
def cli_time() -> None:
"""Show the current time in an ASCII clock."""
_invoke_chronoterm_command("time")


@app.command("world")
def cli_world() -> None:
"""Show world time for favorite timezones."""
_invoke_chronoterm_command("world")


@app.command("tz")
def cli_tz(action: str = typer.Argument("list", help="list | add | remove"), name: str | None = typer.Argument(None, help="IANA timezone name, e.g. Europe/London")) -> None:
"""Manage favorite timezones."""
command = "tz"
if name:
command += f" {action} {name}"
else:
command += f" {action}"
_invoke_chronoterm_command(command)


@app.command("alarm")
def cli_alarm(action: str = typer.Argument("list", help="list | add | remove"), time: str | None = typer.Argument(None, help="Time as HH:MM or YYYY-MM-DD HH:MM"), tz: str | None = typer.Option(None, "--tz", help="Timezone name"), label: str | None = typer.Option(None, "--label", help="Alarm label")) -> None:
"""Manage alarms."""
command = "alarm " + action
if time:
command += f" {time}"
if tz:
command += f" --tz {tz}"
if label:
command += f" --label {label}"
_invoke_chronoterm_command(command)


@app.command("sw")
def cli_sw(action: str = typer.Argument("show", help="start | pause | lap | reset | show")) -> None:
"""Control the stopwatch."""
_invoke_chronoterm_command(f"sw {action}")
1 change: 1 addition & 0 deletions trushell/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Built-in TruShell commands implemented as manifest-driven wrappers."""
45 changes: 45 additions & 0 deletions trushell/commands/chronoterm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from __future__ import annotations

import shlex

from trushell.chronoterm.shell import app as chronoterm_app


def _run_chrono_command(raw_command: str) -> None:
try:
chronoterm_app(shlex.split(raw_command))
except SystemExit:
return
except Exception as error:
print(f"ChronoTerm error: {error}")


def run_now(_: str) -> None:
_run_chrono_command("now")


def run_time(_: str) -> None:
_run_chrono_command("time")


def run_world(_: str) -> None:
_run_chrono_command("world")


def run_tz(args: str) -> None:
if not args.strip():
_run_chrono_command("tz list")
return
_run_chrono_command(f"tz {args.strip()}")


def run_alarm(args: str) -> None:
if not args.strip():
_run_chrono_command("alarm list")
return
_run_chrono_command(f"alarm {args.strip()}")


def run_sw(args: str) -> None:
command = "sw" if not args.strip() else f"sw {args.strip()}"
_run_chrono_command(command)
91 changes: 91 additions & 0 deletions trushell/commands/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from __future__ import annotations

import os
import subprocess


def run_help(_: str) -> None:
"""Display available commands by reading the manifest registry."""
# Import here to avoid circular dependency if trukernel imports core
from trushell.core.trukernel import get_kernel
kernel = get_kernel()
cmds = sorted(kernel.registry.keys())
print("Available commands:")
# Print in columns
col_width = max(len(c) for c in cmds) + 2
cols = 4
for i, cmd in enumerate(cmds):
print(f" {cmd:<{col_width}}", end="")
if (i + 1) % cols == 0:
print()
if len(cmds) % cols != 0:
print()
print("\nType 'help <command>' for more info (coming soon).")


def run_exit(_: str) -> str:
"""Signal TruKernel to exit the REPL loop cleanly.

Returns a special sentinel string instead of raising SystemExit.
The kernel checks for this return value and breaks its loop.
"""
return "__TRUSHELL_EXIT__"


def run_settings(args: str) -> None:
"""Open the TruShell settings manager."""
try:
from trushell.core.settings import launch_settings
launch_settings()
except ImportError:
print("Settings module not available.")
except Exception as e:
print(f"Settings error: {e}")


def run_os_passthrough(args: str) -> int:
"""Generic wrapper that passes args directly to the OS shell.

Args:
args: The full argument string after the command name.
e.g., for 'ls -la /tmp', args = '-la /tmp'

Returns:
The subprocess return code (0 = success).
"""
if not args:
print("Usage: <command> [args]")
return 1
try:
result = subprocess.run(args, shell=True)
return result.returncode
except FileNotFoundError:
print(f"Command not found in PATH.")
return 127
except Exception as e:
print(f"OS command error: {e}")
return 1


def run_cd_command(args: str) -> None:
"""Change directory. Updates Python process CWD so subsequent
os_passthrough commands inherit the new directory.

Note: This does NOT update the terminal emulator's displayed path.
That is an inherent limitation of Python-based shells.
"""
if not args:
target = os.path.expanduser("~")
else:
target = os.path.expandvars(os.path.expanduser(args.strip()))

try:
os.chdir(target)
# Print new path so user knows cd succeeded
print(os.getcwd())
except FileNotFoundError:
print(f"cd: no such file or directory: {target}")
except PermissionError:
print(f"cd: permission denied: {target}")
except NotADirectoryError:
print(f"cd: not a directory: {target}")
26 changes: 26 additions & 0 deletions trushell/commands/editor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from __future__ import annotations

from pathlib import Path


def run_edit_command(args: str) -> None:
"""Open a file in the TruShell editor from a manifest-driven command."""
if not args.strip():
print("Usage: edit <filename>")
return

file_path = Path(args.strip()).expanduser()
initial_text = ""
if file_path.exists():
try:
initial_text = file_path.read_text(encoding="utf-8")
except OSError as error:
print(f"Editor error: {error}")
return

try:
from trushell.project import TruShellEditor

TruShellEditor(str(file_path), initial_text=initial_text).run()
except Exception as error:
print(f"Editor error: {error}")
Loading