From a5725088f68815e28e247fe07a4a60c0c058e63f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 07:19:54 +0000 Subject: [PATCH 1/4] Add Taskwarrior-style hook support Hook scripts placed in a directory specified by hooks_directory in .letsdo.yaml are executed on task start and stop events. Each executable file in that directory is called with the event name ("start" or "stop") followed by the full task description. If hooks_directory is not configured, the feature is silently ignored. https://claude.ai/code/session_01G5XVbosnCuQ2ZDju6waeaB --- src/configuration.py | 9 +++++++++ src/handlers.py | 6 +++++- src/hooks.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/hooks.py diff --git a/src/configuration.py b/src/configuration.py index 8a1570b..8d1020e 100644 --- a/src/configuration.py +++ b/src/configuration.py @@ -27,6 +27,15 @@ def create_default_configuration(home="~"): return yaml.dump(default_config, f) +def get_hooks_directory(home="~"): + """Return the hooks directory path if configured, otherwise None""" + config = get_configuration(home) + hooks_dir = config.get("hooks_directory") + if hooks_dir: + return os.path.expanduser(hooks_dir) + return None + + def get_task_file_path(home="~"): """Return the running task data file path""" return os.path.join(get_configuration(home)["data_directory"], TASK_FILE_NAME) diff --git a/src/handlers.py b/src/handlers.py index 42652b8..46ef056 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -8,6 +8,7 @@ from datetime import datetime from app import Task, guess_task_id_from_string, work_on from configuration import autocomplete, create_default_configuration +from hooks import run_hooks from typing import Tuple @@ -36,6 +37,7 @@ def start_task_handler(description: str, start_str: str="") -> Tuple[bool, str]: task = Task.get_running() if task: + run_hooks("start", task.name) return True, f"{task.start_time}: {task.name} started" return False, "No task running" @@ -65,11 +67,13 @@ def stop_task_handler(stop_time: str) -> Tuple[bool, str]: if not task: return False, "no task running, nothing to do" + task_name = task.name work_time = Task.stop(stop_time) if not work_time: return False, "error: could not get stop time" + run_hooks("stop", task_name) now = datetime.now().strftime("%H:%M") - msg = f"{now}: stopped task: {task.name}, after {work_time[0]}hours, {work_time[1]} minutes" + msg = f"{now}: stopped task: {task_name}, after {work_time[0]}hours, {work_time[1]} minutes" return True, msg diff --git a/src/hooks.py b/src/hooks.py new file mode 100644 index 0000000..763ef75 --- /dev/null +++ b/src/hooks.py @@ -0,0 +1,28 @@ +""" +Hook execution support. Hooks are scripts placed in the hooks_directory +configured in .letsdo.yaml. Each executable file in that directory is called +with two arguments: the event name ("start" or "stop") and the task description. +""" +import os +import subprocess +from log import LOGGER +from configuration import get_hooks_directory + + +def run_hooks(event: str, task_description: str) -> None: + """Run all executable hook scripts in the configured hooks directory.""" + hooks_dir = get_hooks_directory() + if not hooks_dir: + return + + if not os.path.isdir(hooks_dir): + LOGGER.warning(f"hooks_directory '{hooks_dir}' does not exist or is not a directory") + return + + for entry in sorted(os.listdir(hooks_dir)): + script_path = os.path.join(hooks_dir, entry) + if os.path.isfile(script_path) and os.access(script_path, os.X_OK): + try: + subprocess.run([script_path, event, task_description], check=False) + except Exception as exc: + LOGGER.warning(f"hook '{script_path}' failed: {exc}") From 6918acd90a6ce250b5ed0adc6d1a033cd83db786 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 07:20:45 +0000 Subject: [PATCH 2/4] Extend .gitignore to cover Python cache and build artifacts https://claude.ai/code/session_01G5XVbosnCuQ2ZDju6waeaB --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5fddb19..b39f770 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ __version__.py +__pycache__/ +*.py[cod] +*.egg-info/ +src/letsdo.egg-info build/ dist/ -src/letsdo.egg-info +.eggs/ From 7ba8ce5bf1bad9c32b3f1537a1e9f1aa1b436720 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 08:32:42 +0000 Subject: [PATCH 3/4] Document hooks feature in README https://claude.ai/code/session_01G5XVbosnCuQ2ZDju6waeaB --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index c94cd3f..b92bf76 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,33 @@ color: true data_directory: /home/username/ ``` +An optional `hooks_directory` field can also be set: + +``` +color: true +data_directory: /home/username/ +hooks_directory: /home/username/.letsdo-hooks/ +``` + +## Hooks + +Letsdo supports hooks similarly to Taskwarrior. A hook is any executable script placed in the directory defined by `hooks_directory` in `.letsdo.yaml`. If `hooks_directory` is not set, the feature is silently ignored. + +Hook scripts are called with two arguments: +1. The event name: `start` when a task has just started, `stop` when a task has just stopped. +2. The full description of the task. + +Example hook script (`~/.letsdo-hooks/notify`): + +```sh +#!/bin/sh +event="$1" +task="$2" +notify-send "letsdo" "$event: $task" +``` + +All executable files in `hooks_directory` are called in alphabetical order on each event. + Let's see now the history: you can rapidly have a look at **today** and **yesterday** work done by typing: ``` From 58305aaf548104f7b19a94ab51f6da90d751be35 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 16:32:40 +0000 Subject: [PATCH 4/4] Fire cancel hook when a task is cancelled https://claude.ai/code/session_01G5XVbosnCuQ2ZDju6waeaB --- src/handlers.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/handlers.py b/src/handlers.py index 46ef056..bc4fc65 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -55,10 +55,13 @@ def edit_file_handler(filename) -> Tuple[bool, str]: def cancel_task_handler() -> Tuple[bool, str]: """handles a request to cancel the current task""" - msg = Task.cancel() - if not msg: + task = Task.get_running() + if not task: return False, "No task running, nothing to do" - return True, f"cancelled task: {msg}" + task_name = task.name + Task.cancel() + run_hooks("cancel", task_name) + return True, f"cancelled task: {task_name}" def stop_task_handler(stop_time: str) -> Tuple[bool, str]: