Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ The hook auto-registers in `settings.json` on your first `/tomato start`. Restar

Tomato installs a global `PreToolUse` hook in `~/.claude/settings.json`. On every tool call, the hook checks a state file (`~/.tomato/state.json`) and either allows the call (exit 0) or blocks it (exit 2).

The hook is fast: ~8ms on the normal path (read JSON, check timer, allow). Phase transitions (work to rest, rest to work) take ~35ms and happen once every 25 minutes.
The hook is designed to be fast on the normal path (read JSON, check timer, allow). Phase transitions (work rest, rest work) happen once every 25 minutes and do a little more work.

During rest, all tool calls are blocked — but `/tomato stop`, `/tomato resume`, and `/tomato status` still work. The hook whitelists its own CLI so you're never locked out.

Expand Down
9 changes: 9 additions & 0 deletions skills/tomato/bin/tomato-cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ def load_config() -> Dict[str, Any]:
config.update(user)
except (json.JSONDecodeError, OSError) as exc:
print(f"Warning: could not read config — {exc}", file=sys.stderr)

for key, default in DEFAULT_CONFIG.items():
val = config.get(key)
if isinstance(val, bool) or not isinstance(val, int) or val <= 0:
print(
f"Warning: config.json {key}={val!r} is invalid; using default {default}",
file=sys.stderr,
)
config[key] = default
return config


Expand Down
6 changes: 5 additions & 1 deletion skills/tomato/bin/tomato-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,11 @@ if [ "$PHASE" = "rest" ] && [ "$ELAPSED" -lt $((ACTIVE_REST_MINUTES * 60)) ]; th
# Grace period: let the active session finish its current task
if [ "$GRACE_SESSION_ID" != "null" ] && [ -n "$SESSION_ID" ] && \
[ "$SESSION_ID" = "$GRACE_SESSION_ID" ]; then
# Check hard cap first
# Check hard cap first. The `$NOW -ge $GRACE_*_AT` guards protect against
# backward clock skew: without them, a negative delta would evaluate as
# "within grace window" and bypass rest indefinitely.
if [ "$GRACE_STARTED_AT" != "null" ] && \
[ "$NOW" -ge "$GRACE_STARTED_AT" ] && \
[ $((NOW - GRACE_STARTED_AT)) -ge "$GRACE_MAX_SEC" ]; then
# Hard cap reached — clear grace, fall through to block
if acquire_lock; then
Expand All @@ -285,6 +288,7 @@ if [ "$PHASE" = "rest" ] && [ "$ELAPSED" -lt $((ACTIVE_REST_MINUTES * 60)) ]; th
release_lock
fi
elif [ "$GRACE_LAST_CALL_AT" != "null" ] && \
[ "$NOW" -ge "$GRACE_LAST_CALL_AT" ] && \
[ $((NOW - GRACE_LAST_CALL_AT)) -lt "$GRACE_TIMEOUT_SEC" ]; then
# Within grace window — allow and update timestamp
if acquire_lock; then
Expand Down
39 changes: 31 additions & 8 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,14 +228,16 @@ def test_stats_empty(self, tmp_path: Path) -> None:

def test_stats_with_data(self, tmp_path: Path) -> None:
"""Write history entries with 'timestamp' key -> stats shows correct cycles."""
# Keep all timestamps within a 5-second window so the test never
# straddles midnight (stats filters by local-time "today").
now = int(time.time())
events = [
{"event": "work_start", "timestamp": now - 3000, "cycle": 1},
{"event": "work_end", "timestamp": now - 1500, "cycle": 1, "duration_sec": 1500},
{"event": "rest_start", "timestamp": now - 1500, "cycle": 1, "rest_minutes": 5},
{"event": "rest_end", "timestamp": now - 1200, "cycle": 1},
{"event": "work_start", "timestamp": now - 1200, "cycle": 2},
{"event": "work_end", "timestamp": now - 100, "cycle": 2, "duration_sec": 1100},
{"event": "work_start", "timestamp": now - 5, "cycle": 1},
{"event": "work_end", "timestamp": now - 4, "cycle": 1, "duration_sec": 1500},
{"event": "rest_start", "timestamp": now - 3, "cycle": 1, "rest_minutes": 5},
{"event": "rest_end", "timestamp": now - 2, "cycle": 1},
{"event": "work_start", "timestamp": now - 1, "cycle": 2},
{"event": "work_end", "timestamp": now, "cycle": 2, "duration_sec": 1100},
]
write_history(tmp_path, events)

Expand Down Expand Up @@ -342,6 +344,26 @@ def test_no_args_shows_menu(self, tmp_path: Path) -> None:
assert "Commands:" in result.stdout


class TestConfigValidation:
def test_invalid_config_falls_back_to_defaults(self, tmp_path: Path) -> None:
"""Bad config values (negative, wrong type) fall back to defaults with a warning."""
config_dir = tmp_path / ".tomato"
config_dir.mkdir()
(config_dir / "config.json").write_text(json.dumps({
"work_minutes": -5,
"rest_minutes": "not an int",
"grace_max_sec": True,
}))

result = run_cli("start", home=tmp_path)
assert result.returncode == 0
assert "invalid" in result.stderr.lower()

state = read_state(tmp_path)
assert state["work_minutes"] == 25
assert state["rest_minutes"] == 5


# ---------------------------------------------------------------------------
# Integration: timestamp key consistency
# ---------------------------------------------------------------------------
Expand All @@ -360,10 +382,11 @@ def test_start_history_uses_timestamp_key(self, tmp_path: Path) -> None:

def test_stats_reads_timestamp_key(self, tmp_path: Path) -> None:
"""Write history with 'timestamp' key -> stats returns correct data (not zero)."""
# Keep timestamps within a small window so the test never straddles midnight.
now = int(time.time())
events = [
{"event": "work_start", "timestamp": now - 1600, "cycle": 1},
{"event": "work_end", "timestamp": now - 100, "cycle": 1, "duration_sec": 1500},
{"event": "work_start", "timestamp": now - 2, "cycle": 1},
{"event": "work_end", "timestamp": now, "cycle": 1, "duration_sec": 1500},
]
write_history(tmp_path, events)

Expand Down
13 changes: 11 additions & 2 deletions uninstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ SKILL_DIR="$HOME/.claude/skills/$SKILL_NAME"
TOMATO_DIR="$HOME/.tomato"
SETTINGS_FILE="$HOME/.claude/settings.json"

PURGE=0
if [ "${1:-}" = "--purge" ]; then
PURGE=1
fi

# ---------- Remove hook from settings.json ----------

echo "Removing Tomato hook from Claude Code settings..."
Expand Down Expand Up @@ -73,8 +78,12 @@ fi
# ---------- Remove history ----------

if [ -d "$TOMATO_DIR" ]; then
rm -rf "$TOMATO_DIR"
echo " Removed $TOMATO_DIR/"
if [ "$PURGE" = "1" ]; then
rm -rf "$TOMATO_DIR"
echo " Removed $TOMATO_DIR/ (including focus history)"
else
echo " Preserved $TOMATO_DIR/ (focus history). Re-run with --purge to delete."
fi
else
echo " $TOMATO_DIR/ not found. Skipping."
fi
Expand Down
Loading