diff options
| author | Louis Burda <dev@sinitax.com> | 2026-03-03 01:10:09 +0100 |
|---|---|---|
| committer | Louis Burda <dev@sinitax.com> | 2026-03-03 01:10:09 +0100 |
| commit | b8694d1456a591e8ca20c76b17f27dbd0cfe1857 (patch) | |
| tree | 7d9979ab90ce9fa97aae96254d78efc4ee2afef0 | |
| parent | f8720efcc51355b44b28440a6d22c6ca4c56d165 (diff) | |
| download | claude-task-main.tar.gz claude-task-main.zip | |
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | src/ctask/cli.py | 361 |
2 files changed, 290 insertions, 82 deletions
@@ -102,7 +102,9 @@ Options: ```bash ctask mod 8 --status in_progress ctask mod 8 --name "New name" --description "New description" -ctask mod 8 --add-before 7 --add-after 5 +ctask mod 8 --after 5 # add dependency +ctask mod 8 --unblock # remove all blockers +ctask mod 8 --unblock --after 5 # replace blockers with only task 5 ``` Options: @@ -110,8 +112,9 @@ Options: - `--description`: Update description - `--status`: Update status - `--active-form`: Update active form -- `--add-blocks` / `--add-before`: Add task IDs this blocks (this comes before them) -- `--add-blocked-by` / `--add-after`: Add task IDs blocking this (this comes after them) +- `--blocks` / `--before`: Set task IDs this blocks (replaces existing) +- `--blocked-by` / `--after`: Set/add task IDs blocking this (adds to existing, or replaces if used with --unblock) +- `--unblock`: Clear all blockers before applying --blocked-by ### Edit a task ```bash @@ -162,7 +165,7 @@ Task IDs are automatically incremented when creating new tasks. The ID is determ - The maximum of existing task IDs + 1 - The value in `.highwatermark` file + 1 (if it exists) -The `.highwatermark` file in each session directory tracks the highest task ID to prevent ID conflicts when tasks are deleted. +The `.highwatermark` file in each session directory is managed by Claude and tracks the highest task ID to prevent ID conflicts. ## Session State diff --git a/src/ctask/cli.py b/src/ctask/cli.py index fe1d19f..45e76c2 100644 --- a/src/ctask/cli.py +++ b/src/ctask/cli.py @@ -1,7 +1,12 @@ +import datetime import json import os +import shutil +import signal import subprocess import sys +import tempfile +import time from pathlib import Path from typing import Optional @@ -17,29 +22,6 @@ console = Console() def get_tasks_base_dir() -> Path: return Path.home() / ".claude" / "tasks" -def load_sessions_index() -> dict: - projects_dir = Path.home() / ".claude" / "projects" - sessions_data = {} - - if not projects_dir.exists(): - return sessions_data - - for project_dir in projects_dir.iterdir(): - if project_dir.is_dir(): - index_file = project_dir / "sessions-index.json" - if index_file.exists(): - try: - with open(index_file) as f: - data = json.load(f) - if "entries" in data: - for entry in data["entries"]: - session_id = entry.get("sessionId") - if session_id: - sessions_data[session_id] = entry - except Exception: - continue - - return sessions_data def get_session_state_file() -> Path: return Path(os.path.expanduser("~/.claude/ctask_state.json")) @@ -85,6 +67,41 @@ def load_task(task_file: Path) -> dict: with open(task_file) as f: return json.load(f) +def task_to_md(task: dict) -> str: + lines = ["---", f"subject: {task.get('subject', '')}"] + blocks = task.get("blocks", []) + if blocks: + lines.append(f"blocks: {', '.join(blocks)}") + blocked_by = task.get("blockedBy", []) + if blocked_by: + lines.append(f"blockedBy: {', '.join(blocked_by)}") + lines += ["---", "", task.get("description", "")] + return "\n".join(lines) + +def md_to_task(text: str, original: dict) -> dict: + lines = text.split("\n") + if not lines or lines[0].strip() != "---": + raise ValueError("Missing opening frontmatter delimiter") + try: + end = next(i for i in range(1, len(lines)) if lines[i].strip() == "---") + except StopIteration: + raise ValueError("Missing closing frontmatter delimiter") + fm_lines = lines[1:end] + body = "\n".join(lines[end + 1:]).strip() + fm = {} + for line in fm_lines: + if ":" in line: + k, _, v = line.partition(":") + fm[k.strip()] = v.strip() + def parse_ids(s): + return [x.strip() for x in s.split(",") if x.strip()] if s else [] + task = dict(original) + task["subject"] = fm.get("subject", task.get("subject", "")) + task["blocks"] = parse_ids(fm.get("blocks", "")) + task["blockedBy"] = parse_ids(fm.get("blockedBy", "")) + task["description"] = body + return task + def save_task(task_file: Path, task: dict): with open(task_file, 'w') as f: json.dump(task, f, indent=2) @@ -103,34 +120,107 @@ def get_all_tasks(task_dir: Path) -> list[tuple[Path, dict]]: return tasks def get_next_task_id(task_dir: Path) -> int: - highwatermark_file = task_dir / ".highwatermark" - highwatermark = 0 - - if highwatermark_file.exists(): - try: - with open(highwatermark_file) as f: - highwatermark = int(f.read().strip()) - except (ValueError, IOError): - highwatermark = 0 - tasks = get_all_tasks(task_dir) - max_existing_id = 0 if tasks: - max_existing_id = max(int(task["id"]) for _, task in tasks) - - next_id = max(highwatermark + 1, max_existing_id + 1) - + return max(int(task["id"]) for _, task in tasks) + 1 + return 1 + +def _run_impl(resume: bool = False): + ctask_bin = sys.argv[0] if sys.argv[0] != "-c" else (shutil.which("ctask") or "ctask") + settings = { + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": f"{ctask_bin} --stop-hook" + } + ] + } + ] + } + } + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", prefix="ctask_settings_", delete=False + ) as tmp: + json.dump(settings, tmp, indent=2) + tmp_path = Path(tmp.name) try: - with open(highwatermark_file, 'w') as f: - f.write(str(next_id)) - except IOError: - pass - - return next_id + cmd = ["claude"] + if resume: + cmd.append("--resume") + cmd += ["--settings", str(tmp_path), "Use the tasks tool to check for tasks and run them"] + result = subprocess.run(cmd) + sys.exit(result.returncode) + finally: + tmp_path.unlink(missing_ok=True) + +def _stop_hook_impl(): + try: + hook_input = json.loads(sys.stdin.read()) + except Exception: + hook_input = {} + + with open("/tmp/log", "w") as _log: + print(json.dumps(hook_input, indent=2), file=_log) + session_id = hook_input.get("session_id") + if not session_id: + sys.exit(0) + session_path = get_tasks_base_dir() / session_id + + def get_unblocked_tasks() -> list[dict]: + tasks = get_all_tasks(session_path) + if not tasks: + return [] + completed_ids = {t["id"] for _, t in tasks if t.get("status") == "completed"} + return [ + task for _, task in tasks + if task.get("status") not in ("completed", "deleted") + and all(bid in completed_ids for bid in task.get("blockedBy", [])) + ] + + def block_stop(tasks: list[dict]): + task_lines = "\n".join( + f"- #{t['id']}: {t.get('subject', '(no subject)')} [{t.get('status', 'pending')}]" + for t in tasks + ) + reason = f"Use the tasks tool to check for tasks and run them\n\nPending tasks:\n{task_lines}" + print(json.dumps({"decision": "block", "reason": reason})) + sys.exit(0) + + pending = get_unblocked_tasks() + if pending: + block_stop(pending) + + def handle_signal(signum, frame): + sys.exit(0) + + signal.signal(signal.SIGINT, handle_signal) + signal.signal(signal.SIGTERM, handle_signal) + + def dir_snapshot() -> dict: + if not session_path.exists(): + return {} + return {str(f): f.stat().st_mtime for f in session_path.glob("*.json")} + + snapshot = dir_snapshot() + while True: + time.sleep(1) + new_snapshot = dir_snapshot() + if new_snapshot != snapshot: + snapshot = new_snapshot + pending = get_unblocked_tasks() + if pending: + block_stop(pending) @click.group(invoke_without_command=True, context_settings={"help_option_names": ["-h", "--help"]}) +@click.option("--stop-hook", "do_stop_hook", is_flag=True, hidden=True) @click.pass_context -def main(ctx): +def main(ctx, do_stop_hook): + if do_stop_hook: + _stop_hook_impl() + return if ctx.invoked_subcommand is None: ctx.invoke(list_tasks) @@ -187,6 +277,45 @@ def ls_tasks(): _list_tasks_impl() def _create_task_impl(name, description, status, active_form, blocks, blocked_by): + if name is None: + skeleton = { + "subject": "", + "status": status, + "activeForm": active_form or "", + "blocks": list(blocks), + "blockedBy": list(blocked_by), + "description": description, + } + md_content = task_to_md(skeleton) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".md", prefix="ctask_new_", delete=False + ) as tmp: + tmp.write(md_content) + tmp_path = Path(tmp.name) + try: + mtime_before = tmp_path.stat().st_mtime + editor = os.environ.get("EDITOR", "vi") + subprocess.run([editor, str(tmp_path)]) + mtime_after = tmp_path.stat().st_mtime + if mtime_after == mtime_before: + console.print("[yellow]No changes made, task not created[/yellow]") + return + parsed = md_to_task(tmp_path.read_text(), skeleton) + except ValueError as e: + console.print(f"[red]Parse error: {e}[/red]") + sys.exit(1) + finally: + tmp_path.unlink(missing_ok=True) + name = parsed["subject"].strip() + if not name: + console.print("[yellow]No subject provided, task not created[/yellow]") + return + description = parsed["description"] + status = parsed["status"] + active_form = parsed["activeForm"] + blocks = parsed["blocks"] + blocked_by = parsed["blockedBy"] + task_dir = require_session() task_dir.mkdir(parents=True, exist_ok=True) @@ -207,7 +336,7 @@ def _create_task_impl(name, description, status, active_form, blocks, blocked_by console.print(f"[dim]{task_file}[/dim]") @main.command("create") -@click.argument("name") +@click.argument("name", required=False, default=None) @click.argument("description", required=False, default="") @click.option("--status", default="pending", help="Task status") @click.option("--active-form", help="Active form description") @@ -217,7 +346,7 @@ def create_task(name, description, status, active_form, blocks, blocked_by): _create_task_impl(name, description, status, active_form, blocks, blocked_by) @main.command("add") -@click.argument("name") +@click.argument("name", required=False, default=None) @click.argument("description", required=False, default="") @click.option("--status", default="pending", help="Task status") @click.option("--active-form", help="Active form description") @@ -232,9 +361,10 @@ def add_task(name, description, status, active_form, blocks, blocked_by): @click.option("--description", help="New description") @click.option("--status", help="New status") @click.option("--active-form", help="New active form") -@click.option("--add-blocks", "--add-before", multiple=True, help="Add task IDs this blocks (comes before)") -@click.option("--add-blocked-by", "--add-after", multiple=True, help="Add task IDs blocking this (comes after)") -def modify_task(task_id, name, description, status, active_form, add_blocks, add_blocked_by): +@click.option("--blocks", "--before", multiple=True, help="Set task IDs this blocks (comes before)") +@click.option("--blocked-by", "--after", multiple=True, help="Set task IDs blocking this (comes after)") +@click.option("--unblock", is_flag=True, help="Clear all blockers before applying --blocked-by") +def modify_task(task_id, name, description, status, active_form, blocks, blocked_by, unblock): task_dir = require_session() task_file = task_dir / f"{task_id}.json" @@ -252,17 +382,23 @@ def modify_task(task_id, name, description, status, active_form, add_blocks, add task["status"] = status if active_form: task["activeForm"] = active_form - if add_blocks: - task["blocks"].extend(add_blocks) - if add_blocked_by: - task["blockedBy"].extend(add_blocked_by) + if unblock: + task["blockedBy"] = [] + if blocks: + task["blocks"] = list(blocks) + if blocked_by: + if unblock: + task["blockedBy"] = list(blocked_by) + else: + task["blockedBy"].extend(blocked_by) save_task(task_file, task) console.print(f"[green]Modified task {task_id}[/green]") @main.command("edit") @click.argument("task_id") -def edit_task(task_id): +@click.option("--json", "use_json", is_flag=True, help="Edit raw JSON instead of markdown") +def edit_task(task_id, use_json): task_dir = require_session() task_file = task_dir / f"{task_id}.json" @@ -271,7 +407,38 @@ def edit_task(task_id): sys.exit(1) editor = os.environ.get("EDITOR", "vi") - subprocess.run([editor, str(task_file)]) + + if use_json: + subprocess.run([editor, str(task_file)]) + return + + task = load_task(task_file) + md_content = task_to_md(task) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".md", prefix=f"ctask_{task_id}_", delete=False + ) as tmp: + tmp.write(md_content) + tmp_path = Path(tmp.name) + + try: + mtime_before = tmp_path.stat().st_mtime + subprocess.run([editor, str(tmp_path)]) + mtime_after = tmp_path.stat().st_mtime + + if mtime_after == mtime_before: + console.print("[yellow]No changes made[/yellow]") + return + + updated_text = tmp_path.read_text() + updated_task = md_to_task(updated_text, task) + save_task(task_file, updated_task) + console.print(f"[green]Saved task {task_id}[/green]") + except ValueError as e: + console.print(f"[red]Parse error: {e}[/red]") + sys.exit(1) + finally: + tmp_path.unlink(missing_ok=True) def _delete_task_impl(task_id): task_dir = require_session() @@ -296,32 +463,65 @@ def delete_task(task_id): def rm_task(task_id): _delete_task_impl(task_id) -def get_available_sessions() -> list[dict]: - tasks_base = get_tasks_base_dir() +def _relative_time(mtime: float) -> str: + diff = time.time() - mtime + if diff < 60: + return "just now" + elif diff < 3600: + n = int(diff / 60) + return f"{n} minute{'s' if n != 1 else ''} ago" + elif diff < 86400: + n = int(diff / 3600) + return f"{n} hour{'s' if n != 1 else ''} ago" + elif diff < 86400 * 7: + n = int(diff / 86400) + return f"{n} day{'s' if n != 1 else ''} ago" + else: + n = int(diff / (86400 * 7)) + return f"{n} week{'s' if n != 1 else ''} ago" + +def _transcript_cwd(transcript: Path) -> Optional[str]: + try: + with open(transcript) as f: + for line in f: + line = line.strip() + if not line: + continue + entry = json.loads(line) + if "cwd" in entry: + return entry["cwd"] + except Exception: + pass + return None - if not tasks_base.exists(): +def get_available_sessions() -> list[dict]: + projects_dir = Path.home() / ".claude" / "projects" + if not projects_dir.exists(): return [] - sessions_index = load_sessions_index() sessions = [] - - for entry in tasks_base.iterdir(): - if entry.is_dir(): - task_files = list(entry.glob("*.json")) - if task_files: - session_id = entry.name - mtime = entry.stat().st_mtime - - description = None - if session_id in sessions_index: - description = sessions_index[session_id].get("firstPrompt") - - sessions.append({ - "name": session_id, - "path": str(entry), - "description": description, - "mtime": mtime - }) + for project_dir in projects_dir.iterdir(): + if not project_dir.is_dir(): + continue + for transcript in project_dir.glob("*.jsonl"): + cwd = _transcript_cwd(transcript) + mtime = transcript.stat().st_mtime + session_id = transcript.stem + task_path = get_tasks_base_dir() / session_id + if cwd: + parts = Path(cwd).parts + name = str(Path(*parts[-2:])) if len(parts) >= 2 else cwd + else: + name = project_dir.name + name = f"{name} {session_id[:8]}" + dt = datetime.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M") + description = f"{dt} ({_relative_time(mtime)})" + sessions.append({ + "name": name, + "path": str(task_path), + "description": description, + "mtime": mtime, + }) sessions.sort(key=lambda x: x["mtime"], reverse=True) return [{"name": s["name"], "path": s["path"], "description": s["description"]} for s in sessions] @@ -378,5 +578,10 @@ def select_session(): console.print("\n[yellow]Selection cancelled[/yellow]") sys.exit(1) +@main.command("run") +@click.option("--resume", "resume", is_flag=True, help="Resume the last Claude session") +def run_claude(resume): + _run_impl(resume=resume) + if __name__ == "__main__": main() |
