/**
* @fileoverview ListTasks command using Commander's native class pattern
* Extends Commander.Command for better integration with the framework
*/
import {
OUTPUT_FORMATS,
type OutputFormat,
STATUS_ICONS,
TASK_STATUSES,
type Task,
type TaskStatus,
type TmCore,
type WatchSubscription,
createTmCore
} from '@tm/core';
import type { StorageType } from '@tm/core';
import chalk from 'chalk';
import { Command } from 'commander';
import {
type NextTaskInfo,
calculateDependencyStatistics,
calculateSubtaskStatistics,
calculateTaskStatistics,
displayDashboards,
displayRecommendedNextTask,
displaySuggestedNextSteps,
displaySyncMessage,
displayWatchFooter,
getPriorityBreakdown,
getTaskDescription
} from '../ui/index.js';
import { displayCommandHeader } from '../utils/display-helpers.js';
import { displayError } from '../utils/error-handler.js';
import { getProjectRoot } from '../utils/project-root.js';
import { isTaskComplete } from '../utils/task-status.js';
import * as ui from '../utils/ui.js';
/**
* Options interface for the list command
*/
export interface ListCommandOptions {
status?: string;
tag?: string;
withSubtasks?: boolean;
format?: OutputFormat;
json?: boolean;
compact?: boolean;
noHeader?: boolean;
silent?: boolean;
project?: string;
watch?: boolean;
}
/**
* Result type from list command
*/
export interface ListTasksResult {
tasks: Task[];
total: number;
filtered: number;
tag?: string;
storageType: Exclude<StorageType, 'auto'>;
}
/**
* ListTasksCommand extending Commander's Command class
* This is a thin presentation layer over @tm/core
*/
export class ListTasksCommand extends Command {
private tmCore?: TmCore;
private lastResult?: ListTasksResult;
constructor(name?: string) {
super(name || 'list');
// Configure the command with positional status argument
this.description('List tasks with optional filtering')
.alias('ls')
.argument(
'[status]',
'Filter by status (e.g., pending, done, in-progress) or "all" to show with subtasks'
)
.option(
'-s, --status <status>',
'Filter by status (fallback if not using positional)'
)
.option('-t, --tag <tag>', 'Filter by tag')
.option('--with-subtasks', 'Include subtasks in the output')
.option(
'-f, --format <format>',
'Output format (text, json, compact)',
'text'
)
.option('--json', 'Output in JSON format (shorthand for --format json)')
.option(
'-c, --compact',
'Output in compact format (shorthand for --format compact)'
)
.option('--no-header', 'Hide the command header')
.option('--silent', 'Suppress output (useful for programmatic usage)')
.option(
'-p, --project <path>',
'Project root directory (auto-detected if not provided)'
)
.option('-w, --watch', 'Watch for changes and update list automatically')
.action(async (statusArg?: string, options?: ListCommandOptions) => {
// Handle special "all" keyword to show with subtasks
let status = statusArg || options?.status;
let withSubtasks = options?.withSubtasks || false;
if (statusArg === 'all') {
// "all" means show all tasks with subtasks expanded
status = undefined;
withSubtasks = true;
}
// Prioritize positional argument over option
const mergedOptions: ListCommandOptions = {
...options,
status,
withSubtasks
};
await this.executeCommand(mergedOptions);
});
}
/**
* Execute the list command
*/
private async executeCommand(options: ListCommandOptions): Promise<void> {
try {
// Validate options
if (!this.validateOptions(options)) {
process.exit(1);
}
// Initialize tm-core (project root auto-detected if not provided)
await this.initializeCore(getProjectRoot(options.project));
// Get tasks from core
if (options.watch) {
await this.watchTasks(options);
} else {
const result = await this.getTasks(options);
// Store result for programmatic access
this.setLastResult(result);
// Display results
if (!options.silent) {
this.displayResults(result, options);
}
}
} catch (error: any) {
displayError(error);
}
}
/**
* Watch for changes and update list
* Uses tm-core's unified watch API which handles both file and API storage
*/
private async watchTasks(options: ListCommandOptions): Promise<void> {
if (!this.tmCore) {
throw new Error('TmCore not initialized');
}
// Initial render
let result = await this.getTasks(options);
let lastSync = new Date();
console.clear();
this.displayResults(result, options);
const storageType = result.storageType;
displayWatchFooter(storageType, lastSync);
let subscription: WatchSubscription | undefined;
try {
// Subscribe to task changes via tm-core
subscription = await this.tmCore.tasks.watch(
async (event) => {
if (event.type === 'change') {
try {
// Re-fetch tasks
result = await this.getTasks(options);
lastSync = new Date();
// Clear and display
console.clear();
this.displayResults(result, options);
// Show sync message with timestamp
displaySyncMessage(storageType, lastSync);
displayWatchFooter(storageType, lastSync);
} catch {
// Ignore errors during watch (e.g. partial writes)
}
} else if (event.type === 'error' && event.error) {
console.error(chalk.red(`\n⚠ Watch error: ${event.error.message}`));
}
},
{ tag: options.tag }
);
// Cleanup on process termination
const cleanup = () => {
subscription?.unsubscribe();
process.exit(0);
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
// Keep process alive
await new Promise(() => {});
} catch (error: any) {
console.error(chalk.red(`Watch mode error: ${error.message}`));
throw error;
}
}
/**
* Validate command options
*/
private validateOptions(options: ListCommandOptions): boolean {
// Validate format
if (
options.format &&
!OUTPUT_FORMATS.includes(options.format as OutputFormat)
) {
console.error(chalk.red(`Invalid format: ${options.format}`));
console.error(chalk.gray(`Valid formats: ${OUTPUT_FORMATS.join(', ')}`));
return false;
}
// Validate status
if (options.status) {
const statuses = options.status.split(',').map((s: string) => s.trim());
for (const status of statuses) {
if (status !== 'all' && !TASK_STATUSES.includes(status as TaskStatus)) {
console.error(chalk.red(`Invalid status: ${status}`));
console.error(
chalk.gray(`Valid statuses: ${TASK_STATUSES.join(', ')}`)
);
return false;
}
}
}
return true;
}
/**
* Initialize TmCore
*/
private async initializeCore(projectRoot: string): Promise<void> {
if (!this.tmCore) {
this.tmCore = await createTmCore({ projectPath: projectRoot });
}
}
/**
* Get tasks from tm-core
*/
private async getTasks(
options: ListCommandOptions
): Promise<ListTasksResult> {
if (!this.tmCore) {
throw new Error('TmCore not initialized');
}
// Build filter
const filter =
options.status && options.status !== 'all'
? {
status: options.status
.split(',')
.map((s: string) => s.trim() as TaskStatus)
}
: undefined;
// Call tm-core
const result = await this.tmCore.tasks.list({
tag: options.tag,
filter,
includeSubtasks: options.withSubtasks
});
return result as ListTasksResult;
}
/**
* Display results based on format
*/
private displayResults(
result: ListTasksResult,
options: ListCommandOptions
): void {
// Resolve format: --json and --compact flags override --format option
const format = (
options.json
? 'json'
: options.compact
? 'compact'
: options.format || 'text'
) as OutputFormat;
switch (format) {
case 'json':
this.displayJson(result);
break;
case 'compact':
this.displayCompact(result, options);
break;
case 'text':
default:
this.displayText(result, options);
break;
}
}
/**
* Display in JSON format
*/
private displayJson(data: ListTasksResult): void {
console.log(
JSON.stringify(
{
tasks: data.tasks,
metadata: {
total: data.total,
filtered: data.filtered,
tag: data.tag,
storageType: data.storageType
}
},
null,
2
)
);
}
/**
* Display in compact format
*/
private displayCompact(
data: ListTasksResult,
options: ListCommandOptions
): void {
const { tasks, tag, storageType } = data;
// Display header unless --no-header is set
if (options.noHeader !== true) {
displayCommandHeader(this.tmCore, {
tag: tag || 'master',
storageType
});
}
tasks.forEach((task) => {
const icon = STATUS_ICONS[task.status];
console.log(`${chalk.cyan(task.id)} ${icon} ${task.title}`);
if (options.withSubtasks && task.subtasks?.length) {
task.subtasks.forEach((subtask) => {
const subIcon = STATUS_ICONS[subtask.status];
console.log(
` ${chalk.gray(String(subtask.id))} ${subIcon} ${chalk.gray(subtask.title)}`
);
});
}
});
}
/**
* Display in text format with tables
*/
private displayText(
data: ListTasksResult,
options: ListCommandOptions
): void {
const { tasks, tag, storageType } = data;
// Display header unless --no-header is set
if (options.noHeader !== true) {
displayCommandHeader(this.tmCore, {
tag: tag || 'master',
storageType
});
}
// No tasks message
if (tasks.length === 0) {
ui.displayWarning('No tasks found matching the criteria.');
return;
}
// Calculate statistics
const taskStats = calculateTaskStatistics(tasks);
const subtaskStats = calculateSubtaskStatistics(tasks);
const depStats = calculateDependencyStatistics(tasks);
const priorityBreakdown = getPriorityBreakdown(tasks);
// Find next task following the same logic as findNextTask
const nextTaskInfo = this.findNextTask(tasks);
// Get the full task object with complexity data already included
const nextTask = nextTaskInfo
? tasks.find((t) => String(t.id) === String(nextTaskInfo.id))
: undefined;
// Display dashboard boxes (nextTask already has complexity from storage enrichment)
displayDashboards(
taskStats,
subtaskStats,
priorityBreakdown,
depStats,
nextTask
);
// Task table
console.log(
ui.createTaskTable(tasks, {
showSubtasks: options.withSubtasks,
showDependencies: true,
showComplexity: true // Enable complexity column
})
);
// Display recommended next task section immediately after table
// Don't show "no tasks available" message in list command - that's for tm next
if (nextTask) {
const description = getTaskDescription(nextTask);
displayRecommendedNextTask({
id: nextTask.id,
title: nextTask.title,
priority: nextTask.priority,
status: nextTask.status,
dependencies: nextTask.dependencies,
description,
complexity: nextTask.complexity as number | undefined
});
}
// If no next task, don't show any message - dashboard already shows the info
// Display suggested next steps at the end
displaySuggestedNextSteps();
}
/**
* Set the last result for programmatic access
*/
private setLastResult(result: ListTasksResult): void {
this.lastResult = result;
}
/**
* Find the next task to work on
* Implements the same logic as scripts/modules/task-manager/find-next-task.js
*/
private findNextTask(tasks: Task[]): NextTaskInfo | undefined {
const priorityValues: Record<string, number> = {
critical: 4,
high: 3,
medium: 2,
low: 1
};
// Build set of completed task IDs (including subtasks)
const completedIds = new Set<string>();
tasks.forEach((t) => {
if (isTaskComplete(t.status)) {
completedIds.add(String(t.id));
}
if (t.subtasks) {
t.subtasks.forEach((st) => {
if (isTaskComplete(st.status as TaskStatus)) {
completedIds.add(`${t.id}.${st.id}`);
}
});
}
});
// First, look for eligible subtasks in in-progress parent tasks
const candidateSubtasks: NextTaskInfo[] = [];
tasks
.filter(
(t) => t.status === 'in-progress' && t.subtasks && t.subtasks.length > 0
)
.forEach((parent) => {
parent.subtasks!.forEach((st) => {
const stStatus = (st.status || 'pending').toLowerCase();
if (stStatus !== 'pending' && stStatus !== 'in-progress') return;
// Check if dependencies are satisfied
const fullDeps =
st.dependencies?.map((d) => {
// Handle both numeric and string IDs
if (typeof d === 'string' && d.includes('.')) {
return d;
}
return `${parent.id}.${d}`;
}) ?? [];
const depsSatisfied =
fullDeps.length === 0 ||
fullDeps.every((depId) => completedIds.has(String(depId)));
if (depsSatisfied) {
candidateSubtasks.push({
id: `${parent.id}.${st.id}`,
title: st.title || `Subtask ${st.id}`,
priority: st.priority || parent.priority || 'medium',
dependencies: fullDeps.map((d) => String(d))
});
}
});
});
if (candidateSubtasks.length > 0) {
// Sort by priority, then by dependencies count, then by ID
candidateSubtasks.sort((a, b) => {
const pa = priorityValues[a.priority || 'medium'] ?? 2;
const pb = priorityValues[b.priority || 'medium'] ?? 2;
if (pb !== pa) return pb - pa;
const depCountA = a.dependencies?.length || 0;
const depCountB = b.dependencies?.length || 0;
if (depCountA !== depCountB) return depCountA - depCountB;
return String(a.id).localeCompare(String(b.id));
});
return candidateSubtasks[0];
}
// Fall back to finding eligible top-level tasks
const eligibleTasks = tasks.filter((task) => {
// Skip non-eligible statuses
const status = (task.status || 'pending').toLowerCase();
if (status !== 'pending' && status !== 'in-progress') return false;
// Check dependencies
const deps = task.dependencies || [];
const depsSatisfied =
deps.length === 0 ||
deps.every((depId) => completedIds.has(String(depId)));
return depsSatisfied;
});
if (eligibleTasks.length === 0) return undefined;
// Sort eligible tasks
eligibleTasks.sort((a, b) => {
// Priority (higher first)
const pa = priorityValues[a.priority || 'medium'] ?? 2;
const pb = priorityValues[b.priority || 'medium'] ?? 2;
if (pb !== pa) return pb - pa;
// Dependencies count (fewer first)
const depCountA = a.dependencies?.length || 0;
const depCountB = b.dependencies?.length || 0;
if (depCountA !== depCountB) return depCountA - depCountB;
// ID (lower first)
return Number(a.id) - Number(b.id);
});
const nextTask = eligibleTasks[0];
return {
id: nextTask.id,
title: nextTask.title,
priority: nextTask.priority,
dependencies: nextTask.dependencies?.map((d) => String(d))
};
}
/**
* Get the last result (for programmatic usage)
*/
getLastResult(): ListTasksResult | undefined {
return this.lastResult;
}
/**
* Clean up resources
*/
async cleanup(): Promise<void> {
if (this.tmCore) {
this.tmCore = undefined;
}
}
/**
* Register this command on an existing program
*/
static register(program: Command, name?: string): ListTasksCommand {
const listCommand = new ListTasksCommand(name);
program.addCommand(listCommand);
return listCommand;
}
}