From e3e94ee5b6f3fb07253f0bd42d565758d64a5ec2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 05:33:55 +0000 Subject: [PATCH] feat: Add claude-task-master - improved task manager for Claude Desktop and Claude Code A Python-based task management system with: - Full MCP server integration for Claude Desktop (36+ tools) - Comprehensive CLI for Claude Code integration - SQLite-backed persistent storage - Cross-platform support (macOS, Linux, Windows) - PRD parsing and dependency-aware task graphs - Project organization with directory detection - Rich terminal output with progress tracking Key improvements over task-master: - Python-based for easier extensibility - Async-first architecture - Pydantic models for type safety - Simpler installation via pip - Native cross-platform configuration --- claude-task-master/LICENSE | 21 + claude-task-master/README.md | 339 ++++++++ claude-task-master/pyproject.toml | 90 +++ claude-task-master/taskmaster/__init__.py | 15 + claude-task-master/taskmaster/cli/__init__.py | 5 + claude-task-master/taskmaster/cli/main.py | 684 ++++++++++++++++ .../taskmaster/config/__init__.py | 5 + .../taskmaster/config/settings.py | 312 ++++++++ .../taskmaster/core/__init__.py | 15 + .../taskmaster/core/database.py | 737 +++++++++++++++++ claude-task-master/taskmaster/core/manager.py | 557 +++++++++++++ claude-task-master/taskmaster/core/models.py | 383 +++++++++ claude-task-master/taskmaster/mcp/__init__.py | 5 + claude-task-master/taskmaster/mcp/server.py | 745 ++++++++++++++++++ claude-task-master/tests/__init__.py | 1 + claude-task-master/tests/test_core.py | 386 +++++++++ 16 files changed, 4300 insertions(+) create mode 100644 claude-task-master/LICENSE create mode 100644 claude-task-master/README.md create mode 100644 claude-task-master/pyproject.toml create mode 100644 claude-task-master/taskmaster/__init__.py create mode 100644 claude-task-master/taskmaster/cli/__init__.py create mode 100644 claude-task-master/taskmaster/cli/main.py create mode 100644 claude-task-master/taskmaster/config/__init__.py create mode 100644 claude-task-master/taskmaster/config/settings.py create mode 100644 claude-task-master/taskmaster/core/__init__.py create mode 100644 claude-task-master/taskmaster/core/database.py create mode 100644 claude-task-master/taskmaster/core/manager.py create mode 100644 claude-task-master/taskmaster/core/models.py create mode 100644 claude-task-master/taskmaster/mcp/__init__.py create mode 100644 claude-task-master/taskmaster/mcp/server.py create mode 100644 claude-task-master/tests/__init__.py create mode 100644 claude-task-master/tests/test_core.py diff --git a/claude-task-master/LICENSE b/claude-task-master/LICENSE new file mode 100644 index 0000000000..ba09705aee --- /dev/null +++ b/claude-task-master/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Claude Task Master Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/claude-task-master/README.md b/claude-task-master/README.md new file mode 100644 index 0000000000..0099b3e3bb --- /dev/null +++ b/claude-task-master/README.md @@ -0,0 +1,339 @@ +# Claude Task Master + +A powerful, Python-based task management system designed for seamless integration with **Claude Desktop** and **Claude Code**. Built as an improved alternative to task-master with cross-platform support for macOS, Linux, and mobile (via REST API). + +## Features + +### Core Capabilities +- **Task Management**: Full CRUD operations with priorities, due dates, tags, and subtasks +- **Dependency Tracking**: Define task dependencies with automatic topological ordering +- **Project Organization**: Group tasks by project with directory-based detection +- **PRD Parsing**: Convert Product Requirement Documents into structured task lists + +### Claude Integration +- **MCP Server**: Model Context Protocol server for Claude Desktop integration +- **CLI**: Comprehensive command-line interface for Claude Code +- **36+ Tools**: Rich set of tools exposed via MCP for AI-driven task management + +### Cross-Platform +- **macOS**: Full support with native configuration paths +- **Linux**: XDG-compliant configuration and data directories +- **Windows**: AppData-based paths +- **Mobile**: REST API server (coming soon) + +## Installation + +### From PyPI (recommended) +```bash +pip install claude-task-master +``` + +### From Source +```bash +git clone https://github.com/claude-task-master/claude-task-master +cd claude-task-master +pip install -e . +``` + +### With Development Dependencies +```bash +pip install -e ".[dev]" +``` + +## Quick Start + +### Initialize a Project +```bash +# Initialize .taskmaster directory in your project +ctm init + +# Or specify a directory +ctm init --dir /path/to/project +``` + +### Create Tasks +```bash +# Simple task +ctm add "Implement user authentication" + +# Task with details +ctm add "Fix login bug" -p high --due 2025-02-01 -t bug -t auth + +# Task with subtasks +ctm add "Build API endpoints" -s "Create routes" -s "Add validation" -s "Write tests" +``` + +### Manage Tasks +```bash +# List all active tasks +ctm list + +# Filter by status and priority +ctm list -s in_progress -p critical -p high + +# Search tasks +ctm list -q "authentication" + +# Show task details +ctm show + +# Get next task to work on +ctm next +``` + +### Update Task Status +```bash +# Start working on a task +ctm start + +# Mark as completed +ctm done + +# Block with reason +ctm block -r "Waiting for API spec" +``` + +### Parse PRD Files +```bash +# Parse a PRD and create tasks +ctm parse-prd requirements.md --project myproject + +# Dry run to see what would be created +ctm parse-prd requirements.md --dry-run +``` + +### Export Tasks +```bash +# Export as JSON +ctm export -f json -o tasks.json + +# Export as Markdown +ctm export -f markdown -o TASKS.md + +# Export as CSV +ctm export -f csv -o tasks.csv +``` + +## Claude Desktop Integration + +### Automatic Setup +```bash +ctm setup-claude +``` + +This will configure Claude Desktop to use the task manager MCP server. + +### Manual Configuration + +Add to your `claude_desktop_config.json`: + +**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +**Linux**: `~/.config/Claude/claude_desktop_config.json` + +```json +{ + "mcpServers": { + "claude-task-master": { + "command": "taskmaster-mcp", + "args": [], + "env": {} + } + } +} +``` + +Restart Claude Desktop after updating the configuration. + +### Available MCP Tools + +Once configured, Claude Desktop can use these tools: + +#### Task Management +- `task_create` - Create new tasks +- `task_get` - Get task by ID +- `task_list` - List and filter tasks +- `task_update` - Update task properties +- `task_delete` - Delete tasks +- `task_start` - Mark task as in progress +- `task_complete` - Mark task as completed +- `task_block` - Mark task as blocked +- `task_next` - Get next task to work on + +#### Subtasks +- `subtask_add` - Add subtask to a task +- `subtask_complete` - Complete a subtask + +#### Tags & Dependencies +- `task_add_tag` / `task_remove_tag` - Manage tags +- `task_add_dependency` / `task_remove_dependency` - Manage dependencies + +#### Projects +- `project_create` - Create new project +- `project_list` - List all projects +- `project_get` - Get project details +- `project_init` - Initialize .taskmaster directory + +#### PRD & Analysis +- `prd_parse` - Parse PRD into tasks +- `task_expand` - Expand task with subtasks +- `stats_get` - Get task statistics +- `tasks_overdue` - Get overdue tasks +- `tasks_due_soon` - Get upcoming tasks +- `dependency_graph` - Get dependency graph +- `topological_order` - Get tasks in dependency order + +#### Bulk Operations +- `tasks_bulk_complete` - Complete multiple tasks +- `tasks_bulk_tag` - Tag multiple tasks + +## CLI Reference + +### Commands + +| Command | Description | +|---------|-------------| +| `ctm add ` | Create a new task | +| `ctm list` | List tasks with filters | +| `ctm show <id>` | Show task details | +| `ctm edit <id>` | Edit task properties | +| `ctm delete <id>` | Delete a task | +| `ctm start <id>` | Start working on task | +| `ctm done <id>` | Complete a task | +| `ctm block <id>` | Block a task | +| `ctm next` | Get next task | +| `ctm tag <id> <tags>` | Add tags | +| `ctm untag <id> <tags>` | Remove tags | +| `ctm subtask <id> <title>` | Add subtask | +| `ctm project create <name>` | Create project | +| `ctm project list` | List projects | +| `ctm parse-prd <file>` | Parse PRD file | +| `ctm stats` | Show statistics | +| `ctm export` | Export tasks | +| `ctm init` | Initialize project | +| `ctm setup-claude` | Configure Claude Desktop | + +### Options + +#### Global Options +- `--version` - Show version +- `--help` - Show help + +#### Task Options +- `-p, --priority` - Priority (critical, high, medium, low, backlog) +- `-d, --description` - Description +- `--due` - Due date (YYYY-MM-DD) +- `-t, --tag` - Tags (multiple) +- `-s, --subtask` - Subtasks (multiple) +- `--project` - Project ID or name + +#### List Options +- `-s, --status` - Filter by status +- `-p, --priority` - Filter by priority +- `-t, --tag` - Filter by tag +- `-q, --search` - Search query +- `-a, --all` - Include completed +- `-n, --limit` - Limit results + +## Configuration + +Configuration is stored in platform-specific locations: + +- **macOS**: `~/Library/Application Support/claude-task-master/config.toml` +- **Linux**: `~/.config/claude-task-master/config.toml` +- **Windows**: `%APPDATA%/claude-task-master/config.toml` + +### Example Configuration + +```toml +enable_research = true +enable_auto_expand = true +enable_smart_scheduling = true +auto_detect_project = true + +[ai] +provider = "anthropic" +model = "claude-3-5-sonnet-20241022" +temperature = 0.7 + +[mcp] +enabled = true +host = "127.0.0.1" +port = 8765 +tools_mode = "all" + +[cli] +default_priority = "medium" +color_output = true +date_format = "%Y-%m-%d" +``` + +### Environment Variables + +- `CTM_CONFIG_DIR` - Override config directory +- `CTM_DATA_DIR` - Override data directory +- `ANTHROPIC_API_KEY` - API key for AI features + +## Project Structure + +``` +.taskmaster/ +├── config.toml # Project-specific settings +├── docs/ # PRD and documentation +└── templates/ # PRD templates + └── prd_template.md +``` + +## Data Storage + +Tasks are stored in SQLite database: + +- **macOS**: `~/Library/Application Support/claude-task-master/tasks.db` +- **Linux**: `~/.local/share/claude-task-master/tasks.db` +- **Windows**: `%LOCALAPPDATA%/claude-task-master/tasks.db` + +## Comparison with task-master + +| Feature | task-master | claude-task-master | +|---------|-------------|-------------------| +| Language | JavaScript/TypeScript | Python | +| MCP Server | Yes | Yes | +| CLI | Yes | Yes (with rich output) | +| Cross-platform | Limited | Full (macOS, Linux, Windows) | +| PRD Parsing | Yes | Yes | +| Dependency Graph | Yes | Yes | +| SQLite Backend | No | Yes | +| Async Support | Limited | Full | +| Type Safety | TypeScript | Pydantic models | +| Package Size | Larger | Smaller | + +## Development + +### Running Tests +```bash +pytest tests/ -v +``` + +### Type Checking +```bash +mypy taskmaster/ +``` + +### Linting +```bash +ruff check taskmaster/ +black taskmaster/ +``` + +## License + +MIT License - See [LICENSE](LICENSE) for details. + +## Contributing + +Contributions welcome! Please read our contributing guidelines and submit pull requests. + +## Related Projects + +- [task-master](https://github.com/eyaltoledano/claude-task-master) - Original JavaScript implementation +- [MCP](https://github.com/modelcontextprotocol/mcp) - Model Context Protocol specification +- [Claude Desktop](https://claude.ai/desktop) - Anthropic's desktop application diff --git a/claude-task-master/pyproject.toml b/claude-task-master/pyproject.toml new file mode 100644 index 0000000000..8fa07657d8 --- /dev/null +++ b/claude-task-master/pyproject.toml @@ -0,0 +1,90 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "claude-task-master" +version = "1.0.0" +description = "A powerful task manager that integrates with Claude Desktop and Claude Code" +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +authors = [ + { name = "Claude Task Master Contributors" } +] +keywords = [ + "task-manager", + "claude", + "mcp", + "productivity", + "cli", + "ai-assistant" +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities", +] +dependencies = [ + "click>=8.1.0", + "rich>=13.0.0", + "pydantic>=2.0.0", + "aiosqlite>=0.19.0", + "mcp>=1.0.0", + "platformdirs>=4.0.0", + "python-dateutil>=2.8.0", + "toml>=0.10.0", + "httpx>=0.25.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.0.0", + "black>=23.0.0", + "ruff>=0.1.0", + "mypy>=1.0.0", +] +sync = [ + "websockets>=12.0", +] + +[project.scripts] +ctm = "taskmaster.cli.main:cli" +claude-task-master = "taskmaster.cli.main:cli" +taskmaster-mcp = "taskmaster.mcp.server:main" + +[project.urls] +Homepage = "https://github.com/claude-task-master/claude-task-master" +Documentation = "https://github.com/claude-task-master/claude-task-master#readme" +Repository = "https://github.com/claude-task-master/claude-task-master" + +[tool.hatch.build.targets.wheel] +packages = ["taskmaster"] + +[tool.black] +line-length = 100 +target-version = ["py310", "py311", "py312"] + +[tool.ruff] +line-length = 100 +select = ["E", "F", "I", "N", "W", "UP"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.10" +strict = true +ignore_missing_imports = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/claude-task-master/taskmaster/__init__.py b/claude-task-master/taskmaster/__init__.py new file mode 100644 index 0000000000..8642cba4ba --- /dev/null +++ b/claude-task-master/taskmaster/__init__.py @@ -0,0 +1,15 @@ +"""Claude Task Master - A powerful task manager for Claude Desktop and Claude Code.""" + +__version__ = "1.0.0" +__author__ = "Claude Task Master Contributors" + +from taskmaster.core.models import Task, TaskStatus, TaskPriority, Project +from taskmaster.core.manager import TaskManager + +__all__ = [ + "Task", + "TaskStatus", + "TaskPriority", + "Project", + "TaskManager", +] diff --git a/claude-task-master/taskmaster/cli/__init__.py b/claude-task-master/taskmaster/cli/__init__.py new file mode 100644 index 0000000000..555496399a --- /dev/null +++ b/claude-task-master/taskmaster/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI interface for Claude Task Master.""" + +from taskmaster.cli.main import cli + +__all__ = ["cli"] diff --git a/claude-task-master/taskmaster/cli/main.py b/claude-task-master/taskmaster/cli/main.py new file mode 100644 index 0000000000..25aba9d342 --- /dev/null +++ b/claude-task-master/taskmaster/cli/main.py @@ -0,0 +1,684 @@ +"""CLI interface for Claude Task Master. + +Provides a comprehensive command-line interface for task management +that integrates seamlessly with Claude Code. +""" + +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path +from typing import Optional + +import click +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from rich.tree import Tree + +from taskmaster.core.manager import SyncTaskManager +from taskmaster.core.models import Task, TaskPriority, TaskStatus +from taskmaster.config.settings import ( + get_settings, + init_project, + get_claude_desktop_config_path, + generate_claude_desktop_config, +) + +console = Console() + + +def get_manager() -> SyncTaskManager: + """Get a task manager instance.""" + settings = get_settings() + return SyncTaskManager(settings.db_path) + + +def format_status(status: TaskStatus) -> Text: + """Format status with color.""" + colors = { + TaskStatus.PENDING: "yellow", + TaskStatus.IN_PROGRESS: "blue", + TaskStatus.BLOCKED: "red", + TaskStatus.REVIEW: "magenta", + TaskStatus.COMPLETED: "green", + TaskStatus.CANCELLED: "dim", + } + return Text(status.value, style=colors.get(status, "white")) + + +def format_priority(priority: TaskPriority) -> Text: + """Format priority with color.""" + colors = { + TaskPriority.CRITICAL: "red bold", + TaskPriority.HIGH: "orange1", + TaskPriority.MEDIUM: "yellow", + TaskPriority.LOW: "green", + TaskPriority.BACKLOG: "dim", + } + return Text(priority.value, style=colors.get(priority, "white")) + + +def format_task_row(task: Task) -> list: + """Format a task for table display.""" + # Short ID + short_id = task.id[:8] + + # Due date + due_str = "" + if task.due_date: + due_str = task.due_date.strftime("%Y-%m-%d") + if task.is_overdue: + due_str = f"[red]{due_str}[/red]" + + # Tags + tags_str = ", ".join(task.tags[:3]) + if len(task.tags) > 3: + tags_str += f" +{len(task.tags) - 3}" + + # Progress + progress = "" + if task.subtasks: + progress = f"{task.progress_percent}%" + + return [short_id, task.title[:50], task.status.value, task.priority.value, due_str, tags_str, progress] + + +def print_task_detail(task: Task) -> None: + """Print detailed task information.""" + # Header + status_text = format_status(task.status) + priority_text = format_priority(task.priority) + + title = Text(task.title, style="bold") + console.print(Panel(title, title=f"Task {task.id[:8]}", subtitle=f"{task.status.value} | {task.priority.value}")) + + # Details table + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("Field", style="dim") + table.add_column("Value") + + table.add_row("ID", task.id) + table.add_row("Status", status_text) + table.add_row("Priority", priority_text) + + if task.description: + table.add_row("Description", task.description) + + if task.due_date: + due_text = task.due_date.strftime("%Y-%m-%d %H:%M") + if task.is_overdue: + due_text = Text(due_text + " (OVERDUE)", style="red bold") + table.add_row("Due Date", due_text) + + if task.project_id: + table.add_row("Project", task.project_id) + + if task.tags: + table.add_row("Tags", ", ".join(task.tags)) + + if task.dependencies: + table.add_row("Depends On", ", ".join(d[:8] for d in task.dependencies)) + + table.add_row("Created", task.created_at.strftime("%Y-%m-%d %H:%M")) + table.add_row("Updated", task.updated_at.strftime("%Y-%m-%d %H:%M")) + + if task.started_at: + table.add_row("Started", task.started_at.strftime("%Y-%m-%d %H:%M")) + + if task.completed_at: + table.add_row("Completed", task.completed_at.strftime("%Y-%m-%d %H:%M")) + + console.print(table) + + # Subtasks + if task.subtasks: + console.print("\n[bold]Subtasks:[/bold]") + for st in task.subtasks: + status_char = "✓" if st.status == TaskStatus.COMPLETED else "○" + style = "dim" if st.status == TaskStatus.COMPLETED else "" + console.print(f" {status_char} {st.title}", style=style) + + # Notes + if task.notes: + console.print(f"\n[bold]Notes:[/bold]\n{task.notes}") + + +@click.group() +@click.version_option(version="1.0.0", prog_name="claude-task-master") +def cli(): + """Claude Task Master - AI-powered task management for Claude Desktop and Claude Code.""" + pass + + +# Task commands +@cli.command("add") +@click.argument("title") +@click.option("-d", "--description", help="Task description") +@click.option( + "-p", "--priority", + type=click.Choice(["critical", "high", "medium", "low", "backlog"]), + default="medium", + help="Task priority", +) +@click.option("--due", help="Due date (YYYY-MM-DD)") +@click.option("-t", "--tag", multiple=True, help="Tags (can be used multiple times)") +@click.option("--project", help="Project ID or name") +@click.option("-s", "--subtask", multiple=True, help="Subtasks (can be used multiple times)") +def add_task(title: str, description: str, priority: str, due: str, tag: tuple, project: str, subtask: tuple): + """Create a new task.""" + manager = get_manager() + + due_date = None + if due: + try: + due_date = datetime.fromisoformat(due) + except ValueError: + console.print(f"[red]Invalid date format: {due}. Use YYYY-MM-DD[/red]") + sys.exit(1) + + task = manager.create_task( + title=title, + description=description, + priority=TaskPriority(priority), + due_date=due_date, + project_id=project, + tags=list(tag) if tag else None, + subtasks=list(subtask) if subtask else None, + source="cli", + ) + + console.print(f"[green]Created task:[/green] {task.id[:8]} - {task.title}") + manager.close() + + +@cli.command("list") +@click.option( + "-s", "--status", + type=click.Choice(["pending", "in_progress", "blocked", "review", "completed", "cancelled"]), + multiple=True, + help="Filter by status", +) +@click.option( + "-p", "--priority", + type=click.Choice(["critical", "high", "medium", "low", "backlog"]), + multiple=True, + help="Filter by priority", +) +@click.option("--project", help="Filter by project") +@click.option("-t", "--tag", multiple=True, help="Filter by tag") +@click.option("-q", "--search", help="Search in title and description") +@click.option("-a", "--all", "show_all", is_flag=True, help="Include completed tasks") +@click.option("-n", "--limit", type=int, help="Limit number of results") +def list_tasks(status: tuple, priority: tuple, project: str, tag: tuple, search: str, show_all: bool, limit: int): + """List tasks with optional filters.""" + manager = get_manager() + + status_list = [TaskStatus(s) for s in status] if status else None + priority_list = [TaskPriority(p) for p in priority] if priority else None + + tasks = manager.list_tasks( + status=status_list, + priority=priority_list, + project_id=project, + tags=list(tag) if tag else None, + search=search, + include_completed=show_all, + limit=limit, + ) + + if not tasks: + console.print("[yellow]No tasks found.[/yellow]") + manager.close() + return + + table = Table(title=f"Tasks ({len(tasks)})") + table.add_column("ID", style="dim", width=8) + table.add_column("Title", min_width=20) + table.add_column("Status") + table.add_column("Priority") + table.add_column("Due") + table.add_column("Tags") + table.add_column("Progress") + + for task in tasks: + row = format_task_row(task) + table.add_row(*row) + + console.print(table) + manager.close() + + +@cli.command("show") +@click.argument("task_id") +def show_task(task_id: str): + """Show detailed task information.""" + manager = get_manager() + task = manager.get_task(task_id) + + if not task: + console.print(f"[red]Task not found: {task_id}[/red]") + sys.exit(1) + + print_task_detail(task) + manager.close() + + +@cli.command("edit") +@click.argument("task_id") +@click.option("--title", help="New title") +@click.option("-d", "--description", help="New description") +@click.option( + "-p", "--priority", + type=click.Choice(["critical", "high", "medium", "low", "backlog"]), + help="New priority", +) +@click.option("--due", help="New due date (YYYY-MM-DD)") +@click.option("--notes", help="Add notes") +def edit_task(task_id: str, title: str, description: str, priority: str, due: str, notes: str): + """Edit a task's properties.""" + manager = get_manager() + task = manager.get_task(task_id) + + if not task: + console.print(f"[red]Task not found: {task_id}[/red]") + sys.exit(1) + + if title: + task.title = title + if description: + task.description = description + if priority: + task.priority = TaskPriority(priority) + if due: + try: + task.due_date = datetime.fromisoformat(due) + except ValueError: + console.print(f"[red]Invalid date format: {due}[/red]") + sys.exit(1) + if notes: + task.notes = notes + + manager.update_task(task) + console.print(f"[green]Updated task:[/green] {task.id[:8]}") + manager.close() + + +@cli.command("delete") +@click.argument("task_id") +@click.option("-y", "--yes", is_flag=True, help="Skip confirmation") +def delete_task(task_id: str, yes: bool): + """Delete a task.""" + manager = get_manager() + task = manager.get_task(task_id) + + if not task: + console.print(f"[red]Task not found: {task_id}[/red]") + sys.exit(1) + + if not yes: + if not click.confirm(f"Delete task '{task.title}'?"): + console.print("Cancelled.") + manager.close() + return + + manager.delete_task(task_id) + console.print(f"[green]Deleted task:[/green] {task_id[:8]}") + manager.close() + + +# Status commands +@cli.command("start") +@click.argument("task_id") +def start_task(task_id: str): + """Start working on a task.""" + manager = get_manager() + task = manager.start_task(task_id) + + if not task: + console.print(f"[red]Task not found: {task_id}[/red]") + sys.exit(1) + + console.print(f"[blue]Started:[/blue] {task.title}") + manager.close() + + +@cli.command("done") +@click.argument("task_id") +def complete_task(task_id: str): + """Mark a task as completed.""" + manager = get_manager() + task = manager.complete_task(task_id) + + if not task: + console.print(f"[red]Task not found: {task_id}[/red]") + sys.exit(1) + + console.print(f"[green]Completed:[/green] {task.title}") + manager.close() + + +@cli.command("block") +@click.argument("task_id") +@click.option("-r", "--reason", help="Reason for blocking") +def block_task(task_id: str, reason: str): + """Mark a task as blocked.""" + manager = get_manager() + task = manager.get_task(task_id) + + if not task: + console.print(f"[red]Task not found: {task_id}[/red]") + sys.exit(1) + + task.block(reason) + manager.update_task(task) + console.print(f"[red]Blocked:[/red] {task.title}") + manager.close() + + +@cli.command("next") +@click.option("--project", help="Filter by project") +def next_task(project: str): + """Get the next task to work on.""" + manager = get_manager() + task = manager.get_next_task(project) + + if not task: + console.print("[yellow]No tasks available to work on.[/yellow]") + manager.close() + return + + console.print("[bold]Next task:[/bold]") + print_task_detail(task) + manager.close() + + +# Tag commands +@cli.command("tag") +@click.argument("task_id") +@click.argument("tags", nargs=-1) +def add_tags(task_id: str, tags: tuple): + """Add tags to a task.""" + manager = get_manager() + task = manager.get_task(task_id) + + if not task: + console.print(f"[red]Task not found: {task_id}[/red]") + sys.exit(1) + + for tag in tags: + task.add_tag(tag) + + manager.update_task(task) + console.print(f"[green]Added tags:[/green] {', '.join(tags)}") + manager.close() + + +@cli.command("untag") +@click.argument("task_id") +@click.argument("tags", nargs=-1) +def remove_tags(task_id: str, tags: tuple): + """Remove tags from a task.""" + manager = get_manager() + task = manager.get_task(task_id) + + if not task: + console.print(f"[red]Task not found: {task_id}[/red]") + sys.exit(1) + + for tag in tags: + task.remove_tag(tag) + + manager.update_task(task) + console.print(f"[yellow]Removed tags:[/yellow] {', '.join(tags)}") + manager.close() + + +# Subtask commands +@cli.command("subtask") +@click.argument("task_id") +@click.argument("title") +@click.option("-d", "--description", help="Subtask description") +def add_subtask(task_id: str, title: str, description: str): + """Add a subtask to a task.""" + manager = get_manager() + task = manager.get_task(task_id) + + if not task: + console.print(f"[red]Task not found: {task_id}[/red]") + sys.exit(1) + + subtask = task.add_subtask(title, description) + manager.update_task(task) + console.print(f"[green]Added subtask:[/green] {subtask.title}") + manager.close() + + +# Project commands +@cli.group("project") +def project_group(): + """Project management commands.""" + pass + + +@project_group.command("create") +@click.argument("name") +@click.option("-d", "--description", help="Project description") +@click.option("--dir", "directory", help="Local directory path") +@click.option("--repo", help="Git repository URL") +def create_project(name: str, description: str, directory: str, repo: str): + """Create a new project.""" + manager = get_manager() + project = manager.create_project( + name=name, + description=description, + directory_path=directory, + repository_url=repo, + ) + console.print(f"[green]Created project:[/green] {project.name}") + manager.close() + + +@project_group.command("list") +@click.option("-a", "--all", "show_all", is_flag=True, help="Include archived projects") +def list_projects(show_all: bool): + """List all projects.""" + manager = get_manager() + projects = manager.list_projects(include_archived=show_all) + + if not projects: + console.print("[yellow]No projects found.[/yellow]") + manager.close() + return + + table = Table(title=f"Projects ({len(projects)})") + table.add_column("ID", style="dim", width=8) + table.add_column("Name") + table.add_column("Description") + table.add_column("Directory") + + for project in projects: + table.add_row( + project.id[:8], + project.name, + project.description or "", + project.directory_path or "", + ) + + console.print(table) + manager.close() + + +# PRD commands +@cli.command("parse-prd") +@click.argument("file", type=click.Path(exists=True)) +@click.option("--project", help="Project to assign tasks to") +@click.option("--dry-run", is_flag=True, help="Don't create tasks, just show what would be created") +def parse_prd(file: str, project: str, dry_run: bool): + """Parse a PRD file into tasks.""" + manager = get_manager() + + content = Path(file).read_text() + tasks = manager.parse_prd(content, project_id=project, create_tasks=not dry_run) + + if dry_run: + console.print("[yellow]Dry run - tasks not created[/yellow]\n") + + console.print(f"[green]Parsed {len(tasks)} tasks from PRD:[/green]\n") + + for task in tasks: + priority_text = format_priority(task.priority) + console.print(f" • {task.title}") + if task.subtasks: + for st in task.subtasks: + console.print(f" - {st.title}") + + manager.close() + + +# Stats command +@cli.command("stats") +def show_stats(): + """Show task statistics.""" + manager = get_manager() + stats = manager.get_statistics() + + console.print(Panel("[bold]Task Statistics[/bold]")) + + # By status + console.print("\n[bold]By Status:[/bold]") + for status, count in stats.get("by_status", {}).items(): + console.print(f" {status}: {count}") + + # By priority + console.print("\n[bold]By Priority:[/bold]") + for priority, count in stats.get("by_priority", {}).items(): + console.print(f" {priority}: {count}") + + # Totals + console.print(f"\n[bold]Total Tasks:[/bold] {stats.get('total_tasks', 0)}") + console.print(f"[bold]Total Projects:[/bold] {stats.get('total_projects', 0)}") + console.print(f"[bold]Overdue Tasks:[/bold] {stats.get('overdue_tasks', 0)}") + + manager.close() + + +# Init command +@cli.command("init") +@click.option("--dir", "directory", default=".", help="Directory to initialize") +def init_command(directory: str): + """Initialize a .taskmaster directory in a project.""" + dir_path = Path(directory).resolve() + settings = init_project(dir_path) + + console.print(f"[green]Initialized .taskmaster in {dir_path}[/green]") + console.print("\nCreated:") + console.print(" .taskmaster/") + console.print(" .taskmaster/config.toml") + console.print(" .taskmaster/docs/") + console.print(" .taskmaster/templates/") + console.print(" .taskmaster/templates/prd_template.md") + + +# Setup command for Claude Desktop +@cli.command("setup-claude") +def setup_claude_desktop(): + """Configure Claude Desktop to use this task manager.""" + import json + + config_path = get_claude_desktop_config_path() + + if not config_path: + console.print("[red]Could not determine Claude Desktop config path for this platform.[/red]") + sys.exit(1) + + config = generate_claude_desktop_config() + + console.print(f"[bold]Claude Desktop Configuration[/bold]\n") + console.print(f"Add the following to: {config_path}\n") + console.print(json.dumps(config, indent=2)) + + if click.confirm("\nWould you like to create/update this configuration automatically?"): + config_path.parent.mkdir(parents=True, exist_ok=True) + + existing = {} + if config_path.exists(): + existing = json.loads(config_path.read_text()) + + # Merge configurations + if "mcpServers" not in existing: + existing["mcpServers"] = {} + existing["mcpServers"].update(config["mcpServers"]) + + config_path.write_text(json.dumps(existing, indent=2)) + console.print(f"\n[green]Updated {config_path}[/green]") + console.print("[yellow]Restart Claude Desktop to apply changes.[/yellow]") + + +# Export command +@cli.command("export") +@click.option("-o", "--output", help="Output file (default: stdout)") +@click.option( + "-f", "--format", + type=click.Choice(["json", "markdown", "csv"]), + default="json", + help="Export format", +) +@click.option("--project", help="Filter by project") +def export_tasks(output: str, format: str, project: str): + """Export tasks to various formats.""" + import csv + import io + + manager = get_manager() + tasks = manager.list_tasks(project_id=project, include_completed=True) + + if format == "json": + import json + result = json.dumps([t.model_dump() for t in tasks], indent=2, default=str) + + elif format == "markdown": + lines = ["# Tasks\n"] + current_status = None + for task in sorted(tasks, key=lambda t: t.status.value): + if task.status != current_status: + current_status = task.status + lines.append(f"\n## {current_status.value.title()}\n") + + checkbox = "x" if task.is_completed else " " + lines.append(f"- [{checkbox}] {task.title}") + if task.subtasks: + for st in task.subtasks: + st_checkbox = "x" if st.status == TaskStatus.COMPLETED else " " + lines.append(f" - [{st_checkbox}] {st.title}") + result = "\n".join(lines) + + elif format == "csv": + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow(["ID", "Title", "Status", "Priority", "Due Date", "Tags", "Project"]) + for task in tasks: + writer.writerow([ + task.id, + task.title, + task.status.value, + task.priority.value, + task.due_date.isoformat() if task.due_date else "", + ",".join(task.tags), + task.project_id or "", + ]) + result = buffer.getvalue() + + if output: + Path(output).write_text(result) + console.print(f"[green]Exported to {output}[/green]") + else: + console.print(result) + + manager.close() + + +if __name__ == "__main__": + cli() diff --git a/claude-task-master/taskmaster/config/__init__.py b/claude-task-master/taskmaster/config/__init__.py new file mode 100644 index 0000000000..eaaa58cb0d --- /dev/null +++ b/claude-task-master/taskmaster/config/__init__.py @@ -0,0 +1,5 @@ +"""Configuration management for Claude Task Master.""" + +from taskmaster.config.settings import Settings, get_settings, get_default_db_path + +__all__ = ["Settings", "get_settings", "get_default_db_path"] diff --git a/claude-task-master/taskmaster/config/settings.py b/claude-task-master/taskmaster/config/settings.py new file mode 100644 index 0000000000..2009003b10 --- /dev/null +++ b/claude-task-master/taskmaster/config/settings.py @@ -0,0 +1,312 @@ +"""Cross-platform configuration and settings management.""" + +from __future__ import annotations + +import os +from functools import lru_cache +from pathlib import Path +from typing import Any + +import toml +from platformdirs import user_config_dir, user_data_dir +from pydantic import BaseModel, Field + + +APP_NAME = "claude-task-master" +APP_AUTHOR = "claude-task-master" + + +def get_config_dir() -> Path: + """Get the configuration directory for the current platform.""" + # Check environment variable first + env_dir = os.environ.get("CTM_CONFIG_DIR") + if env_dir: + return Path(env_dir) + + # Use platformdirs for cross-platform support + return Path(user_config_dir(APP_NAME, APP_AUTHOR)) + + +def get_data_dir() -> Path: + """Get the data directory for the current platform.""" + # Check environment variable first + env_dir = os.environ.get("CTM_DATA_DIR") + if env_dir: + return Path(env_dir) + + # Use platformdirs for cross-platform support + return Path(user_data_dir(APP_NAME, APP_AUTHOR)) + + +def get_default_db_path() -> Path: + """Get the default database path.""" + return get_data_dir() / "tasks.db" + + +def get_config_path() -> Path: + """Get the configuration file path.""" + return get_config_dir() / "config.toml" + + +class AIModelConfig(BaseModel): + """Configuration for AI model integration.""" + + provider: str = "anthropic" # anthropic, openai, google, local + model: str = "claude-3-5-sonnet-20241022" + api_key: str | None = None # Read from env if not set + base_url: str | None = None # For custom endpoints + temperature: float = 0.7 + max_tokens: int = 4096 + + +class MCPConfig(BaseModel): + """Configuration for MCP server.""" + + enabled: bool = True + host: str = "127.0.0.1" + port: int = 8765 + tools_mode: str = "all" # core, standard, all + + +class CLIConfig(BaseModel): + """Configuration for CLI interface.""" + + default_priority: str = "medium" + default_project: str | None = None + color_output: bool = True + date_format: str = "%Y-%m-%d" + time_format: str = "%H:%M" + + +class SyncConfig(BaseModel): + """Configuration for sync capabilities.""" + + enabled: bool = False + sync_url: str | None = None + sync_interval_seconds: int = 300 # 5 minutes + api_key: str | None = None + + +class Settings(BaseModel): + """Main settings model with all configuration options.""" + + # Paths + data_dir: Path = Field(default_factory=get_data_dir) + config_dir: Path = Field(default_factory=get_config_dir) + database_path: Path | None = None # Uses default if not set + + # AI configuration + ai: AIModelConfig = Field(default_factory=AIModelConfig) + + # MCP server configuration + mcp: MCPConfig = Field(default_factory=MCPConfig) + + # CLI configuration + cli: CLIConfig = Field(default_factory=CLIConfig) + + # Sync configuration + sync: SyncConfig = Field(default_factory=SyncConfig) + + # Feature flags + enable_research: bool = True # Enable AI research features + enable_auto_expand: bool = True # Auto-expand complex tasks + enable_smart_scheduling: bool = True # Smart due date suggestions + + # Project detection + auto_detect_project: bool = True # Detect project from current directory + project_markers: list[str] = Field( + default_factory=lambda: [ + ".git", + "package.json", + "pyproject.toml", + "Cargo.toml", + "go.mod", + ".taskmaster", + ] + ) + + @property + def db_path(self) -> Path: + """Get the database path.""" + if self.database_path: + return self.database_path + return self.data_dir / "tasks.db" + + def model_post_init(self, __context: Any) -> None: + """Ensure directories exist after initialization.""" + self.data_dir.mkdir(parents=True, exist_ok=True) + self.config_dir.mkdir(parents=True, exist_ok=True) + + def save(self, path: Path | None = None) -> None: + """Save settings to TOML file.""" + path = path or get_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + + # Convert to dict and handle Path objects + data = self.model_dump() + for key in ["data_dir", "config_dir", "database_path"]: + if data[key]: + data[key] = str(data[key]) + + with open(path, "w") as f: + toml.dump(data, f) + + @classmethod + def load(cls, path: Path | None = None) -> "Settings": + """Load settings from TOML file.""" + path = path or get_config_path() + + if path.exists(): + with open(path) as f: + data = toml.load(f) + + # Convert string paths back to Path objects + for key in ["data_dir", "config_dir", "database_path"]: + if key in data and data[key]: + data[key] = Path(data[key]) + + return cls(**data) + + return cls() + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Get cached settings instance.""" + return Settings.load() + + +def reset_settings_cache() -> None: + """Reset the settings cache.""" + get_settings.cache_clear() + + +# Claude Desktop MCP configuration helper +def generate_claude_desktop_config() -> dict[str, Any]: + """Generate configuration for Claude Desktop's claude_desktop_config.json.""" + return { + "mcpServers": { + "claude-task-master": { + "command": "taskmaster-mcp", + "args": [], + "env": {}, + } + } + } + + +def get_claude_desktop_config_path() -> Path | None: + """Get the Claude Desktop config path for the current platform.""" + import platform + + system = platform.system() + + if system == "Darwin": # macOS + return Path.home() / "Library/Application Support/Claude/claude_desktop_config.json" + elif system == "Linux": + # Check XDG first, then fallback + xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) + return Path(xdg_config) / "Claude/claude_desktop_config.json" + elif system == "Windows": + appdata = os.environ.get("APPDATA", "") + if appdata: + return Path(appdata) / "Claude/claude_desktop_config.json" + + return None + + +# Project-specific settings +class ProjectSettings(BaseModel): + """Per-project settings stored in .taskmaster/config.toml.""" + + project_id: str | None = None + default_tags: list[str] = Field(default_factory=list) + default_priority: str = "medium" + auto_sync: bool = False + + @classmethod + def load_from_directory(cls, directory: Path) -> "ProjectSettings | None": + """Load project settings from a directory.""" + config_path = directory / ".taskmaster" / "config.toml" + if config_path.exists(): + with open(config_path) as f: + data = toml.load(f) + return cls(**data) + return None + + def save_to_directory(self, directory: Path) -> None: + """Save project settings to a directory.""" + taskmaster_dir = directory / ".taskmaster" + taskmaster_dir.mkdir(exist_ok=True) + config_path = taskmaster_dir / "config.toml" + with open(config_path, "w") as f: + toml.dump(self.model_dump(), f) + + +def init_project(directory: Path, project_name: str | None = None) -> ProjectSettings: + """Initialize a .taskmaster directory in the given path.""" + taskmaster_dir = directory / ".taskmaster" + taskmaster_dir.mkdir(exist_ok=True) + + # Create subdirectories + (taskmaster_dir / "docs").mkdir(exist_ok=True) + (taskmaster_dir / "templates").mkdir(exist_ok=True) + + # Create default config + settings = ProjectSettings() + settings.save_to_directory(directory) + + # Create a default PRD template + template_path = taskmaster_dir / "templates" / "prd_template.md" + if not template_path.exists(): + template_path.write_text(DEFAULT_PRD_TEMPLATE) + + return settings + + +DEFAULT_PRD_TEMPLATE = """# Product Requirements Document + +## Overview + +Brief description of the feature or project. + +## Goals + +- Goal 1 +- Goal 2 +- Goal 3 + +## Requirements + +### Functional Requirements + +- [ ] Requirement 1 +- [ ] Requirement 2 + +### Non-Functional Requirements + +- [ ] Performance requirement +- [ ] Security requirement + +## Tasks + +### Phase 1: Foundation + +- [ ] Task 1 (high priority) +- [ ] Task 2 + +### Phase 2: Implementation + +- [ ] Task 3 +- [ ] Task 4 + +### Phase 3: Testing & Polish + +- [ ] Task 5 +- [ ] Task 6 + +## Success Metrics + +- Metric 1 +- Metric 2 +""" diff --git a/claude-task-master/taskmaster/core/__init__.py b/claude-task-master/taskmaster/core/__init__.py new file mode 100644 index 0000000000..a112f68859 --- /dev/null +++ b/claude-task-master/taskmaster/core/__init__.py @@ -0,0 +1,15 @@ +"""Core task management functionality.""" + +from taskmaster.core.models import Task, TaskStatus, TaskPriority, Project, Tag +from taskmaster.core.manager import TaskManager +from taskmaster.core.database import Database + +__all__ = [ + "Task", + "TaskStatus", + "TaskPriority", + "Project", + "Tag", + "TaskManager", + "Database", +] diff --git a/claude-task-master/taskmaster/core/database.py b/claude-task-master/taskmaster/core/database.py new file mode 100644 index 0000000000..035c498a3e --- /dev/null +++ b/claude-task-master/taskmaster/core/database.py @@ -0,0 +1,737 @@ +"""SQLite database layer for persistent task storage.""" + +from __future__ import annotations + +import asyncio +import json +import sqlite3 +from contextlib import asynccontextmanager +from datetime import datetime +from pathlib import Path +from typing import Any, AsyncGenerator + +import aiosqlite + +from taskmaster.core.models import ( + Project, + Subtask, + Tag, + Task, + TaskFilter, + TaskPriority, + TaskSort, + TaskStatus, +) + + +class Database: + """Async SQLite database manager for task persistence.""" + + def __init__(self, db_path: Path | str): + """Initialize database with path.""" + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._connection: aiosqlite.Connection | None = None + self._lock = asyncio.Lock() + + async def connect(self) -> None: + """Connect to the database and initialize schema.""" + self._connection = await aiosqlite.connect(self.db_path) + self._connection.row_factory = aiosqlite.Row + await self._init_schema() + + async def close(self) -> None: + """Close the database connection.""" + if self._connection: + await self._connection.close() + self._connection = None + + @asynccontextmanager + async def connection(self) -> AsyncGenerator[aiosqlite.Connection, None]: + """Get a database connection context.""" + if not self._connection: + await self.connect() + async with self._lock: + yield self._connection # type: ignore + + async def _init_schema(self) -> None: + """Initialize database schema.""" + async with self.connection() as conn: + await conn.executescript(""" + -- Tasks table + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'pending', + priority TEXT NOT NULL DEFAULT 'medium', + project_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + due_date TEXT, + started_at TEXT, + completed_at TEXT, + estimated_hours REAL, + actual_hours REAL, + notes TEXT, + context TEXT, + ai_generated INTEGER DEFAULT 0, + source TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id) + ); + + -- Subtasks table + CREATE TABLE IF NOT EXISTS subtasks ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'pending', + order_idx INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + + -- Tags table + CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + color TEXT DEFAULT '#6366f1', + description TEXT, + created_at TEXT NOT NULL + ); + + -- Task-Tag relationship + CREATE TABLE IF NOT EXISTS task_tags ( + task_id TEXT NOT NULL, + tag_name TEXT NOT NULL, + PRIMARY KEY (task_id, tag_name), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + + -- Task dependencies + CREATE TABLE IF NOT EXISTS task_dependencies ( + task_id TEXT NOT NULL, + depends_on_id TEXT NOT NULL, + dependency_type TEXT DEFAULT 'blocks', + PRIMARY KEY (task_id, depends_on_id), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (depends_on_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + + -- Projects table + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT, + color TEXT DEFAULT '#6366f1', + icon TEXT, + archived INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + repository_url TEXT, + directory_path TEXT + ); + + -- Attachments table + CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + path TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + + -- Create indexes for performance + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); + CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority); + CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id); + CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date); + CREATE INDEX IF NOT EXISTS idx_subtasks_task ON subtasks(task_id); + CREATE INDEX IF NOT EXISTS idx_task_tags_task ON task_tags(task_id); + CREATE INDEX IF NOT EXISTS idx_dependencies_task ON task_dependencies(task_id); + """) + await conn.commit() + + # Task CRUD operations + + async def create_task(self, task: Task) -> Task: + """Create a new task.""" + async with self.connection() as conn: + await conn.execute( + """ + INSERT INTO tasks ( + id, title, description, status, priority, project_id, + created_at, updated_at, due_date, started_at, completed_at, + estimated_hours, actual_hours, notes, context, ai_generated, source + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + task.id, + task.title, + task.description, + task.status.value, + task.priority.value, + task.project_id, + task.created_at.isoformat(), + task.updated_at.isoformat(), + task.due_date.isoformat() if task.due_date else None, + task.started_at.isoformat() if task.started_at else None, + task.completed_at.isoformat() if task.completed_at else None, + task.estimated_hours, + task.actual_hours, + task.notes, + json.dumps(task.context), + 1 if task.ai_generated else 0, + task.source, + ), + ) + + # Insert subtasks + for subtask in task.subtasks: + await self._insert_subtask(conn, task.id, subtask) + + # Insert tags + for tag in task.tags: + await conn.execute( + "INSERT OR IGNORE INTO task_tags (task_id, tag_name) VALUES (?, ?)", + (task.id, tag), + ) + + # Insert dependencies + for dep_id in task.dependencies: + await conn.execute( + "INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_id) VALUES (?, ?)", + (task.id, dep_id), + ) + + await conn.commit() + return task + + async def _insert_subtask( + self, conn: aiosqlite.Connection, task_id: str, subtask: Subtask + ) -> None: + """Insert a subtask.""" + await conn.execute( + """ + INSERT INTO subtasks (id, task_id, title, description, status, order_idx, created_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + subtask.id, + task_id, + subtask.title, + subtask.description, + subtask.status.value, + subtask.order, + subtask.created_at.isoformat(), + subtask.completed_at.isoformat() if subtask.completed_at else None, + ), + ) + + async def get_task(self, task_id: str) -> Task | None: + """Get a task by ID.""" + async with self.connection() as conn: + cursor = await conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)) + row = await cursor.fetchone() + if not row: + return None + return await self._row_to_task(conn, row) + + async def _row_to_task(self, conn: aiosqlite.Connection, row: aiosqlite.Row) -> Task: + """Convert a database row to a Task object.""" + # Get subtasks + cursor = await conn.execute( + "SELECT * FROM subtasks WHERE task_id = ? ORDER BY order_idx", (row["id"],) + ) + subtask_rows = await cursor.fetchall() + subtasks = [ + Subtask( + id=sr["id"], + title=sr["title"], + description=sr["description"], + status=TaskStatus(sr["status"]), + order=sr["order_idx"], + created_at=datetime.fromisoformat(sr["created_at"]), + completed_at=( + datetime.fromisoformat(sr["completed_at"]) if sr["completed_at"] else None + ), + ) + for sr in subtask_rows + ] + + # Get tags + cursor = await conn.execute( + "SELECT tag_name FROM task_tags WHERE task_id = ?", (row["id"],) + ) + tag_rows = await cursor.fetchall() + tags = [tr["tag_name"] for tr in tag_rows] + + # Get dependencies + cursor = await conn.execute( + "SELECT depends_on_id FROM task_dependencies WHERE task_id = ?", (row["id"],) + ) + dep_rows = await cursor.fetchall() + dependencies = [dr["depends_on_id"] for dr in dep_rows] + + # Get blocked_by (reverse dependencies) + cursor = await conn.execute( + "SELECT task_id FROM task_dependencies WHERE depends_on_id = ?", (row["id"],) + ) + blocked_rows = await cursor.fetchall() + blocked_by = [br["task_id"] for br in blocked_rows] + + # Get attachments + cursor = await conn.execute( + "SELECT path FROM attachments WHERE task_id = ?", (row["id"],) + ) + att_rows = await cursor.fetchall() + attachments = [ar["path"] for ar in att_rows] + + return Task( + id=row["id"], + title=row["title"], + description=row["description"], + status=TaskStatus(row["status"]), + priority=TaskPriority(row["priority"]), + project_id=row["project_id"], + created_at=datetime.fromisoformat(row["created_at"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + due_date=datetime.fromisoformat(row["due_date"]) if row["due_date"] else None, + started_at=datetime.fromisoformat(row["started_at"]) if row["started_at"] else None, + completed_at=( + datetime.fromisoformat(row["completed_at"]) if row["completed_at"] else None + ), + estimated_hours=row["estimated_hours"], + actual_hours=row["actual_hours"], + notes=row["notes"], + context=json.loads(row["context"]) if row["context"] else {}, + ai_generated=bool(row["ai_generated"]), + source=row["source"], + subtasks=subtasks, + tags=tags, + dependencies=dependencies, + blocked_by=blocked_by, + attachments=attachments, + ) + + async def update_task(self, task: Task) -> Task: + """Update an existing task.""" + task.updated_at = datetime.utcnow() + async with self.connection() as conn: + await conn.execute( + """ + UPDATE tasks SET + title = ?, description = ?, status = ?, priority = ?, project_id = ?, + updated_at = ?, due_date = ?, started_at = ?, completed_at = ?, + estimated_hours = ?, actual_hours = ?, notes = ?, context = ?, + ai_generated = ?, source = ? + WHERE id = ? + """, + ( + task.title, + task.description, + task.status.value, + task.priority.value, + task.project_id, + task.updated_at.isoformat(), + task.due_date.isoformat() if task.due_date else None, + task.started_at.isoformat() if task.started_at else None, + task.completed_at.isoformat() if task.completed_at else None, + task.estimated_hours, + task.actual_hours, + task.notes, + json.dumps(task.context), + 1 if task.ai_generated else 0, + task.source, + task.id, + ), + ) + + # Update subtasks (delete and re-insert) + await conn.execute("DELETE FROM subtasks WHERE task_id = ?", (task.id,)) + for subtask in task.subtasks: + await self._insert_subtask(conn, task.id, subtask) + + # Update tags + await conn.execute("DELETE FROM task_tags WHERE task_id = ?", (task.id,)) + for tag in task.tags: + await conn.execute( + "INSERT OR IGNORE INTO task_tags (task_id, tag_name) VALUES (?, ?)", + (task.id, tag), + ) + + # Update dependencies + await conn.execute("DELETE FROM task_dependencies WHERE task_id = ?", (task.id,)) + for dep_id in task.dependencies: + await conn.execute( + "INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_id) VALUES (?, ?)", + (task.id, dep_id), + ) + + await conn.commit() + return task + + async def delete_task(self, task_id: str) -> bool: + """Delete a task by ID.""" + async with self.connection() as conn: + cursor = await conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) + await conn.commit() + return cursor.rowcount > 0 + + async def list_tasks( + self, + filter: TaskFilter | None = None, + sort_by: TaskSort = TaskSort.PRIORITY, + ascending: bool = True, + limit: int | None = None, + offset: int = 0, + ) -> list[Task]: + """List tasks with optional filtering and sorting.""" + async with self.connection() as conn: + # Build query + query = "SELECT * FROM tasks WHERE 1=1" + params: list[Any] = [] + + if filter: + if filter.status: + placeholders = ",".join("?" for _ in filter.status) + query += f" AND status IN ({placeholders})" + params.extend(s.value for s in filter.status) + else: + # Default: exclude completed and cancelled + if not filter.include_completed: + query += " AND status != ?" + params.append(TaskStatus.COMPLETED.value) + if not filter.include_cancelled: + query += " AND status != ?" + params.append(TaskStatus.CANCELLED.value) + + if filter.priority: + placeholders = ",".join("?" for _ in filter.priority) + query += f" AND priority IN ({placeholders})" + params.extend(p.value for p in filter.priority) + + if filter.project_id: + query += " AND project_id = ?" + params.append(filter.project_id) + + if filter.due_before: + query += " AND due_date <= ?" + params.append(filter.due_before.isoformat()) + + if filter.due_after: + query += " AND due_date >= ?" + params.append(filter.due_after.isoformat()) + + if filter.created_after: + query += " AND created_at >= ?" + params.append(filter.created_after.isoformat()) + + if filter.search_query: + query += " AND (title LIKE ? OR description LIKE ?)" + search_pattern = f"%{filter.search_query}%" + params.extend([search_pattern, search_pattern]) + + # Sorting + sort_column = { + TaskSort.PRIORITY: "priority", + TaskSort.DUE_DATE: "due_date", + TaskSort.CREATED_AT: "created_at", + TaskSort.UPDATED_AT: "updated_at", + TaskSort.TITLE: "title", + TaskSort.STATUS: "status", + }.get(sort_by, "priority") + + direction = "ASC" if ascending else "DESC" + query += f" ORDER BY {sort_column} {direction}" + + if limit: + query += " LIMIT ?" + params.append(limit) + + if offset: + query += " OFFSET ?" + params.append(offset) + + cursor = await conn.execute(query, params) + rows = await cursor.fetchall() + + tasks = [] + for row in rows: + task = await self._row_to_task(conn, row) + # Additional filter matching for tags (done in Python for simplicity) + if filter and filter.tags: + if not any(tag in task.tags for tag in filter.tags): + continue + tasks.append(task) + + return tasks + + async def count_tasks(self, filter: TaskFilter | None = None) -> int: + """Count tasks matching filter.""" + tasks = await self.list_tasks(filter=filter) + return len(tasks) + + # Project operations + + async def create_project(self, project: Project) -> Project: + """Create a new project.""" + async with self.connection() as conn: + await conn.execute( + """ + INSERT INTO projects ( + id, name, description, color, icon, archived, + created_at, updated_at, repository_url, directory_path + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + project.id, + project.name, + project.description, + project.color, + project.icon, + 1 if project.archived else 0, + project.created_at.isoformat(), + project.updated_at.isoformat(), + project.repository_url, + project.directory_path, + ), + ) + await conn.commit() + return project + + async def get_project(self, project_id: str) -> Project | None: + """Get a project by ID.""" + async with self.connection() as conn: + cursor = await conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)) + row = await cursor.fetchone() + if not row: + return None + return self._row_to_project(row) + + async def get_project_by_name(self, name: str) -> Project | None: + """Get a project by name.""" + async with self.connection() as conn: + cursor = await conn.execute("SELECT * FROM projects WHERE name = ?", (name,)) + row = await cursor.fetchone() + if not row: + return None + return self._row_to_project(row) + + def _row_to_project(self, row: aiosqlite.Row) -> Project: + """Convert a database row to a Project object.""" + return Project( + id=row["id"], + name=row["name"], + description=row["description"], + color=row["color"], + icon=row["icon"], + archived=bool(row["archived"]), + created_at=datetime.fromisoformat(row["created_at"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + repository_url=row["repository_url"], + directory_path=row["directory_path"], + ) + + async def list_projects(self, include_archived: bool = False) -> list[Project]: + """List all projects.""" + async with self.connection() as conn: + query = "SELECT * FROM projects" + if not include_archived: + query += " WHERE archived = 0" + query += " ORDER BY name" + cursor = await conn.execute(query) + rows = await cursor.fetchall() + return [self._row_to_project(row) for row in rows] + + async def update_project(self, project: Project) -> Project: + """Update a project.""" + project.updated_at = datetime.utcnow() + async with self.connection() as conn: + await conn.execute( + """ + UPDATE projects SET + name = ?, description = ?, color = ?, icon = ?, archived = ?, + updated_at = ?, repository_url = ?, directory_path = ? + WHERE id = ? + """, + ( + project.name, + project.description, + project.color, + project.icon, + 1 if project.archived else 0, + project.updated_at.isoformat(), + project.repository_url, + project.directory_path, + project.id, + ), + ) + await conn.commit() + return project + + async def delete_project(self, project_id: str) -> bool: + """Delete a project by ID.""" + async with self.connection() as conn: + cursor = await conn.execute("DELETE FROM projects WHERE id = ?", (project_id,)) + await conn.commit() + return cursor.rowcount > 0 + + # Tag operations + + async def create_tag(self, tag: Tag) -> Tag: + """Create a new tag.""" + async with self.connection() as conn: + await conn.execute( + "INSERT INTO tags (id, name, color, description, created_at) VALUES (?, ?, ?, ?, ?)", + (tag.id, tag.name, tag.color, tag.description, tag.created_at.isoformat()), + ) + await conn.commit() + return tag + + async def list_tags(self) -> list[Tag]: + """List all tags.""" + async with self.connection() as conn: + cursor = await conn.execute("SELECT * FROM tags ORDER BY name") + rows = await cursor.fetchall() + return [ + Tag( + id=row["id"], + name=row["name"], + color=row["color"], + description=row["description"], + created_at=datetime.fromisoformat(row["created_at"]), + ) + for row in rows + ] + + # Statistics + + async def get_statistics(self) -> dict[str, Any]: + """Get task statistics.""" + async with self.connection() as conn: + stats: dict[str, Any] = {} + + # Count by status + cursor = await conn.execute( + "SELECT status, COUNT(*) as count FROM tasks GROUP BY status" + ) + rows = await cursor.fetchall() + stats["by_status"] = {row["status"]: row["count"] for row in rows} + + # Count by priority + cursor = await conn.execute( + "SELECT priority, COUNT(*) as count FROM tasks GROUP BY priority" + ) + rows = await cursor.fetchall() + stats["by_priority"] = {row["priority"]: row["count"] for row in rows} + + # Total counts + cursor = await conn.execute("SELECT COUNT(*) as count FROM tasks") + row = await cursor.fetchone() + stats["total_tasks"] = row["count"] if row else 0 + + cursor = await conn.execute("SELECT COUNT(*) as count FROM projects") + row = await cursor.fetchone() + stats["total_projects"] = row["count"] if row else 0 + + # Overdue tasks + now = datetime.utcnow().isoformat() + cursor = await conn.execute( + """ + SELECT COUNT(*) as count FROM tasks + WHERE due_date < ? AND status NOT IN ('completed', 'cancelled') + """, + (now,), + ) + row = await cursor.fetchone() + stats["overdue_tasks"] = row["count"] if row else 0 + + return stats + + +# Synchronous wrapper for non-async contexts +class SyncDatabase: + """Synchronous wrapper around the async database.""" + + def __init__(self, db_path: Path | str): + """Initialize with database path.""" + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._init_sync() + + def _init_sync(self) -> None: + """Initialize database synchronously.""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + conn.executescript(""" + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'pending', + priority TEXT NOT NULL DEFAULT 'medium', + project_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + due_date TEXT, + started_at TEXT, + completed_at TEXT, + estimated_hours REAL, + actual_hours REAL, + notes TEXT, + context TEXT, + ai_generated INTEGER DEFAULT 0, + source TEXT + ); + CREATE TABLE IF NOT EXISTS subtasks ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'pending', + order_idx INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + completed_at TEXT + ); + CREATE TABLE IF NOT EXISTS task_tags ( + task_id TEXT NOT NULL, + tag_name TEXT NOT NULL, + PRIMARY KEY (task_id, tag_name) + ); + CREATE TABLE IF NOT EXISTS task_dependencies ( + task_id TEXT NOT NULL, + depends_on_id TEXT NOT NULL, + dependency_type TEXT DEFAULT 'blocks', + PRIMARY KEY (task_id, depends_on_id) + ); + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT, + color TEXT DEFAULT '#6366f1', + icon TEXT, + archived INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + repository_url TEXT, + directory_path TEXT + ); + CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + color TEXT DEFAULT '#6366f1', + description TEXT, + created_at TEXT NOT NULL + ); + """) + conn.commit() + conn.close() + + def _get_connection(self) -> sqlite3.Connection: + """Get a new database connection.""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn diff --git a/claude-task-master/taskmaster/core/manager.py b/claude-task-master/taskmaster/core/manager.py new file mode 100644 index 0000000000..5ed2e62e4b --- /dev/null +++ b/claude-task-master/taskmaster/core/manager.py @@ -0,0 +1,557 @@ +"""High-level task manager API.""" + +from __future__ import annotations + +import asyncio +import re +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +from taskmaster.core.database import Database +from taskmaster.core.models import ( + Project, + Subtask, + Task, + TaskFilter, + TaskPriority, + TaskSort, + TaskStatus, +) + + +class TaskManager: + """High-level task management API with intelligent features.""" + + def __init__(self, db_path: Path | str | None = None): + """Initialize task manager with optional database path.""" + if db_path is None: + from taskmaster.config.settings import get_default_db_path + + db_path = get_default_db_path() + self.db = Database(db_path) + self._initialized = False + + async def initialize(self) -> None: + """Initialize the database connection.""" + if not self._initialized: + await self.db.connect() + self._initialized = True + + async def close(self) -> None: + """Close database connection.""" + await self.db.close() + self._initialized = False + + async def __aenter__(self) -> "TaskManager": + """Async context manager entry.""" + await self.initialize() + return self + + async def __aexit__(self, *args: Any) -> None: + """Async context manager exit.""" + await self.close() + + # Task operations + + async def create_task( + self, + title: str, + description: str | None = None, + priority: TaskPriority = TaskPriority.MEDIUM, + due_date: datetime | None = None, + project_id: str | None = None, + tags: list[str] | None = None, + subtasks: list[str] | None = None, + dependencies: list[str] | None = None, + source: str | None = None, + ) -> Task: + """Create a new task with optional details.""" + await self.initialize() + + task = Task( + title=title, + description=description, + priority=priority, + due_date=due_date, + project_id=project_id, + tags=tags or [], + dependencies=dependencies or [], + source=source, + ) + + # Add subtasks if provided + if subtasks: + for st_title in subtasks: + task.add_subtask(st_title) + + return await self.db.create_task(task) + + async def get_task(self, task_id: str) -> Task | None: + """Get a task by ID or partial ID.""" + await self.initialize() + + # Try exact match first + task = await self.db.get_task(task_id) + if task: + return task + + # Try partial ID match + tasks = await self.db.list_tasks(limit=100) + for t in tasks: + if t.id.startswith(task_id): + return t + return None + + async def update_task(self, task: Task) -> Task: + """Update an existing task.""" + await self.initialize() + return await self.db.update_task(task) + + async def delete_task(self, task_id: str) -> bool: + """Delete a task by ID.""" + await self.initialize() + return await self.db.delete_task(task_id) + + async def list_tasks( + self, + status: list[TaskStatus] | None = None, + priority: list[TaskPriority] | None = None, + project_id: str | None = None, + tags: list[str] | None = None, + search: str | None = None, + include_completed: bool = False, + sort_by: TaskSort = TaskSort.PRIORITY, + limit: int | None = None, + ) -> list[Task]: + """List tasks with filtering options.""" + await self.initialize() + + filter = TaskFilter( + status=status, + priority=priority, + project_id=project_id, + tags=tags, + search_query=search, + include_completed=include_completed, + ) + return await self.db.list_tasks(filter=filter, sort_by=sort_by, limit=limit) + + async def get_next_task(self, project_id: str | None = None) -> Task | None: + """Get the next task to work on based on priority and dependencies.""" + await self.initialize() + + # Get all active tasks + tasks = await self.list_tasks( + status=[TaskStatus.PENDING, TaskStatus.IN_PROGRESS], + project_id=project_id, + sort_by=TaskSort.PRIORITY, + ) + + if not tasks: + return None + + # Find first task that's not blocked + for task in tasks: + # Already in progress - return it + if task.status == TaskStatus.IN_PROGRESS: + return task + + # Check if dependencies are satisfied + if task.dependencies: + all_deps_complete = True + for dep_id in task.dependencies: + dep_task = await self.get_task(dep_id) + if dep_task and dep_task.status != TaskStatus.COMPLETED: + all_deps_complete = False + break + if not all_deps_complete: + continue + + return task + + return None + + async def start_task(self, task_id: str) -> Task | None: + """Mark a task as in progress.""" + task = await self.get_task(task_id) + if task: + task.start() + return await self.update_task(task) + return None + + async def complete_task(self, task_id: str) -> Task | None: + """Mark a task as completed.""" + task = await self.get_task(task_id) + if task: + task.complete() + return await self.update_task(task) + return None + + async def block_task(self, task_id: str, reason: str | None = None) -> Task | None: + """Mark a task as blocked.""" + task = await self.get_task(task_id) + if task: + task.block(reason) + return await self.update_task(task) + return None + + # Subtask operations + + async def add_subtask( + self, task_id: str, title: str, description: str | None = None + ) -> Task | None: + """Add a subtask to a task.""" + task = await self.get_task(task_id) + if task: + task.add_subtask(title, description) + return await self.update_task(task) + return None + + async def complete_subtask(self, task_id: str, subtask_id: str) -> Task | None: + """Complete a subtask.""" + task = await self.get_task(task_id) + if task: + for st in task.subtasks: + if st.id == subtask_id or st.id.startswith(subtask_id): + st.complete() + return await self.update_task(task) + return None + + # Project operations + + async def create_project( + self, + name: str, + description: str | None = None, + directory_path: str | None = None, + repository_url: str | None = None, + ) -> Project: + """Create a new project.""" + await self.initialize() + + project = Project( + name=name, + description=description, + directory_path=directory_path, + repository_url=repository_url, + ) + return await self.db.create_project(project) + + async def get_project(self, project_id: str) -> Project | None: + """Get a project by ID or name.""" + await self.initialize() + + # Try ID first + project = await self.db.get_project(project_id) + if project: + return project + + # Try by name + return await self.db.get_project_by_name(project_id) + + async def list_projects(self, include_archived: bool = False) -> list[Project]: + """List all projects.""" + await self.initialize() + return await self.db.list_projects(include_archived=include_archived) + + async def get_project_for_directory(self, directory: Path | str) -> Project | None: + """Find a project associated with a directory.""" + await self.initialize() + directory = Path(directory).resolve() + + projects = await self.list_projects() + for project in projects: + if project.directory_path: + project_dir = Path(project.directory_path).resolve() + # Check if directory is same or child of project directory + try: + directory.relative_to(project_dir) + return project + except ValueError: + continue + return None + + # PRD Parsing + + async def parse_prd( + self, + content: str, + project_id: str | None = None, + create_tasks: bool = True, + ) -> list[Task]: + """Parse a PRD document into tasks.""" + await self.initialize() + + tasks = [] + lines = content.split("\n") + current_section = "" + current_task: Task | None = None + + # Pattern matching for common PRD formats + heading_pattern = re.compile(r"^#{1,3}\s+(.+)$") + task_pattern = re.compile(r"^[-*]\s+\[?\s?\]?\s*(.+)$") + subtask_pattern = re.compile(r"^\s{2,}[-*]\s+\[?\s?\]?\s*(.+)$") + priority_pattern = re.compile(r"\b(critical|high|medium|low|backlog)\b", re.IGNORECASE) + due_pattern = re.compile(r"due:?\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE) + + for line in lines: + line = line.strip() + if not line: + continue + + # Check for section headers + heading_match = heading_pattern.match(line) + if heading_match: + current_section = heading_match.group(1) + continue + + # Check for subtasks (must come before task pattern) + subtask_match = subtask_pattern.match(line) + if subtask_match and current_task: + subtask_title = subtask_match.group(1).strip() + current_task.add_subtask(subtask_title) + continue + + # Check for tasks + task_match = task_pattern.match(line) + if task_match: + if current_task: + tasks.append(current_task) + + task_title = task_match.group(1).strip() + + # Extract priority if mentioned + priority = TaskPriority.MEDIUM + priority_match = priority_pattern.search(task_title) + if priority_match: + priority = TaskPriority(priority_match.group(1).lower()) + task_title = priority_pattern.sub("", task_title).strip() + + # Extract due date if mentioned + due_date = None + due_match = due_pattern.search(task_title) + if due_match: + try: + due_date = datetime.fromisoformat(due_match.group(1)) + except ValueError: + pass + task_title = due_pattern.sub("", task_title).strip() + + # Clean up task title + task_title = re.sub(r"\s+", " ", task_title).strip(" :-") + + current_task = Task( + title=task_title, + description=f"From section: {current_section}" if current_section else None, + priority=priority, + due_date=due_date, + project_id=project_id, + source="prd", + ai_generated=True, + ) + + # Don't forget the last task + if current_task: + tasks.append(current_task) + + # Create tasks in database if requested + if create_tasks: + created_tasks = [] + for task in tasks: + created = await self.db.create_task(task) + created_tasks.append(created) + return created_tasks + + return tasks + + async def expand_task(self, task_id: str, subtask_titles: list[str]) -> Task | None: + """Expand a task by adding multiple subtasks.""" + task = await self.get_task(task_id) + if task: + for title in subtask_titles: + task.add_subtask(title) + return await self.update_task(task) + return None + + # Statistics and insights + + async def get_statistics(self) -> dict[str, Any]: + """Get task statistics.""" + await self.initialize() + return await self.db.get_statistics() + + async def get_overdue_tasks(self) -> list[Task]: + """Get all overdue tasks.""" + await self.initialize() + + filter = TaskFilter( + due_before=datetime.utcnow(), + include_completed=False, + ) + tasks = await self.db.list_tasks(filter=filter) + return [t for t in tasks if t.is_overdue] + + async def get_tasks_due_soon(self, days: int = 7) -> list[Task]: + """Get tasks due within the specified number of days.""" + await self.initialize() + + filter = TaskFilter( + due_before=datetime.utcnow() + timedelta(days=days), + due_after=datetime.utcnow(), + include_completed=False, + ) + return await self.db.list_tasks(filter=filter, sort_by=TaskSort.DUE_DATE) + + async def get_blocked_tasks(self) -> list[Task]: + """Get all blocked tasks.""" + return await self.list_tasks(status=[TaskStatus.BLOCKED]) + + # Dependency graph operations + + async def get_dependency_graph(self, project_id: str | None = None) -> dict[str, list[str]]: + """Get a dependency graph for tasks.""" + tasks = await self.list_tasks(project_id=project_id, include_completed=True) + + graph: dict[str, list[str]] = {} + for task in tasks: + graph[task.id] = task.dependencies + + return graph + + async def get_topological_order(self, project_id: str | None = None) -> list[str]: + """Get tasks in topological order based on dependencies.""" + graph = await self.get_dependency_graph(project_id) + + # Kahn's algorithm for topological sort + in_degree: dict[str, int] = {node: 0 for node in graph} + for deps in graph.values(): + for dep in deps: + if dep in in_degree: + in_degree[dep] = in_degree.get(dep, 0) + 1 + + # Start with nodes that have no dependencies + queue = [node for node, degree in in_degree.items() if degree == 0] + result: list[str] = [] + + while queue: + node = queue.pop(0) + result.append(node) + + for other, deps in graph.items(): + if node in deps: + in_degree[other] -= 1 + if in_degree[other] == 0: + queue.append(other) + + return result + + # Bulk operations + + async def bulk_update_status( + self, task_ids: list[str], status: TaskStatus + ) -> list[Task]: + """Update status for multiple tasks.""" + updated = [] + for task_id in task_ids: + task = await self.get_task(task_id) + if task: + task.status = status + task.updated_at = datetime.utcnow() + if status == TaskStatus.COMPLETED: + task.completed_at = datetime.utcnow() + elif status == TaskStatus.IN_PROGRESS: + task.started_at = datetime.utcnow() + updated.append(await self.update_task(task)) + return updated + + async def bulk_add_tag(self, task_ids: list[str], tag: str) -> list[Task]: + """Add a tag to multiple tasks.""" + updated = [] + for task_id in task_ids: + task = await self.get_task(task_id) + if task: + task.add_tag(tag) + updated.append(await self.update_task(task)) + return updated + + async def move_tasks_to_project( + self, task_ids: list[str], project_id: str + ) -> list[Task]: + """Move multiple tasks to a project.""" + updated = [] + for task_id in task_ids: + task = await self.get_task(task_id) + if task: + task.project_id = project_id + task.updated_at = datetime.utcnow() + updated.append(await self.update_task(task)) + return updated + + +# Synchronous wrapper for use in non-async contexts +class SyncTaskManager: + """Synchronous wrapper around TaskManager for CLI and non-async contexts.""" + + def __init__(self, db_path: Path | str | None = None): + """Initialize with optional database path.""" + self._manager = TaskManager(db_path) + self._loop: asyncio.AbstractEventLoop | None = None + + def _get_loop(self) -> asyncio.AbstractEventLoop: + """Get or create event loop.""" + try: + return asyncio.get_running_loop() + except RuntimeError: + if self._loop is None or self._loop.is_closed(): + self._loop = asyncio.new_event_loop() + return self._loop + + def _run(self, coro: Any) -> Any: + """Run a coroutine synchronously.""" + loop = self._get_loop() + return loop.run_until_complete(coro) + + def create_task(self, *args: Any, **kwargs: Any) -> Task: + return self._run(self._manager.create_task(*args, **kwargs)) + + def get_task(self, task_id: str) -> Task | None: + return self._run(self._manager.get_task(task_id)) + + def update_task(self, task: Task) -> Task: + return self._run(self._manager.update_task(task)) + + def delete_task(self, task_id: str) -> bool: + return self._run(self._manager.delete_task(task_id)) + + def list_tasks(self, *args: Any, **kwargs: Any) -> list[Task]: + return self._run(self._manager.list_tasks(*args, **kwargs)) + + def get_next_task(self, project_id: str | None = None) -> Task | None: + return self._run(self._manager.get_next_task(project_id)) + + def start_task(self, task_id: str) -> Task | None: + return self._run(self._manager.start_task(task_id)) + + def complete_task(self, task_id: str) -> Task | None: + return self._run(self._manager.complete_task(task_id)) + + def create_project(self, *args: Any, **kwargs: Any) -> Project: + return self._run(self._manager.create_project(*args, **kwargs)) + + def list_projects(self, **kwargs: Any) -> list[Project]: + return self._run(self._manager.list_projects(**kwargs)) + + def get_project(self, project_id: str) -> Project | None: + return self._run(self._manager.get_project(project_id)) + + def parse_prd(self, content: str, **kwargs: Any) -> list[Task]: + return self._run(self._manager.parse_prd(content, **kwargs)) + + def get_statistics(self) -> dict[str, Any]: + return self._run(self._manager.get_statistics()) + + def close(self) -> None: + self._run(self._manager.close()) + if self._loop and not self._loop.is_closed(): + self._loop.close() diff --git a/claude-task-master/taskmaster/core/models.py b/claude-task-master/taskmaster/core/models.py new file mode 100644 index 0000000000..91a9dd0101 --- /dev/null +++ b/claude-task-master/taskmaster/core/models.py @@ -0,0 +1,383 @@ +"""Data models for Claude Task Master.""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field, field_validator + + +class TaskStatus(str, Enum): + """Task status enumeration.""" + + PENDING = "pending" + IN_PROGRESS = "in_progress" + BLOCKED = "blocked" + REVIEW = "review" + COMPLETED = "completed" + CANCELLED = "cancelled" + + @classmethod + def active_statuses(cls) -> list[TaskStatus]: + """Return statuses considered active.""" + return [cls.PENDING, cls.IN_PROGRESS, cls.BLOCKED, cls.REVIEW] + + @classmethod + def terminal_statuses(cls) -> list[TaskStatus]: + """Return terminal statuses.""" + return [cls.COMPLETED, cls.CANCELLED] + + +class TaskPriority(str, Enum): + """Task priority levels.""" + + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + BACKLOG = "backlog" + + @property + def sort_order(self) -> int: + """Return numeric sort order (lower = higher priority).""" + order = { + TaskPriority.CRITICAL: 0, + TaskPriority.HIGH: 1, + TaskPriority.MEDIUM: 2, + TaskPriority.LOW: 3, + TaskPriority.BACKLOG: 4, + } + return order[self] + + +class Tag(BaseModel): + """Tag for categorizing tasks.""" + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + color: str = "#6366f1" # Default indigo + description: str | None = None + created_at: datetime = Field(default_factory=datetime.utcnow) + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate tag name.""" + v = v.strip().lower() + if not v: + raise ValueError("Tag name cannot be empty") + if len(v) > 50: + raise ValueError("Tag name must be 50 characters or less") + return v + + +class Subtask(BaseModel): + """A subtask within a parent task.""" + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + title: str + description: str | None = None + status: TaskStatus = TaskStatus.PENDING + order: int = 0 + created_at: datetime = Field(default_factory=datetime.utcnow) + completed_at: datetime | None = None + + def complete(self) -> None: + """Mark subtask as completed.""" + self.status = TaskStatus.COMPLETED + self.completed_at = datetime.utcnow() + + +class TaskDependency(BaseModel): + """Represents a dependency between tasks.""" + + task_id: str + depends_on_id: str + dependency_type: str = "blocks" # blocks, related, parent + + +class Task(BaseModel): + """Main task model with full feature set.""" + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + title: str + description: str | None = None + status: TaskStatus = TaskStatus.PENDING + priority: TaskPriority = TaskPriority.MEDIUM + project_id: str | None = None + + # Time tracking + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + due_date: datetime | None = None + started_at: datetime | None = None + completed_at: datetime | None = None + estimated_hours: float | None = None + actual_hours: float | None = None + + # Organization + tags: list[str] = Field(default_factory=list) + subtasks: list[Subtask] = Field(default_factory=list) + dependencies: list[str] = Field(default_factory=list) # IDs of tasks this depends on + blocked_by: list[str] = Field(default_factory=list) # IDs of tasks blocking this + + # Context and notes + notes: str | None = None + context: dict[str, Any] = Field(default_factory=dict) + attachments: list[str] = Field(default_factory=list) + + # For AI integration + ai_generated: bool = False + source: str | None = None # Where this task came from (e.g., "claude-desktop", "cli") + + @field_validator("title") + @classmethod + def validate_title(cls, v: str) -> str: + """Validate task title.""" + v = v.strip() + if not v: + raise ValueError("Task title cannot be empty") + if len(v) > 500: + raise ValueError("Task title must be 500 characters or less") + return v + + @property + def is_active(self) -> bool: + """Check if task is in an active state.""" + return self.status in TaskStatus.active_statuses() + + @property + def is_completed(self) -> bool: + """Check if task is completed.""" + return self.status == TaskStatus.COMPLETED + + @property + def is_blocked(self) -> bool: + """Check if task is blocked.""" + return self.status == TaskStatus.BLOCKED or len(self.blocked_by) > 0 + + @property + def is_overdue(self) -> bool: + """Check if task is overdue.""" + if self.due_date and self.status in TaskStatus.active_statuses(): + return datetime.utcnow() > self.due_date + return False + + @property + def progress(self) -> float: + """Calculate progress based on subtasks (0.0 to 1.0).""" + if not self.subtasks: + return 1.0 if self.is_completed else 0.0 + completed = sum(1 for st in self.subtasks if st.status == TaskStatus.COMPLETED) + return completed / len(self.subtasks) + + @property + def progress_percent(self) -> int: + """Calculate progress as percentage.""" + return int(self.progress * 100) + + def start(self) -> None: + """Mark task as in progress.""" + self.status = TaskStatus.IN_PROGRESS + self.started_at = datetime.utcnow() + self.updated_at = datetime.utcnow() + + def complete(self) -> None: + """Mark task as completed.""" + self.status = TaskStatus.COMPLETED + self.completed_at = datetime.utcnow() + self.updated_at = datetime.utcnow() + # Complete all subtasks + for subtask in self.subtasks: + if subtask.status != TaskStatus.COMPLETED: + subtask.complete() + + def block(self, reason: str | None = None) -> None: + """Mark task as blocked.""" + self.status = TaskStatus.BLOCKED + self.updated_at = datetime.utcnow() + if reason: + self.notes = f"Blocked: {reason}\n{self.notes or ''}" + + def cancel(self) -> None: + """Cancel the task.""" + self.status = TaskStatus.CANCELLED + self.updated_at = datetime.utcnow() + + def add_subtask(self, title: str, description: str | None = None) -> Subtask: + """Add a subtask.""" + subtask = Subtask( + title=title, + description=description, + order=len(self.subtasks), + ) + self.subtasks.append(subtask) + self.updated_at = datetime.utcnow() + return subtask + + def remove_subtask(self, subtask_id: str) -> bool: + """Remove a subtask by ID.""" + for i, st in enumerate(self.subtasks): + if st.id == subtask_id: + self.subtasks.pop(i) + self.updated_at = datetime.utcnow() + return True + return False + + def add_tag(self, tag: str) -> None: + """Add a tag to the task.""" + tag = tag.strip().lower() + if tag and tag not in self.tags: + self.tags.append(tag) + self.updated_at = datetime.utcnow() + + def remove_tag(self, tag: str) -> None: + """Remove a tag from the task.""" + tag = tag.strip().lower() + if tag in self.tags: + self.tags.remove(tag) + self.updated_at = datetime.utcnow() + + def add_dependency(self, task_id: str) -> None: + """Add a dependency (this task depends on task_id).""" + if task_id not in self.dependencies: + self.dependencies.append(task_id) + self.updated_at = datetime.utcnow() + + def remove_dependency(self, task_id: str) -> None: + """Remove a dependency.""" + if task_id in self.dependencies: + self.dependencies.remove(task_id) + self.updated_at = datetime.utcnow() + + def to_summary(self) -> str: + """Generate a human-readable summary.""" + status_emoji = { + TaskStatus.PENDING: "⏳", + TaskStatus.IN_PROGRESS: "🔄", + TaskStatus.BLOCKED: "🚫", + TaskStatus.REVIEW: "👀", + TaskStatus.COMPLETED: "✅", + TaskStatus.CANCELLED: "❌", + } + priority_emoji = { + TaskPriority.CRITICAL: "🔴", + TaskPriority.HIGH: "🟠", + TaskPriority.MEDIUM: "🟡", + TaskPriority.LOW: "🟢", + TaskPriority.BACKLOG: "⚪", + } + parts = [ + f"{status_emoji.get(self.status, '❓')} {priority_emoji.get(self.priority, '❓')} {self.title}", + ] + if self.due_date: + parts.append(f"Due: {self.due_date.strftime('%Y-%m-%d')}") + if self.subtasks: + parts.append(f"Progress: {self.progress_percent}%") + if self.tags: + parts.append(f"Tags: {', '.join(self.tags)}") + return " | ".join(parts) + + +class Project(BaseModel): + """Project for organizing tasks.""" + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + description: str | None = None + color: str = "#6366f1" + icon: str | None = None + archived: bool = False + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + # Project metadata + repository_url: str | None = None + directory_path: str | None = None # Local directory this project maps to + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate project name.""" + v = v.strip() + if not v: + raise ValueError("Project name cannot be empty") + if len(v) > 100: + raise ValueError("Project name must be 100 characters or less") + return v + + +class TaskFilter(BaseModel): + """Filter criteria for querying tasks.""" + + status: list[TaskStatus] | None = None + priority: list[TaskPriority] | None = None + project_id: str | None = None + tags: list[str] | None = None + due_before: datetime | None = None + due_after: datetime | None = None + created_after: datetime | None = None + search_query: str | None = None + include_completed: bool = False + include_cancelled: bool = False + + def matches(self, task: Task) -> bool: + """Check if a task matches the filter criteria.""" + # Status filter + if self.status and task.status not in self.status: + return False + + # Exclude completed/cancelled unless explicitly included + if not self.include_completed and task.status == TaskStatus.COMPLETED: + return False + if not self.include_cancelled and task.status == TaskStatus.CANCELLED: + return False + + # Priority filter + if self.priority and task.priority not in self.priority: + return False + + # Project filter + if self.project_id and task.project_id != self.project_id: + return False + + # Tags filter (any match) + if self.tags: + if not any(tag in task.tags for tag in self.tags): + return False + + # Due date filters + if self.due_before and task.due_date: + if task.due_date > self.due_before: + return False + if self.due_after and task.due_date: + if task.due_date < self.due_after: + return False + + # Created after filter + if self.created_after and task.created_at < self.created_after: + return False + + # Search query (searches title and description) + if self.search_query: + query = self.search_query.lower() + title_match = query in task.title.lower() + desc_match = task.description and query in task.description.lower() + if not (title_match or desc_match): + return False + + return True + + +class TaskSort(str, Enum): + """Sort options for tasks.""" + + PRIORITY = "priority" + DUE_DATE = "due_date" + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" + TITLE = "title" + STATUS = "status" diff --git a/claude-task-master/taskmaster/mcp/__init__.py b/claude-task-master/taskmaster/mcp/__init__.py new file mode 100644 index 0000000000..a08edc930a --- /dev/null +++ b/claude-task-master/taskmaster/mcp/__init__.py @@ -0,0 +1,5 @@ +"""MCP server for Claude Desktop integration.""" + +from taskmaster.mcp.server import create_server, main + +__all__ = ["create_server", "main"] diff --git a/claude-task-master/taskmaster/mcp/server.py b/claude-task-master/taskmaster/mcp/server.py new file mode 100644 index 0000000000..5677112182 --- /dev/null +++ b/claude-task-master/taskmaster/mcp/server.py @@ -0,0 +1,745 @@ +"""MCP Server for Claude Desktop and Claude Code integration. + +This module provides a Model Context Protocol (MCP) server that exposes +task management tools to Claude Desktop and other MCP-compatible clients. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import Any + +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import ( + CallToolResult, + ListToolsResult, + TextContent, + Tool, +) +from pydantic import BaseModel + +from taskmaster.core.manager import TaskManager +from taskmaster.core.models import TaskPriority, TaskStatus, TaskFilter, TaskSort +from taskmaster.config.settings import get_settings, init_project + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class TaskMasterMCPServer: + """MCP Server exposing task management capabilities.""" + + def __init__(self, db_path: Path | str | None = None): + """Initialize the MCP server.""" + self.manager = TaskManager(db_path) + self.server = Server("claude-task-master") + self._setup_handlers() + + def _setup_handlers(self) -> None: + """Set up MCP request handlers.""" + + @self.server.list_tools() + async def list_tools() -> ListToolsResult: + """List all available tools.""" + return ListToolsResult(tools=self._get_tools()) + + @self.server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult: + """Handle tool calls.""" + try: + result = await self._handle_tool_call(name, arguments) + return CallToolResult( + content=[TextContent(type="text", text=json.dumps(result, indent=2, default=str))] + ) + except Exception as e: + logger.exception(f"Error handling tool call {name}") + return CallToolResult( + content=[TextContent(type="text", text=f"Error: {str(e)}")], + isError=True, + ) + + def _get_tools(self) -> list[Tool]: + """Get all available MCP tools.""" + return [ + # Task CRUD + Tool( + name="task_create", + description="Create a new task with title, optional description, priority, due date, and tags", + inputSchema={ + "type": "object", + "properties": { + "title": {"type": "string", "description": "Task title"}, + "description": {"type": "string", "description": "Task description"}, + "priority": { + "type": "string", + "enum": ["critical", "high", "medium", "low", "backlog"], + "description": "Task priority level", + }, + "due_date": {"type": "string", "description": "Due date in YYYY-MM-DD format"}, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Tags to categorize the task", + }, + "project_id": {"type": "string", "description": "Project ID to assign task to"}, + "subtasks": { + "type": "array", + "items": {"type": "string"}, + "description": "List of subtask titles to create", + }, + }, + "required": ["title"], + }, + ), + Tool( + name="task_get", + description="Get a task by ID or partial ID", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Task ID or partial ID"}, + }, + "required": ["task_id"], + }, + ), + Tool( + name="task_list", + description="List tasks with optional filters for status, priority, project, and tags", + inputSchema={ + "type": "object", + "properties": { + "status": { + "type": "array", + "items": { + "type": "string", + "enum": ["pending", "in_progress", "blocked", "review", "completed", "cancelled"], + }, + "description": "Filter by status", + }, + "priority": { + "type": "array", + "items": { + "type": "string", + "enum": ["critical", "high", "medium", "low", "backlog"], + }, + "description": "Filter by priority", + }, + "project_id": {"type": "string", "description": "Filter by project"}, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Filter by tags (any match)", + }, + "search": {"type": "string", "description": "Search in title and description"}, + "include_completed": {"type": "boolean", "description": "Include completed tasks"}, + "limit": {"type": "integer", "description": "Maximum number of tasks to return"}, + }, + }, + ), + Tool( + name="task_update", + description="Update a task's properties", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Task ID"}, + "title": {"type": "string", "description": "New title"}, + "description": {"type": "string", "description": "New description"}, + "priority": { + "type": "string", + "enum": ["critical", "high", "medium", "low", "backlog"], + }, + "due_date": {"type": "string", "description": "New due date (YYYY-MM-DD)"}, + "notes": {"type": "string", "description": "Additional notes"}, + }, + "required": ["task_id"], + }, + ), + Tool( + name="task_delete", + description="Delete a task by ID", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Task ID to delete"}, + }, + "required": ["task_id"], + }, + ), + # Task status changes + Tool( + name="task_start", + description="Mark a task as in progress", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Task ID to start"}, + }, + "required": ["task_id"], + }, + ), + Tool( + name="task_complete", + description="Mark a task as completed", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Task ID to complete"}, + }, + "required": ["task_id"], + }, + ), + Tool( + name="task_block", + description="Mark a task as blocked with an optional reason", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Task ID to block"}, + "reason": {"type": "string", "description": "Reason for blocking"}, + }, + "required": ["task_id"], + }, + ), + Tool( + name="task_next", + description="Get the next task to work on based on priority and dependencies", + inputSchema={ + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "Optional project filter"}, + }, + }, + ), + # Subtasks + Tool( + name="subtask_add", + description="Add a subtask to a task", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Parent task ID"}, + "title": {"type": "string", "description": "Subtask title"}, + "description": {"type": "string", "description": "Subtask description"}, + }, + "required": ["task_id", "title"], + }, + ), + Tool( + name="subtask_complete", + description="Mark a subtask as completed", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Parent task ID"}, + "subtask_id": {"type": "string", "description": "Subtask ID"}, + }, + "required": ["task_id", "subtask_id"], + }, + ), + # Tags + Tool( + name="task_add_tag", + description="Add a tag to a task", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Task ID"}, + "tag": {"type": "string", "description": "Tag to add"}, + }, + "required": ["task_id", "tag"], + }, + ), + Tool( + name="task_remove_tag", + description="Remove a tag from a task", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Task ID"}, + "tag": {"type": "string", "description": "Tag to remove"}, + }, + "required": ["task_id", "tag"], + }, + ), + # Dependencies + Tool( + name="task_add_dependency", + description="Add a dependency (this task depends on another task)", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Task ID"}, + "depends_on": {"type": "string", "description": "ID of task this depends on"}, + }, + "required": ["task_id", "depends_on"], + }, + ), + Tool( + name="task_remove_dependency", + description="Remove a dependency from a task", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Task ID"}, + "depends_on": {"type": "string", "description": "ID of dependency to remove"}, + }, + "required": ["task_id", "depends_on"], + }, + ), + # Projects + Tool( + name="project_create", + description="Create a new project", + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Project name"}, + "description": {"type": "string", "description": "Project description"}, + "directory_path": {"type": "string", "description": "Local directory path"}, + "repository_url": {"type": "string", "description": "Git repository URL"}, + }, + "required": ["name"], + }, + ), + Tool( + name="project_list", + description="List all projects", + inputSchema={ + "type": "object", + "properties": { + "include_archived": {"type": "boolean", "description": "Include archived projects"}, + }, + }, + ), + Tool( + name="project_get", + description="Get a project by ID or name", + inputSchema={ + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "Project ID or name"}, + }, + "required": ["project_id"], + }, + ), + # PRD parsing + Tool( + name="prd_parse", + description="Parse a PRD document into tasks", + inputSchema={ + "type": "object", + "properties": { + "content": {"type": "string", "description": "PRD content in markdown format"}, + "project_id": {"type": "string", "description": "Project to assign tasks to"}, + "create_tasks": { + "type": "boolean", + "description": "Whether to create tasks in database (default: true)", + }, + }, + "required": ["content"], + }, + ), + # Task expansion + Tool( + name="task_expand", + description="Expand a task by adding multiple subtasks", + inputSchema={ + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Task ID to expand"}, + "subtasks": { + "type": "array", + "items": {"type": "string"}, + "description": "List of subtask titles", + }, + }, + "required": ["task_id", "subtasks"], + }, + ), + # Statistics + Tool( + name="stats_get", + description="Get task statistics including counts by status and priority", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="tasks_overdue", + description="Get all overdue tasks", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="tasks_due_soon", + description="Get tasks due within specified days", + inputSchema={ + "type": "object", + "properties": { + "days": {"type": "integer", "description": "Number of days (default: 7)"}, + }, + }, + ), + # Bulk operations + Tool( + name="tasks_bulk_complete", + description="Mark multiple tasks as completed", + inputSchema={ + "type": "object", + "properties": { + "task_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of task IDs to complete", + }, + }, + "required": ["task_ids"], + }, + ), + Tool( + name="tasks_bulk_tag", + description="Add a tag to multiple tasks", + inputSchema={ + "type": "object", + "properties": { + "task_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of task IDs", + }, + "tag": {"type": "string", "description": "Tag to add"}, + }, + "required": ["task_ids", "tag"], + }, + ), + # Project initialization + Tool( + name="project_init", + description="Initialize a .taskmaster directory in a project folder", + inputSchema={ + "type": "object", + "properties": { + "directory": {"type": "string", "description": "Directory path to initialize"}, + "project_name": {"type": "string", "description": "Project name"}, + }, + "required": ["directory"], + }, + ), + # Dependency graph + Tool( + name="dependency_graph", + description="Get the task dependency graph for a project", + inputSchema={ + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "Project ID (optional)"}, + }, + }, + ), + Tool( + name="topological_order", + description="Get tasks in topological order based on dependencies", + inputSchema={ + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "Project ID (optional)"}, + }, + }, + ), + ] + + async def _handle_tool_call(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """Handle a tool call and return the result.""" + await self.manager.initialize() + + # Task CRUD + if name == "task_create": + due_date = None + if arguments.get("due_date"): + due_date = datetime.fromisoformat(arguments["due_date"]) + + priority = TaskPriority.MEDIUM + if arguments.get("priority"): + priority = TaskPriority(arguments["priority"]) + + task = await self.manager.create_task( + title=arguments["title"], + description=arguments.get("description"), + priority=priority, + due_date=due_date, + project_id=arguments.get("project_id"), + tags=arguments.get("tags"), + subtasks=arguments.get("subtasks"), + source="mcp", + ) + return {"success": True, "task": task.model_dump()} + + elif name == "task_get": + task = await self.manager.get_task(arguments["task_id"]) + if task: + return {"success": True, "task": task.model_dump()} + return {"success": False, "error": "Task not found"} + + elif name == "task_list": + status = None + if arguments.get("status"): + status = [TaskStatus(s) for s in arguments["status"]] + + priority = None + if arguments.get("priority"): + priority = [TaskPriority(p) for p in arguments["priority"]] + + tasks = await self.manager.list_tasks( + status=status, + priority=priority, + project_id=arguments.get("project_id"), + tags=arguments.get("tags"), + search=arguments.get("search"), + include_completed=arguments.get("include_completed", False), + limit=arguments.get("limit"), + ) + return { + "success": True, + "count": len(tasks), + "tasks": [t.model_dump() for t in tasks], + } + + elif name == "task_update": + task = await self.manager.get_task(arguments["task_id"]) + if not task: + return {"success": False, "error": "Task not found"} + + if "title" in arguments: + task.title = arguments["title"] + if "description" in arguments: + task.description = arguments["description"] + if "priority" in arguments: + task.priority = TaskPriority(arguments["priority"]) + if "due_date" in arguments: + task.due_date = datetime.fromisoformat(arguments["due_date"]) + if "notes" in arguments: + task.notes = arguments["notes"] + + updated = await self.manager.update_task(task) + return {"success": True, "task": updated.model_dump()} + + elif name == "task_delete": + deleted = await self.manager.delete_task(arguments["task_id"]) + return {"success": deleted} + + # Status changes + elif name == "task_start": + task = await self.manager.start_task(arguments["task_id"]) + if task: + return {"success": True, "task": task.model_dump()} + return {"success": False, "error": "Task not found"} + + elif name == "task_complete": + task = await self.manager.complete_task(arguments["task_id"]) + if task: + return {"success": True, "task": task.model_dump()} + return {"success": False, "error": "Task not found"} + + elif name == "task_block": + task = await self.manager.block_task( + arguments["task_id"], arguments.get("reason") + ) + if task: + return {"success": True, "task": task.model_dump()} + return {"success": False, "error": "Task not found"} + + elif name == "task_next": + task = await self.manager.get_next_task(arguments.get("project_id")) + if task: + return {"success": True, "task": task.model_dump()} + return {"success": True, "task": None, "message": "No tasks available"} + + # Subtasks + elif name == "subtask_add": + task = await self.manager.add_subtask( + arguments["task_id"], + arguments["title"], + arguments.get("description"), + ) + if task: + return {"success": True, "task": task.model_dump()} + return {"success": False, "error": "Task not found"} + + elif name == "subtask_complete": + task = await self.manager.complete_subtask( + arguments["task_id"], arguments["subtask_id"] + ) + if task: + return {"success": True, "task": task.model_dump()} + return {"success": False, "error": "Task or subtask not found"} + + # Tags + elif name == "task_add_tag": + task = await self.manager.get_task(arguments["task_id"]) + if task: + task.add_tag(arguments["tag"]) + updated = await self.manager.update_task(task) + return {"success": True, "task": updated.model_dump()} + return {"success": False, "error": "Task not found"} + + elif name == "task_remove_tag": + task = await self.manager.get_task(arguments["task_id"]) + if task: + task.remove_tag(arguments["tag"]) + updated = await self.manager.update_task(task) + return {"success": True, "task": updated.model_dump()} + return {"success": False, "error": "Task not found"} + + # Dependencies + elif name == "task_add_dependency": + task = await self.manager.get_task(arguments["task_id"]) + if task: + task.add_dependency(arguments["depends_on"]) + updated = await self.manager.update_task(task) + return {"success": True, "task": updated.model_dump()} + return {"success": False, "error": "Task not found"} + + elif name == "task_remove_dependency": + task = await self.manager.get_task(arguments["task_id"]) + if task: + task.remove_dependency(arguments["depends_on"]) + updated = await self.manager.update_task(task) + return {"success": True, "task": updated.model_dump()} + return {"success": False, "error": "Task not found"} + + # Projects + elif name == "project_create": + project = await self.manager.create_project( + name=arguments["name"], + description=arguments.get("description"), + directory_path=arguments.get("directory_path"), + repository_url=arguments.get("repository_url"), + ) + return {"success": True, "project": project.model_dump()} + + elif name == "project_list": + projects = await self.manager.list_projects( + include_archived=arguments.get("include_archived", False) + ) + return { + "success": True, + "count": len(projects), + "projects": [p.model_dump() for p in projects], + } + + elif name == "project_get": + project = await self.manager.get_project(arguments["project_id"]) + if project: + return {"success": True, "project": project.model_dump()} + return {"success": False, "error": "Project not found"} + + # PRD parsing + elif name == "prd_parse": + tasks = await self.manager.parse_prd( + content=arguments["content"], + project_id=arguments.get("project_id"), + create_tasks=arguments.get("create_tasks", True), + ) + return { + "success": True, + "count": len(tasks), + "tasks": [t.model_dump() for t in tasks], + } + + # Task expansion + elif name == "task_expand": + task = await self.manager.expand_task( + arguments["task_id"], arguments["subtasks"] + ) + if task: + return {"success": True, "task": task.model_dump()} + return {"success": False, "error": "Task not found"} + + # Statistics + elif name == "stats_get": + stats = await self.manager.get_statistics() + return {"success": True, "statistics": stats} + + elif name == "tasks_overdue": + tasks = await self.manager.get_overdue_tasks() + return { + "success": True, + "count": len(tasks), + "tasks": [t.model_dump() for t in tasks], + } + + elif name == "tasks_due_soon": + days = arguments.get("days", 7) + tasks = await self.manager.get_tasks_due_soon(days) + return { + "success": True, + "count": len(tasks), + "tasks": [t.model_dump() for t in tasks], + } + + # Bulk operations + elif name == "tasks_bulk_complete": + tasks = await self.manager.bulk_update_status( + arguments["task_ids"], TaskStatus.COMPLETED + ) + return { + "success": True, + "count": len(tasks), + "tasks": [t.model_dump() for t in tasks], + } + + elif name == "tasks_bulk_tag": + tasks = await self.manager.bulk_add_tag( + arguments["task_ids"], arguments["tag"] + ) + return { + "success": True, + "count": len(tasks), + "tasks": [t.model_dump() for t in tasks], + } + + # Project initialization + elif name == "project_init": + directory = Path(arguments["directory"]) + settings = init_project(directory, arguments.get("project_name")) + return { + "success": True, + "message": f"Initialized .taskmaster in {directory}", + "settings": settings.model_dump(), + } + + # Dependency graph + elif name == "dependency_graph": + graph = await self.manager.get_dependency_graph(arguments.get("project_id")) + return {"success": True, "graph": graph} + + elif name == "topological_order": + order = await self.manager.get_topological_order(arguments.get("project_id")) + return {"success": True, "order": order} + + else: + return {"success": False, "error": f"Unknown tool: {name}"} + + async def run(self) -> None: + """Run the MCP server.""" + async with stdio_server() as (read_stream, write_stream): + await self.server.run(read_stream, write_stream, self.server.create_initialization_options()) + + +def create_server(db_path: Path | str | None = None) -> TaskMasterMCPServer: + """Create a new MCP server instance.""" + return TaskMasterMCPServer(db_path) + + +def main() -> None: + """Main entry point for the MCP server.""" + server = create_server() + asyncio.run(server.run()) + + +if __name__ == "__main__": + main() diff --git a/claude-task-master/tests/__init__.py b/claude-task-master/tests/__init__.py new file mode 100644 index 0000000000..670d11f4df --- /dev/null +++ b/claude-task-master/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Claude Task Master.""" diff --git a/claude-task-master/tests/test_core.py b/claude-task-master/tests/test_core.py new file mode 100644 index 0000000000..4b52fa6176 --- /dev/null +++ b/claude-task-master/tests/test_core.py @@ -0,0 +1,386 @@ +"""Tests for core task management functionality.""" + +from __future__ import annotations + +import tempfile +from datetime import datetime, timedelta +from pathlib import Path + +import pytest + +from taskmaster.core.models import ( + Task, + TaskStatus, + TaskPriority, + Project, + Subtask, + TaskFilter, +) +from taskmaster.core.database import Database +from taskmaster.core.manager import TaskManager + + +class TestTaskModel: + """Tests for the Task model.""" + + def test_create_task(self): + """Test creating a basic task.""" + task = Task(title="Test task") + assert task.title == "Test task" + assert task.status == TaskStatus.PENDING + assert task.priority == TaskPriority.MEDIUM + assert task.id is not None + + def test_task_with_all_fields(self): + """Test creating a task with all fields.""" + due = datetime.now() + timedelta(days=7) + task = Task( + title="Full task", + description="A complete task", + status=TaskStatus.IN_PROGRESS, + priority=TaskPriority.HIGH, + due_date=due, + tags=["test", "important"], + ) + assert task.title == "Full task" + assert task.description == "A complete task" + assert task.status == TaskStatus.IN_PROGRESS + assert task.priority == TaskPriority.HIGH + assert task.due_date == due + assert "test" in task.tags + assert "important" in task.tags + + def test_task_start(self): + """Test starting a task.""" + task = Task(title="Test") + assert task.started_at is None + task.start() + assert task.status == TaskStatus.IN_PROGRESS + assert task.started_at is not None + + def test_task_complete(self): + """Test completing a task.""" + task = Task(title="Test") + task.complete() + assert task.status == TaskStatus.COMPLETED + assert task.completed_at is not None + + def test_task_block(self): + """Test blocking a task.""" + task = Task(title="Test") + task.block("Waiting for approval") + assert task.status == TaskStatus.BLOCKED + assert "Waiting for approval" in (task.notes or "") + + def test_add_subtask(self): + """Test adding subtasks.""" + task = Task(title="Parent") + subtask = task.add_subtask("Child task", "Description") + assert len(task.subtasks) == 1 + assert subtask.title == "Child task" + assert subtask.description == "Description" + assert subtask.status == TaskStatus.PENDING + + def test_task_progress(self): + """Test progress calculation.""" + task = Task(title="Parent") + assert task.progress == 0.0 + + task.add_subtask("ST 1") + task.add_subtask("ST 2") + assert task.progress == 0.0 + + task.subtasks[0].complete() + assert task.progress == 0.5 + + task.subtasks[1].complete() + assert task.progress == 1.0 + + def test_task_tags(self): + """Test tag management.""" + task = Task(title="Test") + task.add_tag("bug") + task.add_tag("urgent") + assert "bug" in task.tags + assert "urgent" in task.tags + + task.add_tag("BUG") # Should not duplicate + assert task.tags.count("bug") == 1 + + task.remove_tag("bug") + assert "bug" not in task.tags + + def test_task_dependencies(self): + """Test dependency management.""" + task = Task(title="Test") + task.add_dependency("dep-1") + task.add_dependency("dep-2") + assert "dep-1" in task.dependencies + assert "dep-2" in task.dependencies + + task.remove_dependency("dep-1") + assert "dep-1" not in task.dependencies + + def test_is_overdue(self): + """Test overdue detection.""" + # Not overdue - no due date + task = Task(title="Test") + assert not task.is_overdue + + # Not overdue - future date + task.due_date = datetime.utcnow() + timedelta(days=1) + assert not task.is_overdue + + # Overdue - past date + task.due_date = datetime.utcnow() - timedelta(days=1) + assert task.is_overdue + + # Not overdue - completed + task.complete() + assert not task.is_overdue + + +class TestTaskFilter: + """Tests for TaskFilter.""" + + def test_filter_by_status(self): + """Test filtering by status.""" + filter = TaskFilter(status=[TaskStatus.PENDING]) + task1 = Task(title="Pending", status=TaskStatus.PENDING) + task2 = Task(title="Done", status=TaskStatus.COMPLETED) + + assert filter.matches(task1) + assert not filter.matches(task2) + + def test_filter_by_priority(self): + """Test filtering by priority.""" + filter = TaskFilter(priority=[TaskPriority.HIGH, TaskPriority.CRITICAL]) + high = Task(title="High", priority=TaskPriority.HIGH) + low = Task(title="Low", priority=TaskPriority.LOW) + + assert filter.matches(high) + assert not filter.matches(low) + + def test_filter_by_tags(self): + """Test filtering by tags.""" + filter = TaskFilter(tags=["bug"]) + task1 = Task(title="Bug", tags=["bug", "frontend"]) + task2 = Task(title="Feature", tags=["feature"]) + + assert filter.matches(task1) + assert not filter.matches(task2) + + def test_filter_search(self): + """Test search filter.""" + filter = TaskFilter(search_query="auth", include_completed=True) + task1 = Task(title="Implement authentication") + task2 = Task(title="Fix bug", description="Related to auth system") + task3 = Task(title="Update docs") + + assert filter.matches(task1) + assert filter.matches(task2) + assert not filter.matches(task3) + + +class TestProject: + """Tests for Project model.""" + + def test_create_project(self): + """Test creating a project.""" + project = Project(name="My Project", description="Test project") + assert project.name == "My Project" + assert project.description == "Test project" + assert project.id is not None + assert not project.archived + + +class TestDatabase: + """Tests for the database layer.""" + + @pytest.fixture + def db(self, tmp_path): + """Create a temporary database.""" + db_path = tmp_path / "test.db" + return Database(db_path) + + @pytest.mark.asyncio + async def test_create_and_get_task(self, db): + """Test creating and retrieving a task.""" + await db.connect() + + task = Task(title="Test task", priority=TaskPriority.HIGH) + created = await db.create_task(task) + assert created.id == task.id + + retrieved = await db.get_task(task.id) + assert retrieved is not None + assert retrieved.title == "Test task" + assert retrieved.priority == TaskPriority.HIGH + + await db.close() + + @pytest.mark.asyncio + async def test_list_tasks(self, db): + """Test listing tasks.""" + await db.connect() + + for i in range(5): + await db.create_task(Task(title=f"Task {i}")) + + tasks = await db.list_tasks() + assert len(tasks) == 5 + + await db.close() + + @pytest.mark.asyncio + async def test_update_task(self, db): + """Test updating a task.""" + await db.connect() + + task = Task(title="Original") + await db.create_task(task) + + task.title = "Updated" + task.priority = TaskPriority.CRITICAL + await db.update_task(task) + + retrieved = await db.get_task(task.id) + assert retrieved.title == "Updated" + assert retrieved.priority == TaskPriority.CRITICAL + + await db.close() + + @pytest.mark.asyncio + async def test_delete_task(self, db): + """Test deleting a task.""" + await db.connect() + + task = Task(title="To delete") + await db.create_task(task) + + deleted = await db.delete_task(task.id) + assert deleted + + retrieved = await db.get_task(task.id) + assert retrieved is None + + await db.close() + + @pytest.mark.asyncio + async def test_task_with_subtasks(self, db): + """Test task with subtasks.""" + await db.connect() + + task = Task(title="Parent") + task.add_subtask("Child 1") + task.add_subtask("Child 2") + await db.create_task(task) + + retrieved = await db.get_task(task.id) + assert len(retrieved.subtasks) == 2 + assert retrieved.subtasks[0].title == "Child 1" + + await db.close() + + @pytest.mark.asyncio + async def test_project_crud(self, db): + """Test project CRUD operations.""" + await db.connect() + + project = Project(name="Test Project") + created = await db.create_project(project) + assert created.id == project.id + + retrieved = await db.get_project(project.id) + assert retrieved.name == "Test Project" + + projects = await db.list_projects() + assert len(projects) == 1 + + await db.close() + + +class TestTaskManager: + """Tests for the TaskManager.""" + + @pytest.fixture + def manager(self, tmp_path): + """Create a task manager with temporary database.""" + db_path = tmp_path / "test.db" + return TaskManager(db_path) + + @pytest.mark.asyncio + async def test_create_and_get_task(self, manager): + """Test creating and getting a task.""" + async with manager: + task = await manager.create_task( + title="Test", + priority=TaskPriority.HIGH, + tags=["test"], + ) + assert task.id is not None + + retrieved = await manager.get_task(task.id) + assert retrieved.title == "Test" + + @pytest.mark.asyncio + async def test_get_next_task(self, manager): + """Test getting the next task.""" + async with manager: + # Create tasks with different priorities + await manager.create_task(title="Low", priority=TaskPriority.LOW) + await manager.create_task(title="High", priority=TaskPriority.HIGH) + await manager.create_task(title="Critical", priority=TaskPriority.CRITICAL) + + next_task = await manager.get_next_task() + assert next_task.title == "Critical" + + @pytest.mark.asyncio + async def test_task_workflow(self, manager): + """Test complete task workflow.""" + async with manager: + # Create + task = await manager.create_task(title="Workflow test") + assert task.status == TaskStatus.PENDING + + # Start + task = await manager.start_task(task.id) + assert task.status == TaskStatus.IN_PROGRESS + assert task.started_at is not None + + # Complete + task = await manager.complete_task(task.id) + assert task.status == TaskStatus.COMPLETED + assert task.completed_at is not None + + @pytest.mark.asyncio + async def test_parse_prd(self, manager): + """Test PRD parsing.""" + prd_content = """ +# Requirements + +## Phase 1 +- Task one (high) +- Task two + - Subtask A + - Subtask B + +## Phase 2 +- Task three (low) +""" + async with manager: + tasks = await manager.parse_prd(prd_content, create_tasks=True) + assert len(tasks) >= 3 + + @pytest.mark.asyncio + async def test_statistics(self, manager): + """Test getting statistics.""" + async with manager: + await manager.create_task(title="Task 1") + await manager.create_task(title="Task 2", priority=TaskPriority.HIGH) + task3 = await manager.create_task(title="Task 3") + await manager.complete_task(task3.id) + + stats = await manager.get_statistics() + assert stats["total_tasks"] == 3 + assert TaskStatus.COMPLETED.value in stats["by_status"]