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
24 changes: 24 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: TrashClaw CI

on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Test with pytest
run: |
PYTHONPATH=. python -m pytest tests/
54 changes: 54 additions & 0 deletions tests/test_achievements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import os
import pytest
import json
import trashclaw
from trashclaw import _track_tool, _save_achievements, ACHIEVEMENTS, ACHIEVEMENT_DEFS

@pytest.fixture(autouse=True)
def mock_achievements(tmp_path, monkeypatch):
"""Ensure achievements are saved to a temporary directory."""
temp_config = tmp_path / ".trashclaw"
temp_config.mkdir()
temp_file = temp_config / "achievements.json"

# Mock global variables
monkeypatch.setattr(trashclaw, "CONFIG_DIR", str(temp_config))
monkeypatch.setattr(trashclaw, "ACHIEVEMENTS_FILE", str(temp_file))

# Reset ACHIEVEMENTS for each test
initial = {
"unlocked": [],
"stats": {
"files_read": 0, "files_written": 0, "edits": 0,
"commands_run": 0, "commits": 0, "sessions": 0,
"tools_used": 0, "total_turns": 0
}
}
monkeypatch.setitem(trashclaw.ACHIEVEMENTS, "unlocked", initial["unlocked"])
monkeypatch.setitem(trashclaw.ACHIEVEMENTS, "stats", initial["stats"])
return temp_file

def test_track_tool_increments_stats():
# Record stats before
before = trashclaw.ACHIEVEMENTS["stats"]["files_read"]

_track_tool("read_file")

assert trashclaw.ACHIEVEMENTS["stats"]["files_read"] == before + 1
assert trashclaw.ACHIEVEMENTS["stats"]["tools_used"] >= 1

def test_unlock_achievement():
# Manual stat injection to trigger unlock
# 'first_blood' needs 1 edit
_track_tool("edit_file")

assert "first_blood" in trashclaw.ACHIEVEMENTS["unlocked"]

def test_persistence(mock_achievements):
_track_tool("read_file")
_save_achievements(trashclaw.ACHIEVEMENTS)

assert os.path.exists(str(mock_achievements))
with open(str(mock_achievements), 'r') as f:
data = json.load(f)
assert data["stats"]["files_read"] >= 1
122 changes: 122 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import os
import pytest
import trashclaw
from trashclaw import (
tool_read_file, tool_write_file, tool_edit_file, tool_patch_file,
tool_list_dir, tool_find_files, tool_search_files, tool_run_command,
_load_config, _CFG, CWD, handle_slash, LAST_ASSISTANT_RESPONSE
)

@pytest.fixture
def test_env(tmp_path):
"""Setup a temporary workspace for file operations."""
d = tmp_path / "workspace"
d.mkdir()
return d

def test_write_and_read_file(test_env):
path = str(test_env / "hello.txt")
content = "Hello, TrashClaw!\nLine 2"

# Test write
res = tool_write_file(path, content)
assert "Wrote" in res
assert os.path.exists(path)

# Test read
read_res = tool_read_file(path)
assert "Hello, TrashClaw!" in read_res
assert "Line 2" in read_res
assert "1" in read_res # Line numbering

def test_edit_file(test_env):
path = str(test_env / "config.py")
content = "VERSION = '0.1.0'\nDEBUG = False"
tool_write_file(path, content)

# Test precise edit
res = tool_edit_file(path, "VERSION = '0.1.0'", "VERSION = '0.2.0'")
assert "Edited" in res

with open(path, 'r') as f:
new_content = f.read()
assert "VERSION = '0.2.0'" in new_content
assert "DEBUG = False" in new_content

def test_patch_file(test_env):
path = str(test_env / "main.py")
content = "def start():\n print('Starting')\n\nif __name__ == '__main__':\n start()"
tool_write_file(path, content)

patch = """@@ -1,5 +1,6 @@
def start():
+ print('Initializing...')
print('Starting')

if __name__ == '__main__':"""

res = tool_patch_file(path, patch)
assert "Patched" in res

with open(path, 'r') as f:
patched = f.read()
assert "Initializing..." in patched
assert "Starting" in patched

def test_list_dir(test_env):
(test_env / "subdir").mkdir()
tool_write_file(str(test_env / "file1.txt"), "test")

res = tool_list_dir(str(test_env))
assert "subdir/" in res
assert "file1.txt" in res

def test_find_files(test_env):
(test_env / "src").mkdir()
tool_write_file(str(test_env / "src/main.py"), "test")
tool_write_file(str(test_env / "README.md"), "test")

res = tool_find_files("**/*.py", str(test_env))
assert "src/main.py" in res
assert "README.md" not in res

def test_search_files(test_env):
tool_write_file(str(test_env / "data.txt"), "Secret: 12345\nOther stuff")

res = tool_search_files(r"Secret: \d+", str(test_env))
assert "data.txt:1: Secret: 12345" in res

def test_run_command_basic():
# run_command respects global APPROVE_SHELL, usually off in tests or we need to mock it
import trashclaw
trashclaw.APPROVE_SHELL = False

res = tool_run_command("echo 'Hello World'")
assert "Hello World" in res

def test_load_config_project(test_env):
toml_path = test_env / ".trashclaw.toml"
with open(toml_path, "w") as f:
f.write("model = \"test-model-abc\"\nauto_shell = true\n")

cfg = _load_config(str(test_env))
assert cfg.get("model") == "test-model-abc"
assert cfg.get("auto_shell") == True

def test_pipe_command(test_env, monkeypatch):
import trashclaw
monkeypatch.setattr(trashclaw, "CWD", str(test_env))
monkeypatch.setattr(trashclaw, "LAST_ASSISTANT_RESPONSE", "Mock Response Data")

# 1. Pipe with explicit filename
handle_slash("/pipe test_output.txt")
output_file = test_env / "test_output.txt"
assert output_file.exists()
assert output_file.read_text() == "Mock Response Data"

# 2. Pipe with auto-timestamp (no arg)
handle_slash("/pipe")
# Should create output_YYYYMMDD_HHMMSS.md
matches = list(test_env.glob("output_*.md"))
assert len(matches) == 1
assert matches[0].read_text() == "Mock Response Data"
40 changes: 40 additions & 0 deletions tests/test_undo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import os
import pytest
import trashclaw
from trashclaw import _save_undo, tool_write_file, UNDO_STACK

@pytest.fixture(autouse=True)
def clean_undo_stack():
"""Clear the undo stack before each test."""
trashclaw.UNDO_STACK.clear()
yield
trashclaw.UNDO_STACK.clear()

def test_save_undo_write(tmp_path):
path = tmp_path / "undo_test.txt"
# No file yet
_save_undo(str(path), "write")

assert len(trashclaw.UNDO_STACK) == 1
assert trashclaw.UNDO_STACK[0]["path"] == str(path)
assert trashclaw.UNDO_STACK[0]["content"] is None
assert trashclaw.UNDO_STACK[0]["action"] == "write"

def test_save_undo_edit(tmp_path):
path = tmp_path / "edit_test.txt"
content = "Original Content"
with open(path, "w") as f:
f.write(content)

_save_undo(str(path), "edit")

assert len(trashclaw.UNDO_STACK) == 1
assert trashclaw.UNDO_STACK[0]["content"] == content
assert trashclaw.UNDO_STACK[0]["action"] == "edit"

def test_tool_write_triggers_undo(tmp_path):
path = str(tmp_path / "auto_undo.txt")
tool_write_file(path, "new content")

assert len(trashclaw.UNDO_STACK) == 1
assert trashclaw.UNDO_STACK[0]["path"] == path
92 changes: 74 additions & 18 deletions trashclaw.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,37 +39,74 @@ def parse_and_bind(self, *args): pass
import readline

# ── Config ──
VERSION = "0.7.0"
VERSION = "0.7.1"
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".trashclaw")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
HISTORY_FILE = os.path.join(CONFIG_DIR, "history")

def _load_config() -> Dict:
"""Load config from ~/.trashclaw/config.json, merged with env vars (env wins)."""
def _load_config(cwd: str = None) -> Dict:
"""Load config from ~/.trashclaw/config.json and .trashclaw.toml (cwd). Env wins."""
cfg = {}

# 1. Base config from home dir
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, 'r') as f:
cfg = json.load(f)
except Exception:
pass

# 2. Project config from .trashclaw.toml in CWD
# We use a minimal TOML parser to keep zero external dependencies on Python < 3.11
target_cwd = cwd or os.getcwd()
project_config = os.path.join(target_cwd, ".trashclaw.toml")
if os.path.exists(project_config):
try:
with open(project_config, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"): continue
if "=" in line:
k, v = line.split("=", 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
# Basic type casting
if v.lower() == "true": v = True
elif v.lower() == "false": v = False
elif v.isdigit(): v = int(v)
cfg[k] = v
except Exception:
pass

return cfg

def _apply_config(cfg: Dict):
"""Apply config dict to global variables."""
global LLAMA_URL, MODEL_NAME, MAX_TOOL_ROUNDS, MAX_CONTEXT_MESSAGES
global AUTO_COMPACT_THRESHOLD, APPROVE_SHELL

def _c(key: str, env_key: str, default: Any) -> Any:
val = os.environ.get(env_key, cfg.get(key, default))
if isinstance(default, int) and not isinstance(val, int):
try: return int(val)
except: return default
return val

LLAMA_URL = _c("url", "TRASHCLAW_URL", "http://localhost:8080")
MODEL_NAME = _c("model", "TRASHCLAW_MODEL", "local")
# print(f"DEBUG: Loaded model={MODEL_NAME} from config")
MAX_TOOL_ROUNDS = _c("max_rounds", "TRASHCLAW_MAX_ROUNDS", 15)
MAX_CONTEXT_MESSAGES = _c("max_context", "TRASHCLAW_MAX_CONTEXT", 80)
AUTO_COMPACT_THRESHOLD = MAX_CONTEXT_MESSAGES + 20
APPROVE_SHELL = _c("auto_shell", "TRASHCLAW_AUTO_SHELL", "0") != "1"

# Initial load with default CWD
_CFG = _load_config()
_apply_config(_CFG)

def _c(key: str, env_key: str, default: str) -> str:
"""Config lookup: env var > config file > default."""
return os.environ.get(env_key, _CFG.get(key, default))

LLAMA_URL = _c("url", "TRASHCLAW_URL", "http://localhost:8080")
MODEL_NAME = _c("model", "TRASHCLAW_MODEL", "local")
MAX_TOOL_ROUNDS = int(_c("max_rounds", "TRASHCLAW_MAX_ROUNDS", "15"))
MAX_OUTPUT_CHARS = 8000
MAX_CONTEXT_MESSAGES = int(_c("max_context", "TRASHCLAW_MAX_CONTEXT", "80"))
AUTO_COMPACT_THRESHOLD = MAX_CONTEXT_MESSAGES + 20
LLM_RETRY_ATTEMPTS = 2
LLM_RETRY_DELAY = 3
APPROVE_SHELL = _c("auto_shell", "TRASHCLAW_AUTO_SHELL", "0") != "1"
HISTORY: List[Dict] = []
UNDO_STACK: List[Dict] = [] # [{path, content_before, action}]
APPROVED_COMMANDS: set = set()
Expand Down Expand Up @@ -1493,12 +1530,24 @@ def _agent_loop(round_limit: int):
HISTORY.append({"role": "assistant", "content": content})
global LAST_ASSISTANT_RESPONSE
LAST_ASSISTANT_RESPONSE = content

# Show generation stats
stats = LAST_GENERATION_STATS
if stats:
tps = stats.get('tokens_per_sec', 0)
print(f" \033[90m[{stats.get('tokens', 0)} tokens | {stats.get('seconds', 0):.2f}s | {tps:.1f} tps]\033[0m")
return

# Execute tool calls
assistant_msg = {"role": "assistant", "content": content or None, "tool_calls": tool_calls}
HISTORY.append(assistant_msg)

# Show generation stats
stats = LAST_GENERATION_STATS
if stats:
tps = stats.get('tokens_per_sec', 0)
print(f" \033[90m[{stats.get('tokens', 0)} tokens | {stats.get('seconds', 0):.2f}s | {tps:.1f} tps]\033[0m")

for tc in tool_calls:
func = tc.get("function", {})
tool_name = func.get("name", "unknown")
Expand Down Expand Up @@ -1892,19 +1941,22 @@ def handle_slash(cmd: str) -> bool:
elif command == "/pipe":
# Save last assistant response to file
global LAST_ASSISTANT_RESPONSE
if not arg:
print(" Usage: /pipe <filename>")
print(" Saves the last assistant response to the specified file.")
elif not LAST_ASSISTANT_RESPONSE:
if not LAST_ASSISTANT_RESPONSE:
print(" Error: No assistant response to save yet.")
else:
if not arg:
# Use timestamp-based name if no filename provided
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
arg = f"output_{ts}.md"

pipe_path = _resolve_path(arg)
try:
os.makedirs(os.path.dirname(pipe_path), exist_ok=True)
with open(pipe_path, 'w', encoding='utf-8') as f:
f.write(LAST_ASSISTANT_RESPONSE)
lines = LAST_ASSISTANT_RESPONSE.count('\n') + 1
print(f" Piped last response to {pipe_path} ({len(LAST_ASSISTANT_RESPONSE)} bytes, {lines} lines)")
size = len(LAST_ASSISTANT_RESPONSE)
print(f" Piped last response to {pipe_path} ({size} bytes, {lines} lines)")
except Exception as e:
print(f" Error: {e}")

Expand Down Expand Up @@ -2131,8 +2183,12 @@ def main():
while i < len(args):
if args[i] == "--cwd" and i + 1 < len(args):
CWD = os.path.abspath(args[i + 1]); i += 2
# Reload config from new CWD
_apply_config(_load_config(CWD))
elif args[i].startswith("--cwd="):
CWD = os.path.abspath(args[i].split("=", 1)[1]); i += 1
# Reload config from new CWD
_apply_config(_load_config(CWD))
elif args[i] == "--url" and i + 1 < len(args):
globals()["LLAMA_URL"] = args[i + 1]; i += 2
elif args[i].startswith("--url="):
Expand Down