feat: adds ability to add or remove subtasks. Can also turn subtasks into standalone features. Also refactors the task-master.js by deleting 200+ lines of duplicate code. Instead properly imports the commands from commands.js which is the single source of truth for command definitions.
This commit is contained in:
@@ -20,6 +20,8 @@ import {
|
||||
expandAllTasks,
|
||||
clearSubtasks,
|
||||
addTask,
|
||||
addSubtask,
|
||||
removeSubtask,
|
||||
analyzeTaskComplexity
|
||||
} from './task-manager.js';
|
||||
|
||||
@@ -36,6 +38,7 @@ import {
|
||||
displayNextTask,
|
||||
displayTaskById,
|
||||
displayComplexityReport,
|
||||
getStatusWithColor
|
||||
} from './ui.js';
|
||||
|
||||
/**
|
||||
@@ -400,6 +403,143 @@ function registerCommands(programInstance) {
|
||||
.action(async (options) => {
|
||||
await displayComplexityReport(options.file);
|
||||
});
|
||||
|
||||
// add-subtask command
|
||||
programInstance
|
||||
.command('add-subtask')
|
||||
.description('Add a subtask to an existing task')
|
||||
.option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json')
|
||||
.option('-p, --parent <id>', 'Parent task ID (required)')
|
||||
.option('-i, --task-id <id>', 'Existing task ID to convert to subtask')
|
||||
.option('-t, --title <title>', 'Title for the new subtask (when creating a new subtask)')
|
||||
.option('-d, --description <text>', 'Description for the new subtask')
|
||||
.option('--details <text>', 'Implementation details for the new subtask')
|
||||
.option('--dependencies <ids>', 'Comma-separated list of dependency IDs for the new subtask')
|
||||
.option('-s, --status <status>', 'Status for the new subtask', 'pending')
|
||||
.option('--no-generate', 'Skip regenerating task files')
|
||||
.action(async (options) => {
|
||||
const tasksPath = options.file;
|
||||
const parentId = options.parent;
|
||||
const existingTaskId = options.taskId;
|
||||
const generateFiles = options.generate;
|
||||
|
||||
if (!parentId) {
|
||||
console.error(chalk.red('Error: --parent parameter is required. Please provide a parent task ID.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse dependencies if provided
|
||||
let dependencies = [];
|
||||
if (options.dependencies) {
|
||||
dependencies = options.dependencies.split(',').map(id => {
|
||||
// Handle both regular IDs and dot notation
|
||||
return id.includes('.') ? id.trim() : parseInt(id.trim(), 10);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (existingTaskId) {
|
||||
// Convert existing task to subtask
|
||||
console.log(chalk.blue(`Converting task ${existingTaskId} to a subtask of ${parentId}...`));
|
||||
await addSubtask(tasksPath, parentId, existingTaskId, null, generateFiles);
|
||||
console.log(chalk.green(`✓ Task ${existingTaskId} successfully converted to a subtask of task ${parentId}`));
|
||||
} else if (options.title) {
|
||||
// Create new subtask with provided data
|
||||
console.log(chalk.blue(`Creating new subtask for parent task ${parentId}...`));
|
||||
|
||||
const newSubtaskData = {
|
||||
title: options.title,
|
||||
description: options.description || '',
|
||||
details: options.details || '',
|
||||
status: options.status || 'pending',
|
||||
dependencies: dependencies
|
||||
};
|
||||
|
||||
const subtask = await addSubtask(tasksPath, parentId, null, newSubtaskData, generateFiles);
|
||||
console.log(chalk.green(`✓ New subtask ${parentId}.${subtask.id} successfully created`));
|
||||
|
||||
// Display success message and suggested next steps
|
||||
console.log(boxen(
|
||||
chalk.white.bold(`Subtask ${parentId}.${subtask.id} Added Successfully`) + '\n\n' +
|
||||
chalk.white(`Title: ${subtask.title}`) + '\n' +
|
||||
chalk.white(`Status: ${getStatusWithColor(subtask.status)}`) + '\n' +
|
||||
(dependencies.length > 0 ? chalk.white(`Dependencies: ${dependencies.join(', ')}`) + '\n' : '') +
|
||||
'\n' +
|
||||
chalk.white.bold('Next Steps:') + '\n' +
|
||||
chalk.cyan(`1. Run ${chalk.yellow(`task-master show ${parentId}`)} to see the parent task with all subtasks`) + '\n' +
|
||||
chalk.cyan(`2. Run ${chalk.yellow(`task-master set-status --id=${parentId}.${subtask.id} --status=in-progress`)} to start working on it`),
|
||||
{ padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } }
|
||||
));
|
||||
} else {
|
||||
console.error(chalk.red('Error: Either --task-id or --title must be provided.'));
|
||||
console.log(boxen(
|
||||
chalk.white.bold('Usage Examples:') + '\n\n' +
|
||||
chalk.white('Convert existing task to subtask:') + '\n' +
|
||||
chalk.yellow(` task-master add-subtask --parent=5 --task-id=8`) + '\n\n' +
|
||||
chalk.white('Create new subtask:') + '\n' +
|
||||
chalk.yellow(` task-master add-subtask --parent=5 --title="Implement login UI" --description="Create the login form"`) + '\n\n',
|
||||
{ padding: 1, borderColor: 'blue', borderStyle: 'round' }
|
||||
));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error: ${error.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// remove-subtask command
|
||||
programInstance
|
||||
.command('remove-subtask')
|
||||
.description('Remove a subtask from its parent task')
|
||||
.option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json')
|
||||
.option('-i, --id <id>', 'Subtask ID to remove in format "parentId.subtaskId" (required)')
|
||||
.option('-c, --convert', 'Convert the subtask to a standalone task instead of deleting it')
|
||||
.option('--no-generate', 'Skip regenerating task files')
|
||||
.action(async (options) => {
|
||||
const tasksPath = options.file;
|
||||
const subtaskId = options.id;
|
||||
const convertToTask = options.convert || false;
|
||||
const generateFiles = options.generate;
|
||||
|
||||
if (!subtaskId) {
|
||||
console.error(chalk.red('Error: --id parameter is required. Please provide a subtask ID in format "parentId.subtaskId".'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(chalk.blue(`Removing subtask ${subtaskId}...`));
|
||||
if (convertToTask) {
|
||||
console.log(chalk.blue('The subtask will be converted to a standalone task'));
|
||||
}
|
||||
|
||||
const result = await removeSubtask(tasksPath, subtaskId, convertToTask, generateFiles);
|
||||
|
||||
if (convertToTask && result) {
|
||||
// Display success message and next steps for converted task
|
||||
console.log(boxen(
|
||||
chalk.white.bold(`Subtask ${subtaskId} Converted to Task #${result.id}`) + '\n\n' +
|
||||
chalk.white(`Title: ${result.title}`) + '\n' +
|
||||
chalk.white(`Status: ${getStatusWithColor(result.status)}`) + '\n' +
|
||||
chalk.white(`Dependencies: ${result.dependencies.join(', ')}`) + '\n\n' +
|
||||
chalk.white.bold('Next Steps:') + '\n' +
|
||||
chalk.cyan(`1. Run ${chalk.yellow(`task-master show ${result.id}`)} to see details of the new task`) + '\n' +
|
||||
chalk.cyan(`2. Run ${chalk.yellow(`task-master set-status --id=${result.id} --status=in-progress`)} to start working on it`),
|
||||
{ padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } }
|
||||
));
|
||||
} else {
|
||||
// Display success message for deleted subtask
|
||||
console.log(boxen(
|
||||
chalk.white.bold(`Subtask ${subtaskId} Removed`) + '\n\n' +
|
||||
chalk.white('The subtask has been successfully deleted.'),
|
||||
{ padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } }
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error: ${error.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Add more commands as needed...
|
||||
|
||||
|
||||
@@ -2288,6 +2288,274 @@ function findNextTask(tasks) {
|
||||
return nextTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a subtask to a parent task
|
||||
* @param {string} tasksPath - Path to the tasks.json file
|
||||
* @param {number|string} parentId - ID of the parent task
|
||||
* @param {number|string|null} existingTaskId - ID of an existing task to convert to subtask (optional)
|
||||
* @param {Object} newSubtaskData - Data for creating a new subtask (used if existingTaskId is null)
|
||||
* @param {boolean} generateFiles - Whether to regenerate task files after adding the subtask
|
||||
* @returns {Object} The newly created or converted subtask
|
||||
*/
|
||||
async function addSubtask(tasksPath, parentId, existingTaskId = null, newSubtaskData = null, generateFiles = true) {
|
||||
try {
|
||||
log('info', `Adding subtask to parent task ${parentId}...`);
|
||||
|
||||
// Read the existing tasks
|
||||
const data = readJSON(tasksPath);
|
||||
if (!data || !data.tasks) {
|
||||
throw new Error(`Invalid or missing tasks file at ${tasksPath}`);
|
||||
}
|
||||
|
||||
// Convert parent ID to number
|
||||
const parentIdNum = parseInt(parentId, 10);
|
||||
|
||||
// Find the parent task
|
||||
const parentTask = data.tasks.find(t => t.id === parentIdNum);
|
||||
if (!parentTask) {
|
||||
throw new Error(`Parent task with ID ${parentIdNum} not found`);
|
||||
}
|
||||
|
||||
// Initialize subtasks array if it doesn't exist
|
||||
if (!parentTask.subtasks) {
|
||||
parentTask.subtasks = [];
|
||||
}
|
||||
|
||||
let newSubtask;
|
||||
|
||||
// Case 1: Convert an existing task to a subtask
|
||||
if (existingTaskId !== null) {
|
||||
const existingTaskIdNum = parseInt(existingTaskId, 10);
|
||||
|
||||
// Find the existing task
|
||||
const existingTaskIndex = data.tasks.findIndex(t => t.id === existingTaskIdNum);
|
||||
if (existingTaskIndex === -1) {
|
||||
throw new Error(`Task with ID ${existingTaskIdNum} not found`);
|
||||
}
|
||||
|
||||
const existingTask = data.tasks[existingTaskIndex];
|
||||
|
||||
// Check if task is already a subtask
|
||||
if (existingTask.parentTaskId) {
|
||||
throw new Error(`Task ${existingTaskIdNum} is already a subtask of task ${existingTask.parentTaskId}`);
|
||||
}
|
||||
|
||||
// Check for circular dependency
|
||||
if (existingTaskIdNum === parentIdNum) {
|
||||
throw new Error(`Cannot make a task a subtask of itself`);
|
||||
}
|
||||
|
||||
// Check if parent task is a subtask of the task we're converting
|
||||
// This would create a circular dependency
|
||||
if (isTaskDependentOn(data.tasks, parentTask, existingTaskIdNum)) {
|
||||
throw new Error(`Cannot create circular dependency: task ${parentIdNum} is already a subtask or dependent of task ${existingTaskIdNum}`);
|
||||
}
|
||||
|
||||
// Find the highest subtask ID to determine the next ID
|
||||
const highestSubtaskId = parentTask.subtasks.length > 0
|
||||
? Math.max(...parentTask.subtasks.map(st => st.id))
|
||||
: 0;
|
||||
const newSubtaskId = highestSubtaskId + 1;
|
||||
|
||||
// Clone the existing task to be converted to a subtask
|
||||
newSubtask = { ...existingTask, id: newSubtaskId, parentTaskId: parentIdNum };
|
||||
|
||||
// Add to parent's subtasks
|
||||
parentTask.subtasks.push(newSubtask);
|
||||
|
||||
// Remove the task from the main tasks array
|
||||
data.tasks.splice(existingTaskIndex, 1);
|
||||
|
||||
log('info', `Converted task ${existingTaskIdNum} to subtask ${parentIdNum}.${newSubtaskId}`);
|
||||
}
|
||||
// Case 2: Create a new subtask
|
||||
else if (newSubtaskData) {
|
||||
// Find the highest subtask ID to determine the next ID
|
||||
const highestSubtaskId = parentTask.subtasks.length > 0
|
||||
? Math.max(...parentTask.subtasks.map(st => st.id))
|
||||
: 0;
|
||||
const newSubtaskId = highestSubtaskId + 1;
|
||||
|
||||
// Create the new subtask object
|
||||
newSubtask = {
|
||||
id: newSubtaskId,
|
||||
title: newSubtaskData.title,
|
||||
description: newSubtaskData.description || '',
|
||||
details: newSubtaskData.details || '',
|
||||
status: newSubtaskData.status || 'pending',
|
||||
dependencies: newSubtaskData.dependencies || [],
|
||||
parentTaskId: parentIdNum
|
||||
};
|
||||
|
||||
// Add to parent's subtasks
|
||||
parentTask.subtasks.push(newSubtask);
|
||||
|
||||
log('info', `Created new subtask ${parentIdNum}.${newSubtaskId}`);
|
||||
} else {
|
||||
throw new Error('Either existingTaskId or newSubtaskData must be provided');
|
||||
}
|
||||
|
||||
// Write the updated tasks back to the file
|
||||
writeJSON(tasksPath, data);
|
||||
|
||||
// Generate task files if requested
|
||||
if (generateFiles) {
|
||||
log('info', 'Regenerating task files...');
|
||||
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
|
||||
}
|
||||
|
||||
return newSubtask;
|
||||
} catch (error) {
|
||||
log('error', `Error adding subtask: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task is dependent on another task (directly or indirectly)
|
||||
* Used to prevent circular dependencies
|
||||
* @param {Array} allTasks - Array of all tasks
|
||||
* @param {Object} task - The task to check
|
||||
* @param {number} targetTaskId - The task ID to check dependency against
|
||||
* @returns {boolean} Whether the task depends on the target task
|
||||
*/
|
||||
function isTaskDependentOn(allTasks, task, targetTaskId) {
|
||||
// If the task is a subtask, check if its parent is the target
|
||||
if (task.parentTaskId === targetTaskId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check direct dependencies
|
||||
if (task.dependencies && task.dependencies.includes(targetTaskId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check dependencies of dependencies (recursive)
|
||||
if (task.dependencies) {
|
||||
for (const depId of task.dependencies) {
|
||||
const depTask = allTasks.find(t => t.id === depId);
|
||||
if (depTask && isTaskDependentOn(allTasks, depTask, targetTaskId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check subtasks for dependencies
|
||||
if (task.subtasks) {
|
||||
for (const subtask of task.subtasks) {
|
||||
if (isTaskDependentOn(allTasks, subtask, targetTaskId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a subtask from its parent task
|
||||
* @param {string} tasksPath - Path to the tasks.json file
|
||||
* @param {string} subtaskId - ID of the subtask to remove in format "parentId.subtaskId"
|
||||
* @param {boolean} convertToTask - Whether to convert the subtask to a standalone task
|
||||
* @param {boolean} generateFiles - Whether to regenerate task files after removing the subtask
|
||||
* @returns {Object|null} The removed subtask if convertToTask is true, otherwise null
|
||||
*/
|
||||
async function removeSubtask(tasksPath, subtaskId, convertToTask = false, generateFiles = true) {
|
||||
try {
|
||||
log('info', `Removing subtask ${subtaskId}...`);
|
||||
|
||||
// Read the existing tasks
|
||||
const data = readJSON(tasksPath);
|
||||
if (!data || !data.tasks) {
|
||||
throw new Error(`Invalid or missing tasks file at ${tasksPath}`);
|
||||
}
|
||||
|
||||
// Parse the subtask ID (format: "parentId.subtaskId")
|
||||
if (!subtaskId.includes('.')) {
|
||||
throw new Error(`Invalid subtask ID format: ${subtaskId}. Expected format: "parentId.subtaskId"`);
|
||||
}
|
||||
|
||||
const [parentIdStr, subtaskIdStr] = subtaskId.split('.');
|
||||
const parentId = parseInt(parentIdStr, 10);
|
||||
const subtaskIdNum = parseInt(subtaskIdStr, 10);
|
||||
|
||||
// Find the parent task
|
||||
const parentTask = data.tasks.find(t => t.id === parentId);
|
||||
if (!parentTask) {
|
||||
throw new Error(`Parent task with ID ${parentId} not found`);
|
||||
}
|
||||
|
||||
// Check if parent has subtasks
|
||||
if (!parentTask.subtasks || parentTask.subtasks.length === 0) {
|
||||
throw new Error(`Parent task ${parentId} has no subtasks`);
|
||||
}
|
||||
|
||||
// Find the subtask to remove
|
||||
const subtaskIndex = parentTask.subtasks.findIndex(st => st.id === subtaskIdNum);
|
||||
if (subtaskIndex === -1) {
|
||||
throw new Error(`Subtask ${subtaskId} not found`);
|
||||
}
|
||||
|
||||
// Get a copy of the subtask before removing it
|
||||
const removedSubtask = { ...parentTask.subtasks[subtaskIndex] };
|
||||
|
||||
// Remove the subtask from the parent
|
||||
parentTask.subtasks.splice(subtaskIndex, 1);
|
||||
|
||||
// If parent has no more subtasks, remove the subtasks array
|
||||
if (parentTask.subtasks.length === 0) {
|
||||
delete parentTask.subtasks;
|
||||
}
|
||||
|
||||
let convertedTask = null;
|
||||
|
||||
// Convert the subtask to a standalone task if requested
|
||||
if (convertToTask) {
|
||||
log('info', `Converting subtask ${subtaskId} to a standalone task...`);
|
||||
|
||||
// Find the highest task ID to determine the next ID
|
||||
const highestId = Math.max(...data.tasks.map(t => t.id));
|
||||
const newTaskId = highestId + 1;
|
||||
|
||||
// Create the new task from the subtask
|
||||
convertedTask = {
|
||||
id: newTaskId,
|
||||
title: removedSubtask.title,
|
||||
description: removedSubtask.description || '',
|
||||
details: removedSubtask.details || '',
|
||||
status: removedSubtask.status || 'pending',
|
||||
dependencies: removedSubtask.dependencies || [],
|
||||
priority: parentTask.priority || 'medium' // Inherit priority from parent
|
||||
};
|
||||
|
||||
// Add the parent task as a dependency if not already present
|
||||
if (!convertedTask.dependencies.includes(parentId)) {
|
||||
convertedTask.dependencies.push(parentId);
|
||||
}
|
||||
|
||||
// Add the converted task to the tasks array
|
||||
data.tasks.push(convertedTask);
|
||||
|
||||
log('info', `Created new task ${newTaskId} from subtask ${subtaskId}`);
|
||||
} else {
|
||||
log('info', `Subtask ${subtaskId} deleted`);
|
||||
}
|
||||
|
||||
// Write the updated tasks back to the file
|
||||
writeJSON(tasksPath, data);
|
||||
|
||||
// Generate task files if requested
|
||||
if (generateFiles) {
|
||||
log('info', 'Regenerating task files...');
|
||||
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
|
||||
}
|
||||
|
||||
return convertedTask;
|
||||
} catch (error) {
|
||||
log('error', `Error removing subtask: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Export task manager functions
|
||||
export {
|
||||
@@ -2301,6 +2569,8 @@ export {
|
||||
expandAllTasks,
|
||||
clearSubtasks,
|
||||
addTask,
|
||||
addSubtask,
|
||||
removeSubtask,
|
||||
findNextTask,
|
||||
analyzeTaskComplexity,
|
||||
};
|
||||
@@ -279,5 +279,5 @@ export {
|
||||
formatTaskId,
|
||||
findTaskById,
|
||||
truncate,
|
||||
findCycles,
|
||||
findCycles
|
||||
};
|
||||
Reference in New Issue
Block a user