diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 075b2e2..2069499 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -13,6 +13,7 @@ jobs: node-version: 'latest' - run: npm ci - run: npm install -g tsx + - run: npm run build - run: npm test publish: diff --git a/README.md b/README.md index 5c67859..6b757ae 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MCP Task Manager - MCP Task Manager ([npm package: taskqueue-mcp](https://www.npmjs.com/package/taskqueue-mcp)) is a Model Context Protocol (MCP) server for AI task management. This tool helps AI assistants handle multi-step tasks in a structured way, with optional user approval checkpoints. +MCP Task Manager ([npm package: taskqueue-mcp](https://www.npmjs.com/package/taskqueue-mcp)) is a Model Context Protocol (MCP) server for AI task management. This tool helps AI assistants handle multi-step tasks in a structured way, with optional user approval checkpoints. ## Features @@ -27,22 +27,6 @@ Usually you will set the tool configuration in Claude Desktop, Cursor, or anothe } ``` -Or, with a custom tasks.json path: - -```json -{ - "tools": { - "taskqueue": { - "command": "npx", - "args": ["-y", "taskqueue-mcp"], - "env": { - "TASK_MANAGER_FILE_PATH": "/path/to/tasks.json" - } - } - } -} -``` - To use the CLI utility, you can use the following command: ```bash @@ -51,7 +35,7 @@ npx task-manager-cli --help This will show the available commands and options. -## Available Operations +## Available MCP Tools The TaskManager now uses a direct tools interface with specific, purpose-built tools for each operation: @@ -143,181 +127,42 @@ This command displays information about all projects in the system or a specific - Task details (title, description, status, approval) - Progress metrics (approved/completed/total tasks) -## Example Usage - -### Creating a Project with Tasks - -```javascript -// Example of how an LLM would use the create_project tool -{ - 'create_project': { - 'initialPrompt': "Create a website for a small business", - 'projectPlan': "We'll create a responsive website with Home, About, Services, and Contact pages", - 'tasks': [ - { - 'title': "Set up project structure", - 'description': "Create repository and initialize with basic HTML/CSS/JS files", - 'toolRecommendations': "create_directory, create_file, git_init", - 'ruleRecommendations': "Use consistent file naming, Initialize git repository" - }, - { - 'title': "Design homepage", - 'description': "Create responsive homepage with navigation and hero section", - 'toolRecommendations': "html_editor, css_editor, image_optimizer", - 'ruleRecommendations': "Follow accessibility guidelines (WCAG), Optimize for mobile-first" - }, - { - 'title': "Implement about page", - 'description': "Create about page with company history and team section", - 'toolRecommendations': "html_editor, css_editor", - 'ruleRecommendations': "Use clear and concise language, Include team member photos" - } - ] -} -} - -// Response will include: -// { -// status: "planned", -// projectId: "proj-1234", -// totalTasks: 3, -// tasks: [ -// { id: "task-1", title: "Set up structure", ..., toolRecommendations: "...", ruleRecommendations: "..." }, -// { id: "task-2", title: "Design homepage", ..., toolRecommendations: "...", ruleRecommendations: "..." }, -// { id: "task-3", title: "Implement about page", ..., toolRecommendations: "...", ruleRecommendations: "..." } -// ], -// message: "Project created with 3 tasks" -// } -``` +## Data Schema and Storage -### Getting the Next Task +### File Location -```javascript -// Example of how an LLM would use the get_next_task tool -{ - 'get_next_task': { - 'projectId': "proj-1234" - } -} +The task manager stores data in a JSON file that must be accessible to both the server and CLI. -// Response will include: -// { -// status: "next_task", -// task: { -// id: "task-1", -// title: "Set up project structure", -// description: "Create repository and initialize with basic HTML/CSS/JS files", -// status: "not started", -// approved: false -// }, -// message: "Retrieved next task" -// } -``` +The default platform-specific location is: + - **Linux**: `~/.local/share/taskqueue-mcp/tasks.json` + - **macOS**: `~/Library/Application Support/taskqueue-mcp/tasks.json` + - **Windows**: `%APPDATA%\taskqueue-mcp\tasks.json` -### Marking a Task as Done +Using a custom file path for storing task data is not recommended, because you have to remember to set the same path for both the MCP server and the CLI, or they won't be able to coordinate with each other. But if you do want to use a custom path, you can set the `TASK_MANAGER_FILE_PATH` environment variable in your MCP client configuration: -```javascript -// Example of how an LLM would use the mark_task_done tool +```json { - 'mark_task_done': { - 'projectId': "proj-1234", - 'taskId': "task-1", - 'completedDetails': "Created repository at github.com/example/business-site and initialized with HTML5 boilerplate, CSS reset, and basic JS structure." // Required when marking as done + "tools": { + "taskqueue": { + "command": "npx", + "args": ["-y", "taskqueue-mcp"], + "env": { + "TASK_MANAGER_FILE_PATH": "/path/to/tasks.json" + } + } } } - -// Response will include: -// { -// status: "task_marked_done", -// task: { -// id: "task-1", -// title: "Set up project structure", -// status: "done", -// approved: false, -// completedDetails: "Created repository at github.com/example/business-site and initialized with HTML5 boilerplate, CSS reset, and basic JS structure." -// }, -// message: "Task marked as done" -// } ``` -### Approving a Task (CLI-only operation) - -This operation can only be performed by the user through the CLI: +Then, before running the CLI, you should export the same path in your shell: ```bash -npm run approve-task -- proj-1234 task-1 -``` - -After approval, the LLM can check the task status using `read_task` or get the next task using `get_next_task`. - -### Finalizing a Project - -```javascript -// Example of how an LLM would use the finalize_project tool -// (Called after all tasks are done and approved) -{ - 'finalize_project': { - 'projectId': "proj-1234" - } -} - -// Response will include: -// { -// status: "project_finalized", -// projectId: "proj-1234", -// message: "Project has been finalized" -// } -``` - -## Status Codes and Responses - -All operations return a status code and message in their response: - -### Project Tool Statuses -- `projects_listed`: Successfully listed all projects -- `planned`: Successfully created a new project -- `project_deleted`: Successfully deleted a project -- `tasks_added`: Successfully added tasks to a project -- `project_finalized`: Successfully finalized a project -- `error`: An error occurred (with error message) - -### Task Tool Statuses -- `task_details`: Successfully retrieved task details -- `task_updated`: Successfully updated a task -- `task_deleted`: Successfully deleted a task -- `task_not_found`: Task not found -- `error`: An error occurred (with error message) - -## Structure of the Codebase - +export TASK_MANAGER_FILE_PATH="/path/to/tasks.json" ``` -src/ -├── index.ts # Main entry point -├── client/ -│ └── cli.ts # CLI for user task review and approval -├── server/ -│ ├── TaskManager.ts # Core service functionality -│ └── tools.ts # MCP tool definitions -└── types/ - └── index.ts # Type definitions and schemas -``` - -## Data Schema and Storage - -The task manager stores data in a JSON file with platform-specific default locations: -- **Default locations**: - - **Linux**: `~/.local/share/taskqueue-mcp/tasks.json` (following XDG Base Directory specification) - - **macOS**: `~/Library/Application Support/taskqueue-mcp/tasks.json` - - **Windows**: `%APPDATA%\taskqueue-mcp\tasks.json` (typically `C:\Users\\AppData\Roaming\taskqueue-mcp\tasks.json`) -- **Custom location**: Set via `TASK_MANAGER_FILE_PATH` environment variable - -```bash -# Example of setting custom storage location -TASK_MANAGER_FILE_PATH=/path/to/custom/tasks.json npm start -``` +### Data Schema -The data schema is organized as follows: +The JSON file uses the following structure: ``` TaskManagerFile @@ -337,19 +182,6 @@ TaskManagerFile └── ruleRecommendations: string # Suggested rules/guidelines to follow for this task ``` -The system persists this structure to the JSON file after each operation. - -**Explanation of Task Properties:** - -- `id`: A unique identifier for the task -- `title`: A short, descriptive title for the task -- `description`: A more detailed explanation of the task -- `status`: The current status of the task (`not started`, `in progress`, or `done`) -- `approved`: Indicates whether the task has been approved by the user -- `completedDetails`: Provides details about the task completion (required when `status` is `done`) -- `toolRecommendations`: A string containing suggested tools (by name or identifier) that might be helpful for completing this task. The LLM can use this to prioritize which tools to consider. -- `ruleRecommendations`: A string containing suggested rules or guidelines that should be followed while working on this task. This can include things like "ensure all code is commented," "follow accessibility guidelines," or "use the company style guide". The LLM uses these to improve the quality of its work. - ## License MIT diff --git a/index.ts b/index.ts index 07a42a1..aba14ef 100644 --- a/index.ts +++ b/index.ts @@ -10,7 +10,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprot const server = new Server( { name: "task-manager-server", - version: "1.1.1" + version: "1.1.2" }, { capabilities: { diff --git a/package-lock.json b/package-lock.json index 7d0321f..f15a394 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "taskqueue-mcp", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "taskqueue-mcp", - "version": "1.1.1", + "version": "1.1.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.7.0", diff --git a/package.json b/package.json index 43aff66..e157f3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "taskqueue-mcp", - "version": "1.1.1", + "version": "1.1.2", "description": "Task Queue MCP Server", "author": "Christopher C. Smith (christopher.smith@promptlytechnologies.com)", "main": "dist/index.js", @@ -19,7 +19,7 @@ "build": "tsc", "start": "node dist/index.js", "dev": "tsc && node dist/index.js", - "test": "tsc && NODE_OPTIONS=--experimental-vm-modules jest", + "test": "NODE_OPTIONS=--experimental-vm-modules jest", "approve-task": "node dist/src/cli.js approve-task", "list-tasks": "node dist/src/cli.js list" }, diff --git a/src/client/cli.ts b/src/client/cli.ts index a1d688a..43f95b9 100644 --- a/src/client/cli.ts +++ b/src/client/cli.ts @@ -1,83 +1,43 @@ #!/usr/bin/env node import { Command } from "commander"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import * as os from "node:os"; import chalk from "chalk"; -import { TaskManagerFile, ErrorCode } from "../types/index.js"; +import { + ErrorCode, + TaskState, + Task, + Project +} from "../types/index.js"; +import { TaskManager } from "../server/TaskManager.js"; import { createError, normalizeError } from "../utils/errors.js"; import { formatCliError } from "./errors.js"; const program = new Command(); -const DEFAULT_PATH = path.join(os.homedir(), "Documents", "tasks.json"); -const TASK_FILE_PATH = process.env.TASK_MANAGER_FILE_PATH || DEFAULT_PATH; - -/** - * Reads task data from the JSON file - * @returns {Promise} The task manager data - */ -async function readData(): Promise { - try { - console.log(chalk.blue(`Reading task data from: ${TASK_FILE_PATH}`)); - - try { - await fs.access(TASK_FILE_PATH); - } catch (error) { - console.warn(chalk.yellow(`Task file does not exist yet. Will create a new one.`)); - return { projects: [] }; - } - - const data = await fs.readFile(TASK_FILE_PATH, "utf-8"); - try { - return JSON.parse(data); - } catch (error) { - throw createError( - ErrorCode.FileParseError, - "Failed to parse task file", - { originalError: error } - ); - } - } catch (error) { - if (error instanceof Error && error.message.includes("ENOENT")) { - return { projects: [] }; - } - throw createError( - ErrorCode.FileReadError, - "Failed to read task file", - { originalError: error } - ); - } -} - -/** - * Writes task data to the JSON file - * @param {TaskManagerFile} data The task manager data to write - * @returns {Promise} - */ -async function writeData(data: TaskManagerFile): Promise { - try { - console.log(chalk.blue(`Writing task data to: ${TASK_FILE_PATH}`)); - - // Ensure the directory exists - const directory = path.dirname(TASK_FILE_PATH); - await fs.mkdir(directory, { recursive: true }); - - await fs.writeFile(TASK_FILE_PATH, JSON.stringify(data, null, 2), "utf-8"); - console.log(chalk.green('Data saved successfully')); - } catch (error) { - throw createError( - ErrorCode.FileWriteError, - "Failed to write task file", - { originalError: error } - ); - } -} program .name("task-manager-cli") .description("CLI for the Task Manager MCP Server") - .version("1.0.0"); + .version("1.0.0") + .option( + '-f, --file-path ', + 'Specify the path to the tasks JSON file. Overrides TASK_MANAGER_FILE_PATH env var.' + ); + +let taskManager: TaskManager; + +program.hook('preAction', (thisCommand, actionCommand) => { + const cliFilePath = program.opts().filePath; + const envFilePath = process.env.TASK_MANAGER_FILE_PATH; + const resolvedPath = cliFilePath || envFilePath || undefined; + + console.log(chalk.blue(`Using task file path determined by CLI/Env: ${resolvedPath || 'TaskManager Default'}`)); + try { + taskManager = new TaskManager(resolvedPath); + } catch (error) { + console.error(chalk.red(`Failed to initialize TaskManager: ${formatCliError(normalizeError(error))}`)); + process.exit(1); + } +}); program .command("approve") @@ -87,80 +47,131 @@ program .option('-f, --force', 'Force approval even if task is not marked as done') .action(async (projectId, taskId, options) => { try { - console.log(chalk.blue(`Approving task ${chalk.bold(taskId)} in project ${chalk.bold(projectId)}...`)); - - const data = await readData(); - - // Check if we have any projects - if (data.projects.length === 0) { - console.error(chalk.red(`No projects found. The task file is empty or just initialized.`)); - process.exit(1); - } - - const project = data.projects.find(p => p.projectId === projectId); - - if (!project) { - console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); - console.log(chalk.yellow('Available projects:')); - data.projects.forEach(p => { - console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); - }); - process.exit(1); - } - - const task = project.tasks.find(t => t.id === taskId); - if (!task) { - console.error(chalk.red(`Task ${chalk.bold(taskId)} not found in project ${chalk.bold(projectId)}.`)); - console.log(chalk.yellow('Available tasks in this project:')); - project.tasks.forEach(t => { - console.log(` - ${t.id}: ${t.title} (Status: ${t.status}, Approved: ${t.approved ? 'Yes' : 'No'})`); - }); - process.exit(1); + console.log(chalk.blue(`Attempting to approve task ${chalk.bold(taskId)} in project ${chalk.bold(projectId)}...`)); + + // First, verify the project and task exist and get their details + let project: Project; + let task: Task | undefined; + try { + const projectResponse = await taskManager.readProject(projectId); + if ('error' in projectResponse) { + throw projectResponse.error; + } + if (projectResponse.status !== "success") { + throw createError(ErrorCode.InvalidResponseFormat, "Unexpected response format from TaskManager"); + } + project = projectResponse.data; + task = project.tasks.find(t => t.id === taskId); + + if (!task) { + console.error(chalk.red(`Task ${chalk.bold(taskId)} not found in project ${chalk.bold(projectId)}.`)); + console.log(chalk.yellow('Available tasks in this project:')); + project.tasks.forEach((t: Task) => { + console.log(` - ${t.id}: ${t.title} (Status: ${t.status}, Approved: ${t.approved ? 'Yes' : 'No'})`); + }); + process.exit(1); + } + } catch (error) { + const normalized = normalizeError(error); + if (normalized.code === ErrorCode.ProjectNotFound) { + console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); + // Optionally list available projects + const projectsResponse = await taskManager.listProjects(); + if ('error' in projectsResponse) { + throw projectsResponse.error; + } + if (projectsResponse.status === "success" && projectsResponse.data.projects.length > 0) { + console.log(chalk.yellow('Available projects:')); + projectsResponse.data.projects.forEach((p: { projectId: string; initialPrompt: string }) => { + console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); + }); + } else { + console.log(chalk.yellow('No projects available.')); + } + process.exit(1); + } + throw error; // Re-throw other errors } + // Pre-check task status if not using force if (task.status !== "done" && !options.force) { console.error(chalk.red(`Task ${chalk.bold(taskId)} is not marked as done yet. Current status: ${chalk.bold(task.status)}`)); - console.log(chalk.yellow(`Use the --force flag to approve anyway, or wait for the task to be marked as done.`)); + console.log(chalk.yellow(`Use the --force flag to attempt approval anyway (may fail if underlying logic prevents it), or wait for the task to be marked as done.`)); process.exit(1); } - + if (task.approved) { console.log(chalk.yellow(`Task ${chalk.bold(taskId)} is already approved.`)); process.exit(0); } - task.approved = true; - await writeData(data); + // Attempt to approve the task + const approvalResponse = await taskManager.approveTaskCompletion(projectId, taskId); + if ('error' in approvalResponse) { + throw approvalResponse.error; + } console.log(chalk.green(`✅ Task ${chalk.bold(taskId)} in project ${chalk.bold(projectId)} has been approved.`)); - + + // Fetch updated project data for display + const updatedProjectResponse = await taskManager.readProject(projectId); + if ('error' in updatedProjectResponse) { + throw updatedProjectResponse.error; + } + if (updatedProjectResponse.status !== "success") { + throw createError(ErrorCode.InvalidResponseFormat, "Unexpected response format from TaskManager"); + } + const updatedProject = updatedProjectResponse.data; + const updatedTask = updatedProject.tasks.find(t => t.id === taskId); + // Show task info - console.log(chalk.cyan('\n📋 Task details:')); - console.log(` - ${chalk.bold('Title:')} ${task.title}`); - console.log(` - ${chalk.bold('Description:')} ${task.description}`); - console.log(` - ${chalk.bold('Status:')} ${task.status === 'done' ? chalk.green('Done ✓') : task.status === 'in progress' ? chalk.yellow('In Progress ⟳') : chalk.blue('Not Started ○')}`); - console.log(` - ${chalk.bold('Completed details:')} ${task.completedDetails || chalk.gray("None")}`); - console.log(` - ${chalk.bold('Approved:')} ${task.approved ? chalk.green('Yes ✓') : chalk.red('No ✗')}`); + if (updatedTask) { + console.log(chalk.cyan('\n📋 Task details:')); + console.log(` - ${chalk.bold('Title:')} ${updatedTask.title}`); + console.log(` - ${chalk.bold('Description:')} ${updatedTask.description}`); + console.log(` - ${chalk.bold('Status:')} ${updatedTask.status === 'done' ? chalk.green('Done ✓') : updatedTask.status === 'in progress' ? chalk.yellow('In Progress ⟳') : chalk.blue('Not Started ○')}`); + console.log(` - ${chalk.bold('Completed details:')} ${updatedTask.completedDetails || chalk.gray("None")}`); + console.log(` - ${chalk.bold('Approved:')} ${updatedTask.approved ? chalk.green('Yes ✓') : chalk.red('No ✗')}`); + if (updatedTask.toolRecommendations) { + console.log(` - ${chalk.bold('Tool Recommendations:')} ${updatedTask.toolRecommendations}`); + } + if (updatedTask.ruleRecommendations) { + console.log(` - ${chalk.bold('Rule Recommendations:')} ${updatedTask.ruleRecommendations}`); + } + } // Show progress info - const totalTasks = project.tasks.length; - const completedTasks = project.tasks.filter(t => t.status === "done").length; - const approvedTasks = project.tasks.filter(t => t.approved).length; - + const totalTasks = updatedProject.tasks.length; + const completedTasks = updatedProject.tasks.filter(t => t.status === "done").length; + const approvedTasks = updatedProject.tasks.filter(t => t.approved).length; + console.log(chalk.cyan(`\n📊 Progress: ${chalk.bold(`${approvedTasks}/${completedTasks}/${totalTasks}`)} (approved/completed/total)`)); - + // Create a progress bar const bar = '▓'.repeat(approvedTasks) + '▒'.repeat(completedTasks - approvedTasks) + '░'.repeat(totalTasks - completedTasks); console.log(` ${bar}`); - + if (completedTasks === totalTasks && approvedTasks === totalTasks) { console.log(chalk.green('\n🎉 All tasks are completed and approved!')); - console.log(chalk.blue('The project can now be finalized.')); + console.log(chalk.blue(`The project can now be finalized using: task-manager-cli finalize ${projectId}`)); } else { - console.log(chalk.yellow(`\n${totalTasks - completedTasks} tasks remaining to be completed.`)); - console.log(chalk.yellow(`${completedTasks - approvedTasks} tasks remaining to be approved.`)); + if (totalTasks - completedTasks > 0) { + console.log(chalk.yellow(`\n${totalTasks - completedTasks} tasks remaining to be completed.`)); + } + if (completedTasks - approvedTasks > 0) { + console.log(chalk.yellow(`${completedTasks - approvedTasks} tasks remaining to be approved.`)); + } } } catch (error) { - console.error(chalk.red(formatCliError(normalizeError(error)))); + const normalized = normalizeError(error); + if (normalized.code === ErrorCode.TaskNotDone) { + console.error(chalk.red(`Approval failed: Task ${chalk.bold(taskId)} is not marked as 'done' according to the Task Manager.`)); + // Just show the error message which should contain all relevant information + // No need to try to access status from details since it's not guaranteed to be there + console.error(chalk.red(normalized.message)); + process.exit(1); + } + // Handle other errors generally + console.error(chalk.red(formatCliError(normalized))); process.exit(1); } }); @@ -171,69 +182,97 @@ program .argument("", "Project ID") .action(async (projectId) => { try { - console.log(chalk.blue(`Approving project ${chalk.bold(projectId)}...`)); - - const data = await readData(); - - // Check if we have any projects - if (data.projects.length === 0) { - console.error(chalk.red(`No projects found. The task file is empty or just initialized.`)); - process.exit(1); + console.log(chalk.blue(`Attempting to finalize project ${chalk.bold(projectId)}...`)); + + // First, verify the project exists and get its details + let project: Project; + try { + const projectResponse = await taskManager.readProject(projectId); + if ('error' in projectResponse) { + throw projectResponse.error; + } + if (projectResponse.status !== "success") { + throw createError(ErrorCode.InvalidResponseFormat, "Unexpected response format from TaskManager"); + } + project = projectResponse.data; + } catch (error) { + const normalized = normalizeError(error); + if (normalized.code === ErrorCode.ProjectNotFound) { + console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); + // Optionally list available projects + const projectsResponse = await taskManager.listProjects(); + if ('error' in projectsResponse) { + throw projectsResponse.error; + } + if (projectsResponse.status === "success" && projectsResponse.data.projects.length > 0) { + console.log(chalk.yellow('Available projects:')); + projectsResponse.data.projects.forEach((p: { projectId: string; initialPrompt: string }) => { + console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); + }); + } else { + console.log(chalk.yellow('No projects available.')); + } + process.exit(1); + } + throw error; // Re-throw other errors } - const project = data.projects.find(p => p.projectId === projectId); - - if (!project) { - console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); - console.log(chalk.yellow('Available projects:')); - data.projects.forEach(p => { - console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); - }); - process.exit(1); + // Pre-check project status + if (project.completed) { + console.log(chalk.yellow(`Project ${chalk.bold(projectId)} is already marked as completed.`)); + process.exit(0); } - // Check if all tasks are done & approved - const allDone = project.tasks.every(t => t.status === "done"); + // Pre-check task status (for better user feedback before attempting finalization) + const allDone = project.tasks.every((t: Task) => t.status === "done"); if (!allDone) { console.error(chalk.red(`Not all tasks in project ${chalk.bold(projectId)} are marked as done.`)); console.log(chalk.yellow('\nPending tasks:')); - project.tasks.filter(t => t.status !== "done").forEach(t => { + project.tasks.filter((t: Task) => t.status !== "done").forEach((t: Task) => { console.log(` - ${chalk.bold(t.id)}: ${t.title} (Status: ${t.status})`); }); process.exit(1); } - const allApproved = project.tasks.every(t => t.approved); + const allApproved = project.tasks.every((t: Task) => t.approved); if (!allApproved) { console.error(chalk.red(`Not all tasks in project ${chalk.bold(projectId)} are approved yet.`)); console.log(chalk.yellow('\nUnapproved tasks:')); - project.tasks.filter(t => !t.approved).forEach(t => { + project.tasks.filter((t: Task) => !t.approved).forEach((t: Task) => { console.log(` - ${chalk.bold(t.id)}: ${t.title}`); }); process.exit(1); } - if (project.completed) { - console.log(chalk.yellow(`Project ${chalk.bold(projectId)} is already approved and completed.`)); - process.exit(0); + // Attempt to finalize the project + const finalizationResponse = await taskManager.approveProjectCompletion(projectId); + if ('error' in finalizationResponse) { + throw finalizationResponse.error; } - - project.completed = true; - await writeData(data); console.log(chalk.green(`✅ Project ${chalk.bold(projectId)} has been approved and marked as complete.`)); + // Fetch updated project data for display + const updatedProjectResponse = await taskManager.readProject(projectId); + if ('error' in updatedProjectResponse) { + throw updatedProjectResponse.error; + } + if (updatedProjectResponse.status !== "success") { + throw createError(ErrorCode.InvalidResponseFormat, "Unexpected response format from TaskManager"); + } + const updatedProject = updatedProjectResponse.data; + // Show project info console.log(chalk.cyan('\n📋 Project details:')); - console.log(` - ${chalk.bold('Initial Prompt:')} ${project.initialPrompt}`); - if (project.projectPlan && project.projectPlan !== project.initialPrompt) { - console.log(` - ${chalk.bold('Project Plan:')} ${project.projectPlan}`); + console.log(` - ${chalk.bold('Initial Prompt:')} ${updatedProject.initialPrompt}`); + if (updatedProject.projectPlan && updatedProject.projectPlan !== updatedProject.initialPrompt) { + console.log(` - ${chalk.bold('Project Plan:')} ${updatedProject.projectPlan}`); } console.log(` - ${chalk.bold('Status:')} ${chalk.green('Completed ✓')}`); // Show progress info - const totalTasks = project.tasks.length; - const completedTasks = project.tasks.filter(t => t.status === "done").length; - const approvedTasks = project.tasks.filter(t => t.approved).length; + const totalTasks = updatedProject.tasks.length; + const completedTasks = updatedProject.tasks.filter((t: Task) => t.status === "done").length; + const approvedTasks = updatedProject.tasks.filter((t: Task) => t.approved).length; console.log(chalk.cyan(`\n📊 Final Progress: ${chalk.bold(`${approvedTasks}/${completedTasks}/${totalTasks}`)} (approved/completed/total)`)); @@ -246,7 +285,23 @@ program console.log(chalk.blue(` task-manager-cli list -p ${projectId}`)); } catch (error) { - console.error(chalk.red(formatCliError(normalizeError(error)))); + const normalized = normalizeError(error); + if (normalized.code === ErrorCode.TasksNotAllDone) { + console.error(chalk.red(`Finalization failed: Not all tasks in project ${chalk.bold(projectId)} are marked as done.`)); + // We already showed pending tasks in pre-check, no need to show again + process.exit(1); + } + if (normalized.code === ErrorCode.TasksNotAllApproved) { + console.error(chalk.red(`Finalization failed: Not all completed tasks in project ${chalk.bold(projectId)} are approved yet.`)); + // We already showed unapproved tasks in pre-check, no need to show again + process.exit(1); + } + if (normalized.code === ErrorCode.ProjectAlreadyCompleted) { + console.log(chalk.yellow(`Project ${chalk.bold(projectId)} was already marked as completed.`)); + process.exit(0); + } + // Handle other errors generally + console.error(chalk.red(formatCliError(normalized))); process.exit(1); } }); @@ -259,128 +314,156 @@ program .action(async (options) => { try { // Validate state option if provided - if (options.state && !['open', 'pending_approval', 'completed', 'all'].includes(options.state)) { + const validStates = ['open', 'pending_approval', 'completed', 'all'] as const; + const stateOption = options.state as TaskState | undefined | 'all'; + if (stateOption && !validStates.includes(stateOption)) { console.error(chalk.red(`Invalid state value: ${options.state}`)); - console.log(chalk.yellow('Valid states are: open, pending_approval, completed, all')); + console.log(chalk.yellow(`Valid states are: ${validStates.join(', ')}`)); process.exit(1); } - - const data = await readData(); - - if (data.projects.length === 0) { - console.log(chalk.yellow('No projects found.')); - return; - } + // Use 'undefined' if state is 'all' or not provided, as TaskManager methods expect TaskState or undefined + const filterState = (stateOption === 'all' || !stateOption) ? undefined : stateOption as TaskState; if (options.project) { // Show details for a specific project - const project = data.projects.find(p => p.projectId === options.project); - if (!project) { - console.error(chalk.red(`Project ${chalk.bold(options.project)} not found.`)); - console.log(chalk.yellow('Available projects:')); - data.projects.forEach(p => { - console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); - }); - process.exit(1); - } + const projectId = options.project; + + // Fetch project details for display first + let projectDetailsResponse; + try { + projectDetailsResponse = await taskManager.readProject(projectId); + if ('error' in projectDetailsResponse) { + throw projectDetailsResponse.error; + } + if (projectDetailsResponse.status !== "success") { + throw createError(ErrorCode.InvalidResponseFormat, "Unexpected response format from TaskManager"); + } + const project = projectDetailsResponse.data; - // Filter tasks by state if specified - let tasks = project.tasks; - if (options.state && options.state !== "all") { - tasks = project.tasks.filter(task => { - switch (options.state) { - case "open": - return task.status !== "done"; - case "pending_approval": - return task.status === "done" && !task.approved; - case "completed": - return task.status === "done" && task.approved; - default: - return true; + // Fetch tasks for this project, applying state filter + const tasksResponse = await taskManager.listTasks(projectId, filterState); + const tasks = tasksResponse.data?.tasks || []; + + console.log(chalk.cyan(`\n📋 Project ${chalk.bold(projectId)} details:`)); + console.log(` - ${chalk.bold('Initial Prompt:')} ${project.initialPrompt}`); + if (project.projectPlan && project.projectPlan !== project.initialPrompt) { + console.log(` - ${chalk.bold('Project Plan:')} ${project.projectPlan}`); } - }); - } + console.log(` - ${chalk.bold('Status:')} ${project.completed ? chalk.green('Completed ✓') : chalk.yellow('In Progress')}`); - console.log(chalk.cyan(`\n📋 Project ${chalk.bold(options.project)} details:`)); - console.log(` - ${chalk.bold('Initial Prompt:')} ${project.initialPrompt}`); - if (project.projectPlan && project.projectPlan !== project.initialPrompt) { - console.log(` - ${chalk.bold('Project Plan:')} ${project.projectPlan}`); - } - console.log(` - ${chalk.bold('Status:')} ${project.completed ? chalk.green('Completed ✓') : chalk.yellow('In Progress')}`); - - // Show progress info - const totalTasks = project.tasks.length; - const completedTasks = project.tasks.filter(t => t.status === "done").length; - const approvedTasks = project.tasks.filter(t => t.approved).length; - - console.log(chalk.cyan(`\n📊 Progress: ${chalk.bold(`${approvedTasks}/${completedTasks}/${totalTasks}`)} (approved/completed/total)`)); - - // Create a progress bar - const bar = '▓'.repeat(approvedTasks) + '▒'.repeat(completedTasks - approvedTasks) + '░'.repeat(totalTasks - completedTasks); - console.log(` ${bar}`); - - if (tasks.length > 0) { - console.log(chalk.cyan('\n📝 Tasks:')); - tasks.forEach(t => { - const status = t.status === 'done' ? chalk.green('Done ✓') : t.status === 'in progress' ? chalk.yellow('In Progress ⟳') : chalk.blue('Not Started ○'); - const approved = t.approved ? chalk.green('Yes ✓') : chalk.red('No ✗'); - console.log(` - ${chalk.bold(t.id)}: ${t.title}`); - console.log(` Status: ${status}, Approved: ${approved}`); - console.log(` Description: ${t.description}`); - if (t.completedDetails) { - console.log(` Completed Details: ${t.completedDetails}`); + // Show progress info (using data from readProject) + const totalTasks = project.tasks.length; + const completedTasks = project.tasks.filter((t: { status: string }) => t.status === "done").length; + const approvedTasks = project.tasks.filter((t: { approved: boolean }) => t.approved).length; + + console.log(chalk.cyan(`\n📊 Progress: ${chalk.bold(`${approvedTasks}/${completedTasks}/${totalTasks}`)} (approved/completed/total)`)); + + // Create a progress bar + if (totalTasks > 0) { + const bar = '▓'.repeat(approvedTasks) + '▒'.repeat(completedTasks - approvedTasks) + '░'.repeat(totalTasks - completedTasks); + console.log(` ${bar}`); + } else { + console.log(chalk.yellow(' No tasks in this project yet.')); } - if (t.toolRecommendations) { - console.log(` Tool Recommendations: ${t.toolRecommendations}`); + + if (tasks.length > 0) { + console.log(chalk.cyan('\n📝 Tasks' + (filterState ? ` (filtered by state: ${filterState})` : '') + ':')); + tasks.forEach((t: { + id: string; + title: string; + status: string; + approved: boolean; + description: string; + completedDetails?: string; + toolRecommendations?: string; + ruleRecommendations?: string; + }) => { + const status = t.status === 'done' ? chalk.green('Done ✓') : t.status === 'in progress' ? chalk.yellow('In Progress ⟳') : chalk.blue('Not Started ○'); + const approved = t.approved ? chalk.green('Yes ✓') : chalk.red('No ✗'); + console.log(` - ${chalk.bold(t.id)}: ${t.title}`); + console.log(` Status: ${status}, Approved: ${approved}`); + console.log(` Description: ${t.description}`); + if (t.completedDetails) { + console.log(` Completed Details: ${t.completedDetails}`); + } + if (t.toolRecommendations) { + console.log(` Tool Recommendations: ${t.toolRecommendations}`); + } + if (t.ruleRecommendations) { + console.log(` Rule Recommendations: ${t.ruleRecommendations}`); + } + }); + } else { + console.log(chalk.yellow(`\nNo tasks found${filterState ? ` matching state '${filterState}'` : ''} in project ${projectId}.`)); } - if (t.ruleRecommendations) { - console.log(` Rule Recommendations: ${t.ruleRecommendations}`); + } catch (error) { + // Handle ProjectNotFound specifically if desired, otherwise let generic handler catch + const normalized = normalizeError(error); + if (normalized.code === ErrorCode.ProjectNotFound) { + console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); + // Optionally list available projects + const projectsResponse = await taskManager.listProjects(); + if ('error' in projectsResponse) { + throw projectsResponse.error; + } + if (projectsResponse.status === "success" && projectsResponse.data.projects.length > 0) { + console.log(chalk.yellow('Available projects:')); + projectsResponse.data.projects.forEach((p: { projectId: string; initialPrompt: string }) => { + console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); + }); + } else { + console.log(chalk.yellow('No projects available.')); + } + process.exit(1); } - }); - } else { - console.log(chalk.yellow('\nNo tasks match the specified state filter.')); + throw error; // Re-throw other errors } } else { - // List all projects - let projectsToList = data.projects; - - if (options.state && options.state !== "all") { - projectsToList = data.projects.filter(project => { - switch (options.state) { - case "open": - return !project.completed && project.tasks.some(task => task.status !== "done"); - case "pending_approval": - return project.tasks.some(task => task.status === "done" && !task.approved); - case "completed": - return project.completed && project.tasks.every(task => task.status === "done" && task.approved); - default: - return true; - } - }); - } + // List all projects, applying state filter + const projectsResponse = await taskManager.listProjects(filterState); + const projectsToList = projectsResponse.data?.projects || []; if (projectsToList.length === 0) { - console.log(chalk.yellow('No projects match the specified state filter.')); + console.log(chalk.yellow(`No projects found${filterState ? ` matching state '${filterState}'` : ''}.`)); return; } - console.log(chalk.cyan('\n📋 Projects List:')); - projectsToList.forEach(p => { - const totalTasks = p.tasks.length; - const completedTasks = p.tasks.filter(t => t.status === "done").length; - const approvedTasks = p.tasks.filter(t => t.approved).length; - const status = p.completed ? chalk.green('Completed ✓') : chalk.yellow('In Progress'); - - console.log(`\n${chalk.bold(p.projectId)}: ${status}`); - console.log(` Initial Prompt: ${p.initialPrompt.substring(0, 100)}${p.initialPrompt.length > 100 ? '...' : ''}`); - console.log(` Progress: ${chalk.bold(`${approvedTasks}/${completedTasks}/${totalTasks}`)} (approved/completed/total)`); - - // Create a progress bar - const bar = '▓'.repeat(approvedTasks) + '▒'.repeat(completedTasks - approvedTasks) + '░'.repeat(totalTasks - completedTasks); - console.log(` ${bar}`); - }); + console.log(chalk.cyan('\n📋 Projects List' + (filterState ? ` (filtered by state: ${filterState})` : ''))); + // Fetch full details for progress bar calculation if needed, or use summary data + for (const pSummary of projectsToList) { + try { + const projDetailsResp = await taskManager.readProject(pSummary.projectId); + if ('error' in projDetailsResp) { + throw projDetailsResp.error; + } + if (projDetailsResp.status !== "success") { + throw createError(ErrorCode.InvalidResponseFormat, "Unexpected response format from TaskManager"); + } + const p = projDetailsResp.data; + + const totalTasks = p.tasks.length; + const completedTasks = p.tasks.filter((t: { status: string }) => t.status === "done").length; + const approvedTasks = p.tasks.filter((t: { approved: boolean }) => t.approved).length; + const status = p.completed ? chalk.green('Completed ✓') : chalk.yellow('In Progress'); + + console.log(`\n${chalk.bold(p.projectId)}: ${status}`); + console.log(` Initial Prompt: ${p.initialPrompt.substring(0, 100)}${p.initialPrompt.length > 100 ? '...' : ''}`); + console.log(` Progress: ${chalk.bold(`${approvedTasks}/${completedTasks}/${totalTasks}`)} (approved/completed/total)`); + + // Create a progress bar + if (totalTasks > 0) { + const bar = '▓'.repeat(approvedTasks) + '▒'.repeat(completedTasks - approvedTasks) + '░'.repeat(totalTasks - completedTasks); + console.log(` ${bar}`); + } else { + console.log(chalk.yellow(' No tasks in this project.')); + } + } catch (error) { + console.error(chalk.red(`Error fetching details for project ${pSummary.projectId}: ${formatCliError(normalizeError(error))}`)); + } + } } } catch (error) { + // Handle errors generally - no need for TaskNotDone handling in list command console.error(chalk.red(formatCliError(normalizeError(error)))); process.exit(1); } diff --git a/src/client/errors.ts b/src/client/errors.ts index 553409c..cb1ccfd 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -2,7 +2,8 @@ import { StandardError } from "../types/index.js"; /** * Formats an error message for CLI output */ -export function formatCliError(error: StandardError): string { - const details = error.details ? `: ${JSON.stringify(error.details)}` : ''; - return `[${error.code}] ${error.message}${details}`; - } \ No newline at end of file +export function formatCliError(error: StandardError, includeDetails: boolean = false): string { + const codePrefix = error.message.includes(`[${error.code}]`) ? '' : `[${error.code}] `; + const message = `${codePrefix}${error.message}`; + return includeDetails && error.details ? `${message}\nDetails: ${JSON.stringify(error.details, null, 2)}` : message; +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index a23917e..64e1b06 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,3 @@ -import { z } from "zod"; - // Task and Project Interfaces export interface Task { id: string; @@ -34,127 +32,6 @@ export const VALID_STATUS_TRANSITIONS = { export type TaskState = "open" | "pending_approval" | "completed" | "all"; -// Tool schemas -// Project action schemas -const ListProjectActionSchema = z.object({ - action: z.literal("list"), - arguments: z.object({ - state: z.enum(["open", "pending_approval", "completed", "all"]).optional() - }).strict() -}); - -const CreateProjectActionSchema = z.object({ - action: z.literal("create"), - arguments: z.object({ - initialPrompt: z.string().min(1, "Initial prompt is required"), - projectPlan: z.string().optional(), - tasks: z.array(z.object({ - title: z.string().min(1, "Task title is required"), - description: z.string().min(1, "Task description is required") - })).min(1, "At least one task is required") - }).strict() -}); - -const DeleteProjectActionSchema = z.object({ - action: z.literal("delete"), - arguments: z.object({ - projectId: z.string().min(1, "Project ID is required") - }).strict() -}); - -const AddTasksActionSchema = z.object({ - action: z.literal("add_tasks"), - arguments: z.object({ - projectId: z.string().min(1, "Project ID is required"), - tasks: z.array(z.object({ - title: z.string().min(1, "Task title is required"), - description: z.string().min(1, "Task description is required") - })).min(1, "At least one task is required") - }).strict() -}); - -const FinalizeActionSchema = z.object({ - action: z.literal("finalize"), - arguments: z.object({ - projectId: z.string().min(1, "Project ID is required") - }).strict() -}); - -// Task action schemas -const ListTaskActionSchema = z.object({ - action: z.literal("list"), - arguments: z.object({ - projectId: z.string().min(1, "Project ID is required").optional(), - state: z.enum(["open", "pending_approval", "completed", "all"]).optional() - }).strict() -}); - -const ReadTaskActionSchema = z.object({ - action: z.literal("read"), - arguments: z.object({ - taskId: z.string().min(1, "Task ID is required") - }).strict() -}); - -const UpdateTaskActionSchema = z.object({ - action: z.literal("update"), - arguments: z.object({ - projectId: z.string().min(1, "Project ID is required"), - taskId: z.string().min(1, "Task ID is required"), - title: z.string().optional(), - description: z.string().optional(), - status: z.enum(["not started", "in progress", "done"]).optional(), - completedDetails: z.string().optional() - }).strict().refine( - data => { - if (data.status === 'done' && !data.completedDetails) { - return false; - } - return true; - }, - { - message: 'completedDetails is required when status is "done"', - path: ['completedDetails'] - } - ) -}); - -const DeleteTaskActionSchema = z.object({ - action: z.literal("delete"), - arguments: z.object({ - projectId: z.string().min(1, "Project ID is required"), - taskId: z.string().min(1, "Task ID is required") - }).strict() -}); - -// Combined action schemas using discriminated unions -export const ProjectActionSchema = z.discriminatedUnion("action", [ - ListProjectActionSchema, - CreateProjectActionSchema, - DeleteProjectActionSchema, - AddTasksActionSchema, - FinalizeActionSchema -]); - -export const TaskActionSchema = z.discriminatedUnion("action", [ - ListTaskActionSchema, - ReadTaskActionSchema, - UpdateTaskActionSchema, - DeleteTaskActionSchema -]); - -// The project tool schema -export const ProjectToolSchema = z.object({ - tool: z.literal("project"), - params: ProjectActionSchema -}); - -// The task tool schema -export const TaskToolSchema = z.object({ - tool: z.literal("task"), - params: TaskActionSchema -}); - // Error Types export enum ErrorCategory { Validation = 'VALIDATION', diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index 12ea348..caff788 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -127,13 +127,13 @@ describe("CLI Integration Tests", () => { }, 5000); it("should handle no matching items gracefully", async () => { - // Test no matching projects + // Test no matching projects with open state const { stdout: noProjects } = await execAsync(`TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} list -s open -p proj-3`); - expect(noProjects).toContain("No tasks match the specified state filter"); + expect(noProjects).toContain("No tasks found matching state 'open' in project proj-3"); - // Test no matching tasks + // Test no matching tasks with completed state const { stdout: noTasks } = await execAsync(`TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} list -s completed -p proj-1`); - expect(noTasks).toContain("No tasks match the specified state filter"); + expect(noTasks).toContain("No tasks found matching state 'completed' in project proj-1"); }, 5000); it("should show progress bars and status indicators correctly", async () => { diff --git a/tests/unit/tools.test.ts b/tests/unit/tools.test.ts index 807176a..84c9187 100644 --- a/tests/unit/tools.test.ts +++ b/tests/unit/tools.test.ts @@ -1,4 +1,4 @@ -import { jest, describe, it, expect } from '@jest/globals'; +import { describe, it, expect } from '@jest/globals'; import { ALL_TOOLS } from '../../src/server/tools.js'; import { Tool } from '@modelcontextprotocol/sdk/types.js'; @@ -14,12 +14,6 @@ interface ToolInputSchema { required?: string[]; } -interface TaskItemSchema { - type: string; - properties: Record; - required: string[]; -} - interface TasksInputSchema { type: string; properties: {