diff --git a/README.md b/README.md index 91da9a7..425124d 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,79 @@ -# Task CLI - Test Target for ai-gitops +# Task CLI -A minimal Python CLI task manager used to test the [ai-gitops](https://github.com/scooke11/ai-gitops) workflow. +A simple task manager for the command line. -## What is this? +## Usage -This is a **test target repository** - not a real project. It exists solely to validate that our AI-assisted bounty hunting workflow looks professional before we use it on real open-source projects. +### Add a task +```bash +python task.py add "Buy groceries" +# Added task 1: Buy groceries +``` -## Installation +### List tasks +```bash +python task.py list +# [ ] 1. Buy groceries +# [✓] 2. Write docs +``` +### Mark task as done ```bash -python task.py --help +python task.py done 1 +# Marked task 1 as done: Buy groceries ``` -## Usage +## JSON Output -```bash -# Add a task -python task.py add "Buy groceries" +All commands support a `--json` flag for scripting and automation: -# List tasks -python task.py list +### Add (JSON) +```bash +python task.py --json add "Deploy v2" +``` +```json +{"status": "added", "task": {"id": 1, "description": "Deploy v2", "done": false}} +``` -# Complete a task -python task.py done 1 +### List (JSON) +```bash +python task.py --json list +``` +```json +{"tasks": [{"id": 1, "description": "Deploy v2", "done": false}], "count": 1} ``` -## Testing +### Done (JSON) +```bash +python task.py --json done 1 +``` +```json +{"status": "done", "task": {"id": 1, "description": "Deploy v2", "done": true}} +``` +### Scripting example ```bash -python -m pytest test_task.py +# Add a task and capture the ID +TASK_ID=$(python task.py --json add "Run tests" | python -c "import sys,json; print(json.load(sys.stdin)['task']['id'])") + +# Mark it done +python task.py --json done $TASK_ID + +# List as JSON and pipe to jq +python task.py --json list | jq '.tasks[] | select(.done == false)' ``` ## Configuration -Copy `config.yaml.example` to `~/.config/task-cli/config.yaml` and customize. +Copy the example config: +```bash +mkdir -p ~/.config/task-cli +cp config.yaml.example ~/.config/task-cli/config.yaml +``` + +## Tests + +```bash +pip install pytest +pytest test_task.py -v +``` diff --git a/commands/add.py b/commands/add.py index 1b1a943..a8f4628 100644 --- a/commands/add.py +++ b/commands/add.py @@ -1,6 +1,7 @@ """Add task command.""" import json +import sys from pathlib import Path @@ -11,7 +12,6 @@ def get_tasks_file(): def validate_description(description): """Validate task description.""" - # NOTE: Validation logic scattered here - should be in utils (refactor bounty) if not description: raise ValueError("Description cannot be empty") if len(description) > 200: @@ -19,8 +19,13 @@ def validate_description(description): return description.strip() -def add_task(description): - """Add a new task.""" +def add_task(description, *, json_output=False): + """Add a new task. + + Args: + description: Task description text. + json_output: If True, output result as JSON instead of plain text. + """ description = validate_description(description) tasks_file = get_tasks_file() @@ -31,7 +36,13 @@ def add_task(description): tasks = json.loads(tasks_file.read_text()) task_id = len(tasks) + 1 - tasks.append({"id": task_id, "description": description, "done": False}) + task = {"id": task_id, "description": description, "done": False} + tasks.append(task) tasks_file.write_text(json.dumps(tasks, indent=2)) - print(f"Added task {task_id}: {description}") + + if json_output: + json.dump({"status": "added", "task": task}, sys.stdout) + print() # trailing newline + else: + print(f"Added task {task_id}: {description}") diff --git a/commands/done.py b/commands/done.py index c9dfd42..20fc9a4 100644 --- a/commands/done.py +++ b/commands/done.py @@ -1,6 +1,7 @@ """Mark task done command.""" import json +import sys from pathlib import Path @@ -11,17 +12,25 @@ def get_tasks_file(): def validate_task_id(tasks, task_id): """Validate task ID exists.""" - # NOTE: Validation logic scattered here - should be in utils (refactor bounty) if task_id < 1 or task_id > len(tasks): raise ValueError(f"Invalid task ID: {task_id}") return task_id -def mark_done(task_id): - """Mark a task as complete.""" +def mark_done(task_id, *, json_output=False): + """Mark a task as complete. + + Args: + task_id: ID of the task to mark as done. + json_output: If True, output result as JSON instead of plain text. + """ tasks_file = get_tasks_file() if not tasks_file.exists(): - print("No tasks found!") + if json_output: + json.dump({"status": "error", "message": "No tasks found"}, sys.stdout) + print() + else: + print("No tasks found!") return tasks = json.loads(tasks_file.read_text()) @@ -31,7 +40,15 @@ def mark_done(task_id): if task["id"] == task_id: task["done"] = True tasks_file.write_text(json.dumps(tasks, indent=2)) - print(f"Marked task {task_id} as done: {task['description']}") + if json_output: + json.dump({"status": "done", "task": task}, sys.stdout) + print() + else: + print(f"Marked task {task_id} as done: {task['description']}") return - print(f"Task {task_id} not found") + if json_output: + json.dump({"status": "error", "message": f"Task {task_id} not found"}, sys.stdout) + print() + else: + print(f"Task {task_id} not found") diff --git a/commands/list.py b/commands/list.py index 714315d..f23ad28 100644 --- a/commands/list.py +++ b/commands/list.py @@ -1,6 +1,7 @@ """List tasks command.""" import json +import sys from pathlib import Path @@ -11,27 +12,42 @@ def get_tasks_file(): def validate_task_file(): """Validate tasks file exists.""" - # NOTE: Validation logic scattered here - should be in utils (refactor bounty) tasks_file = get_tasks_file() if not tasks_file.exists(): return [] return tasks_file -def list_tasks(): - """List all tasks.""" - # NOTE: No --json flag support yet (feature bounty) +def list_tasks(*, json_output=False): + """List all tasks. + + Args: + json_output: If True, output result as JSON instead of plain text. + """ tasks_file = validate_task_file() + if not tasks_file: - print("No tasks yet!") + if json_output: + json.dump({"tasks": [], "count": 0}, sys.stdout) + print() + else: + print("No tasks yet!") return tasks = json.loads(tasks_file.read_text()) if not tasks: - print("No tasks yet!") + if json_output: + json.dump({"tasks": [], "count": 0}, sys.stdout) + print() + else: + print("No tasks yet!") return - for task in tasks: - status = "✓" if task["done"] else " " - print(f"[{status}] {task['id']}. {task['description']}") + if json_output: + json.dump({"tasks": tasks, "count": len(tasks)}, sys.stdout) + print() + else: + for task in tasks: + status = "✓" if task["done"] else " " + print(f"[{status}] {task['id']}. {task['description']}") diff --git a/task.py b/task.py index 53cc8ed..f092030 100644 --- a/task.py +++ b/task.py @@ -20,6 +20,12 @@ def load_config(): def main(): parser = argparse.ArgumentParser(description="Simple task manager") + parser.add_argument( + "--json", + action="store_true", + default=False, + help="Output in JSON format for scripting and automation", + ) subparsers = parser.add_subparsers(dest="command", help="Command to run") # Add command @@ -35,12 +41,14 @@ def main(): args = parser.parse_args() + json_output = args.json + if args.command == "add": - add_task(args.description) + add_task(args.description, json_output=json_output) elif args.command == "list": - list_tasks() + list_tasks(json_output=json_output) elif args.command == "done": - mark_done(args.task_id) + mark_done(args.task_id, json_output=json_output) else: parser.print_help() diff --git a/test_task.py b/test_task.py index ba98e43..2230fcb 100644 --- a/test_task.py +++ b/test_task.py @@ -1,11 +1,18 @@ -"""Basic tests for task CLI.""" +"""Tests for task CLI.""" import json import pytest +import tempfile from pathlib import Path +from unittest.mock import patch +from io import StringIO + from commands.add import add_task, validate_description -from commands.done import validate_task_id +from commands.list import list_tasks +from commands.done import mark_done, validate_task_id + +# --- Existing tests --- def test_validate_description(): """Test description validation.""" @@ -28,3 +35,88 @@ def test_validate_task_id(): with pytest.raises(ValueError): validate_task_id(tasks, 99) + + +# --- JSON output tests --- + +@pytest.fixture +def tmp_tasks_dir(tmp_path): + """Create a temporary tasks directory and patch get_tasks_file.""" + tasks_file = tmp_path / "tasks.json" + + def _get_tasks_file(): + return tasks_file + + with patch("commands.add.get_tasks_file", _get_tasks_file), \ + patch("commands.list.get_tasks_file", _get_tasks_file), \ + patch("commands.done.get_tasks_file", _get_tasks_file): + yield tasks_file + + +class TestAddJsonOutput: + """Test add command with --json flag.""" + + def test_add_json_output(self, tmp_tasks_dir, capsys): + add_task("Test task", json_output=True) + output = capsys.readouterr().out + data = json.loads(output) + assert data["status"] == "added" + assert data["task"]["id"] == 1 + assert data["task"]["description"] == "Test task" + assert data["task"]["done"] is False + + def test_add_plain_output(self, tmp_tasks_dir, capsys): + add_task("Test task", json_output=False) + output = capsys.readouterr().out + assert "Added task 1: Test task" in output + + +class TestListJsonOutput: + """Test list command with --json flag.""" + + def test_list_empty_json(self, tmp_tasks_dir, capsys): + list_tasks(json_output=True) + output = capsys.readouterr().out + data = json.loads(output) + assert data["tasks"] == [] + assert data["count"] == 0 + + def test_list_with_tasks_json(self, tmp_tasks_dir, capsys): + add_task("Task one", json_output=False) + add_task("Task two", json_output=False) + capsys.readouterr() # clear add output + + list_tasks(json_output=True) + output = capsys.readouterr().out + data = json.loads(output) + assert data["count"] == 2 + assert len(data["tasks"]) == 2 + assert data["tasks"][0]["description"] == "Task one" + + def test_list_plain_output(self, tmp_tasks_dir, capsys): + add_task("My task", json_output=False) + capsys.readouterr() + + list_tasks(json_output=False) + output = capsys.readouterr().out + assert "My task" in output + + +class TestDoneJsonOutput: + """Test done command with --json flag.""" + + def test_done_json_output(self, tmp_tasks_dir, capsys): + add_task("Finish this", json_output=False) + capsys.readouterr() + + mark_done(1, json_output=True) + output = capsys.readouterr().out + data = json.loads(output) + assert data["status"] == "done" + assert data["task"]["done"] is True + + def test_done_no_tasks_json(self, tmp_tasks_dir, capsys): + mark_done(1, json_output=True) + output = capsys.readouterr().out + data = json.loads(output) + assert data["status"] == "error"