diff --git a/apps/cli/src/commands/set-status.command.ts b/apps/cli/src/commands/set-status.command.ts new file mode 100644 index 00000000..5eccdab8 --- /dev/null +++ b/apps/cli/src/commands/set-status.command.ts @@ -0,0 +1,289 @@ +/** + * @fileoverview SetStatusCommand using Commander's native class pattern + * Extends Commander.Command for better integration with the framework + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import boxen from 'boxen'; +import { + createTaskMasterCore, + type TaskMasterCore, + type TaskStatus +} from '@tm/core'; +import type { StorageType } from '@tm/core/types'; + +/** + * Valid task status values for validation + */ +const VALID_TASK_STATUSES: TaskStatus[] = [ + 'pending', + 'in-progress', + 'done', + 'deferred', + 'cancelled', + 'blocked', + 'review' +]; + +/** + * Options interface for the set-status command + */ +export interface SetStatusCommandOptions { + id?: string; + status?: TaskStatus; + format?: 'text' | 'json'; + silent?: boolean; + project?: string; +} + +/** + * Result type from set-status command + */ +export interface SetStatusResult { + success: boolean; + updatedTasks: Array<{ + taskId: string; + oldStatus: TaskStatus; + newStatus: TaskStatus; + }>; + storageType: Exclude; +} + +/** + * SetStatusCommand extending Commander's Command class + * This is a thin presentation layer over @tm/core + */ +export class SetStatusCommand extends Command { + private tmCore?: TaskMasterCore; + private lastResult?: SetStatusResult; + + constructor(name?: string) { + super(name || 'set-status'); + + // Configure the command + this.description('Update the status of one or more tasks') + .requiredOption( + '-i, --id ', + 'Task ID(s) to update (comma-separated for multiple, supports subtasks like 5.2)' + ) + .requiredOption( + '-s, --status ', + `New status (${VALID_TASK_STATUSES.join(', ')})` + ) + .option('-f, --format ', 'Output format (text, json)', 'text') + .option('--silent', 'Suppress output (useful for programmatic usage)') + .option('-p, --project ', 'Project root directory', process.cwd()) + .action(async (options: SetStatusCommandOptions) => { + await this.executeCommand(options); + }); + } + + /** + * Execute the set-status command + */ + private async executeCommand( + options: SetStatusCommandOptions + ): Promise { + try { + // Validate required options + if (!options.id) { + console.error(chalk.red('Error: Task ID is required. Use -i or --id')); + process.exit(1); + } + + if (!options.status) { + console.error( + chalk.red('Error: Status is required. Use -s or --status') + ); + process.exit(1); + } + + // Validate status + if (!VALID_TASK_STATUSES.includes(options.status)) { + console.error( + chalk.red( + `Error: Invalid status "${options.status}". Valid options: ${VALID_TASK_STATUSES.join(', ')}` + ) + ); + process.exit(1); + } + + // Initialize TaskMaster core + this.tmCore = await createTaskMasterCore({ + projectPath: options.project || process.cwd() + }); + + // Parse task IDs (handle comma-separated values) + const taskIds = options.id.split(',').map((id) => id.trim()); + + // Update each task + const updatedTasks: Array<{ + taskId: string; + oldStatus: TaskStatus; + newStatus: TaskStatus; + }> = []; + + for (const taskId of taskIds) { + try { + const result = await this.tmCore.updateTaskStatus( + taskId, + options.status + ); + updatedTasks.push({ + taskId: result.taskId, + oldStatus: result.oldStatus, + newStatus: result.newStatus + }); + } catch (error) { + if (!options.silent) { + console.error( + chalk.red(`Failed to update task ${taskId}: ${error.message}`) + ); + } + if (options.format === 'json') { + console.log( + JSON.stringify({ success: false, error: error.message, taskId }) + ); + } + process.exit(1); + } + } + + // Store result for potential reuse + this.lastResult = { + success: true, + updatedTasks, + storageType: this.tmCore.getStorageType() as Exclude< + StorageType, + 'auto' + > + }; + + // Display results + this.displayResults(this.lastResult, options); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error occurred'; + + if (!options.silent) { + console.error(chalk.red(`Error: ${errorMessage}`)); + } + + if (options.format === 'json') { + console.log(JSON.stringify({ success: false, error: errorMessage })); + } + + process.exit(1); + } finally { + // Clean up resources + if (this.tmCore) { + await this.tmCore.close(); + } + } + } + + /** + * Display results based on format + */ + private displayResults( + result: SetStatusResult, + options: SetStatusCommandOptions + ): void { + const format = options.format || 'text'; + + switch (format) { + case 'json': + console.log(JSON.stringify(result, null, 2)); + break; + + case 'text': + default: + if (!options.silent) { + this.displayTextResults(result); + } + break; + } + } + + /** + * Display results in text format + */ + private displayTextResults(result: SetStatusResult): void { + if (result.updatedTasks.length === 1) { + // Single task update + const update = result.updatedTasks[0]; + console.log( + boxen( + chalk.white.bold(`✅ Successfully updated task ${update.taskId}`) + + '\n\n' + + `${chalk.blue('From:')} ${this.getStatusDisplay(update.oldStatus)}\n` + + `${chalk.blue('To:')} ${this.getStatusDisplay(update.newStatus)}`, + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + } else { + // Multiple task updates + console.log( + boxen( + chalk.white.bold( + `✅ Successfully updated ${result.updatedTasks.length} tasks` + ) + + '\n\n' + + result.updatedTasks + .map( + (update) => + `${chalk.cyan(update.taskId)}: ${this.getStatusDisplay(update.oldStatus)} → ${this.getStatusDisplay(update.newStatus)}` + ) + .join('\n'), + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + } + + // Show storage info + console.log(chalk.gray(`\nUsing ${result.storageType} storage`)); + } + + /** + * Get colored status display + */ + private getStatusDisplay(status: TaskStatus): string { + const statusColors: Record string> = { + pending: chalk.yellow, + 'in-progress': chalk.blue, + done: chalk.green, + deferred: chalk.gray, + cancelled: chalk.red, + blocked: chalk.red, + review: chalk.magenta + }; + + const colorFn = statusColors[status] || chalk.white; + return colorFn(status); + } + + /** + * Get the last command result (useful for testing or chaining) + */ + getLastResult(): SetStatusResult | undefined { + return this.lastResult; + } +} + +/** + * Factory function to create and configure the set-status command + */ +export function createSetStatusCommand(): SetStatusCommand { + return new SetStatusCommand(); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 1aa10751..48abcabc 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -8,6 +8,7 @@ export { ListTasksCommand } from './commands/list.command.js'; export { ShowCommand } from './commands/show.command.js'; export { AuthCommand } from './commands/auth.command.js'; export { ContextCommand } from './commands/context.command.js'; +export { SetStatusCommand } from './commands/set-status.command.js'; // UI utilities (for other commands to use) export * as ui from './utils/ui.js'; diff --git a/packages/tm-core/src/services/task-service.ts b/packages/tm-core/src/services/task-service.ts index 8e5b974e..489d6c48 100644 --- a/packages/tm-core/src/services/task-service.ts +++ b/packages/tm-core/src/services/task-service.ts @@ -360,4 +360,103 @@ export class TaskService { async setActiveTag(tag: string): Promise { await this.configManager.setActiveTag(tag); } + + /** + * Update task status + */ + async updateTaskStatus( + taskId: string | number, + newStatus: TaskStatus, + tag?: string + ): Promise<{ + success: boolean; + oldStatus: TaskStatus; + newStatus: TaskStatus; + taskId: string; + }> { + // Ensure we have storage + if (!this.storage) { + throw new TaskMasterError( + 'Storage not initialized', + ERROR_CODES.STORAGE_ERROR + ); + } + + // Use provided tag or get active tag + const activeTag = tag || this.getActiveTag(); + + // Get all tasks to find the one to update + const result = await this.getTaskList({ + tag: activeTag, + includeSubtasks: true + }); + + // Handle both regular tasks (e.g., "5") and subtasks (e.g., "5.2") + const taskIdStr = String(taskId); + let taskToUpdate: Task | undefined; + let oldStatus: TaskStatus; + + if (taskIdStr.includes('.')) { + // Handle subtask + const [parentIdStr, subtaskIdStr] = taskIdStr.split('.'); + const parentId = parseInt(parentIdStr, 10); + const subtaskId = parseInt(subtaskIdStr, 10); + + const parentTask = result.tasks.find((t) => t.id === String(parentId)); + if (!parentTask || !parentTask.subtasks) { + throw new TaskMasterError( + `Parent task ${parentId} not found or has no subtasks`, + ERROR_CODES.TASK_NOT_FOUND + ); + } + + const subtask = parentTask.subtasks.find( + (st) => String(st.id) === String(subtaskId) + ); + if (!subtask) { + throw new TaskMasterError( + `Subtask ${taskIdStr} not found`, + ERROR_CODES.TASK_NOT_FOUND + ); + } + + oldStatus = subtask.status; + + // Update the subtask status + subtask.status = newStatus; + + // Update the parent task with the modified subtask + await this.storage.updateTask(parentTask.id, parentTask, activeTag); + + taskToUpdate = parentTask; + } else { + // Handle regular task + const taskIdNum = parseInt(taskIdStr, 10); + taskToUpdate = result.tasks.find( + (t) => String(t.id) === String(taskIdNum) + ); + + if (!taskToUpdate) { + throw new TaskMasterError( + `Task ${taskIdStr} not found`, + ERROR_CODES.TASK_NOT_FOUND + ); + } + + oldStatus = taskToUpdate.status; + + // Update the task status + taskToUpdate.status = newStatus; + + // Save the updated task + await this.storage.updateTask(taskToUpdate.id, taskToUpdate, activeTag); + } + + return { + success: true, + oldStatus, + newStatus, + taskId: taskIdStr + }; + } } diff --git a/packages/tm-core/src/task-master-core.ts b/packages/tm-core/src/task-master-core.ts index 93b662ff..2db8cc26 100644 --- a/packages/tm-core/src/task-master-core.ts +++ b/packages/tm-core/src/task-master-core.ts @@ -175,6 +175,22 @@ export class TaskMasterCore { await this.configManager.setActiveTag(tag); } + /** + * Update task status + */ + async updateTaskStatus( + taskId: string | number, + newStatus: TaskStatus, + tag?: string + ): Promise<{ + success: boolean; + oldStatus: TaskStatus; + newStatus: TaskStatus; + taskId: string; + }> { + return this.taskService.updateTaskStatus(taskId, newStatus, tag); + } + /** * Close and cleanup resources */