feat(cli): add --watch flag to list command for real-time updates (#1526)

Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
Fixes #1526 PR review comments from CodeRabbit and changeset-bot
This commit is contained in:
Eyal Toledano
2025-12-18 14:11:40 -05:00
committed by GitHub
parent 4d1ed20345
commit 38c2c08af1
14 changed files with 447 additions and 28 deletions

View File

@@ -0,0 +1,12 @@
---
"task-master-ai": minor
---
Add watch mode and compact output to list command
- Add `-w/--watch` flag to continuously monitor task changes with real-time updates
- Add `-c/--compact` flag for minimal task output format
- Add `--no-header` flag to hide the command header
- Support file-based watching via fs.watch for local tasks.json
- Support API-based watching via Supabase Realtime for authenticated users
- Display last sync timestamp and source in watch mode

View File

@@ -11,6 +11,7 @@ import {
type Task,
type TaskStatus,
type TmCore,
type WatchSubscription,
createTmCore
} from '@tm/core';
import type { StorageType } from '@tm/core';
@@ -24,6 +25,8 @@ import {
displayDashboards,
displayRecommendedNextTask,
displaySuggestedNextSteps,
displaySyncMessage,
displayWatchFooter,
getPriorityBreakdown,
getTaskDescription
} from '../ui/index.js';
@@ -42,8 +45,11 @@ export interface ListCommandOptions {
withSubtasks?: boolean;
format?: OutputFormat;
json?: boolean;
compact?: boolean;
noHeader?: boolean;
silent?: boolean;
project?: string;
watch?: boolean;
}
/**
@@ -87,11 +93,17 @@ export class ListTasksCommand extends Command {
'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;
@@ -127,20 +139,88 @@ export class ListTasksCommand extends Command {
await this.initializeCore(getProjectRoot(options.project));
// Get tasks from core
const result = await this.getTasks(options);
if (options.watch) {
await this.watchTasks(options);
} else {
const result = await this.getTasks(options);
// Store result for programmatic access
this.setLastResult(result);
// Store result for programmatic access
this.setLastResult(result);
// Display results
if (!options.silent) {
this.displayResults(result, options);
// 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
*/
@@ -219,9 +299,13 @@ export class ListTasksCommand extends Command {
result: ListTasksResult,
options: ListCommandOptions
): void {
// If --json flag is set, override format to 'json'
// Resolve format: --json and --compact flags override --format option
const format = (
options.json ? 'json' : options.format || 'text'
options.json
? 'json'
: options.compact
? 'compact'
: options.format || 'text'
) as OutputFormat;
switch (format) {
@@ -230,12 +314,12 @@ export class ListTasksCommand extends Command {
break;
case 'compact':
this.displayCompact(result.tasks, options.withSubtasks);
this.displayCompact(result, options);
break;
case 'text':
default:
this.displayText(result, options.withSubtasks, options.status);
this.displayText(result, options);
break;
}
}
@@ -264,12 +348,25 @@ export class ListTasksCommand extends Command {
/**
* Display in compact format
*/
private displayCompact(tasks: Task[], withSubtasks?: boolean): void {
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 (withSubtasks && task.subtasks?.length) {
if (options.withSubtasks && task.subtasks?.length) {
task.subtasks.forEach((subtask) => {
const subIcon = STATUS_ICONS[subtask.status];
console.log(
@@ -285,16 +382,17 @@ export class ListTasksCommand extends Command {
*/
private displayText(
data: ListTasksResult,
withSubtasks?: boolean,
_statusFilter?: string
options: ListCommandOptions
): void {
const { tasks, tag, storageType } = data;
// Display header using utility function
displayCommandHeader(this.tmCore, {
tag: tag || 'master',
storageType
});
// 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) {
@@ -328,7 +426,7 @@ export class ListTasksCommand extends Command {
// Task table
console.log(
ui.createTaskTable(tasks, {
showSubtasks: withSubtasks,
showSubtasks: options.withSubtasks,
showDependencies: true,
showComplexity: true // Enable complexity column
})

View File

@@ -9,3 +9,4 @@ export * from './header.component.js';
export * from './next-task.component.js';
export * from './suggested-steps.component.js';
export * from './task-detail.component.js';
export * from './watch-footer.component.js';

View File

@@ -0,0 +1,41 @@
/**
* @fileoverview Watch mode footer component for real-time task updates
*/
import chalk from 'chalk';
import { formatTime } from '@tm/core';
/**
* Get display label for storage source
*/
function getSourceLabel(storageType: 'api' | 'file'): string {
return storageType === 'api' ? 'Hamster Studio' : 'tasks.json';
}
/**
* Display watch status footer
*/
export function displayWatchFooter(
storageType: 'api' | 'file',
lastSync: Date
): void {
const syncTime = formatTime(lastSync);
const source = getSourceLabel(storageType);
console.log(chalk.dim(`\nWatching ${source} for changes...`));
console.log(chalk.gray(`Last synced: ${syncTime}`));
console.log(chalk.dim('Press Ctrl+C to exit'));
}
/**
* Display sync notification message
*/
export function displaySyncMessage(
storageType: 'api' | 'file',
syncTime: Date
): void {
const formattedTime = formatTime(syncTime);
const source = getSourceLabel(storageType);
console.log(chalk.blue(`\n ${source} updated at ${formattedTime}`));
}

View File

@@ -28,6 +28,15 @@ sidebarTitle: "CLI Commands"
# List tasks with a specific status and include subtasks
task-master list --status=<status> --with-subtasks
# Watch for changes and auto-refresh the list
task-master list --watch
task-master list -w
# Combine watch with other options
task-master list all -w # Watch all tasks with subtasks
task-master list -w --compact # Watch in compact format
task-master list --status=pending -w # Watch pending tasks only
```
</Accordion>

View File

@@ -27,6 +27,20 @@ description: "A comprehensive reference of all available Task Master commands"
# List tasks with a specific status and include subtasks
task-master list --status=<status> --with-subtasks
# Watch for changes and auto-refresh
task-master list --watch
task-master list -w
# Compact output format
task-master list --compact
task-master list -c
# Watch mode with compact output
task-master list -w -c
# Hide the header
task-master list --no-header
```
</Accordion>

View File

@@ -101,7 +101,8 @@ This document provides a detailed reference for interacting with Taskmaster, cov
* `withSubtasks`: `Include subtasks indented under their parent tasks in the list.` (CLI: `--with-subtasks`)
* `tag`: `Specify which tag context to list tasks from. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Get an overview of the project status, often used at the start of a work session.
* `watch`: `Watch for changes and auto-refresh the list in real-time. Works with file storage (fs.watch) and API storage (Supabase Realtime).` (CLI: `-w, --watch`)
* **Usage:** Get an overview of the project status, often used at the start of a work session. Use `--watch` to keep the list live-updating as tasks change.
### 4. Get Next Task (`next_task`)

View File

@@ -235,6 +235,17 @@ export interface IStorage {
* @returns Promise that resolves to tags with statistics
*/
getTagsWithStats(): Promise<TagsWithStatsResult>;
/**
* Watch for changes to tasks
* @param callback - Function called when tasks change
* @param options - Watch options (debounce, tag)
* @returns Subscription handle with unsubscribe method
*/
watch(
callback: (event: WatchEvent) => void,
options?: WatchOptions
): Promise<WatchSubscription>;
}
/**
@@ -282,6 +293,41 @@ export interface TagsWithStatsResult {
totalTags: number;
}
/**
* Watch event types
*/
export type WatchEventType = 'change' | 'error';
/**
* Watch event payload
*/
export interface WatchEvent {
/** Type of event */
type: WatchEventType;
/** Timestamp of the event */
timestamp: Date;
/** Error if type is 'error' */
error?: Error;
}
/**
* Watch options
*/
export interface WatchOptions {
/** Debounce time in milliseconds (default: 100) */
debounceMs?: number;
/** Tag context for file storage */
tag?: string;
}
/**
* Watch subscription handle
*/
export interface WatchSubscription {
/** Unsubscribe from watch events */
unsubscribe: () => void;
}
/**
* Storage statistics interface
*/
@@ -380,6 +426,10 @@ export abstract class BaseStorage implements IStorage {
abstract getStorageType(): 'file' | 'api';
abstract getCurrentBriefName(): string | null;
abstract getTagsWithStats(): Promise<TagsWithStatsResult>;
abstract watch(
callback: (event: WatchEvent) => void,
options?: WatchOptions
): Promise<WatchSubscription>;
/**
* Utility method to generate backup filename
* @param originalPath - Original file path

View File

@@ -36,10 +36,14 @@ export type * from './common/types/index.js';
// Common interfaces
export type * from './common/interfaces/index.js';
// Storage interfaces - TagInfo and TagsWithStatsResult
// Storage interfaces - TagInfo, TagsWithStatsResult, and Watch types
export type {
TagInfo,
TagsWithStatsResult
TagsWithStatsResult,
WatchEvent,
WatchEventType,
WatchOptions,
WatchSubscription
} from './common/interfaces/storage.interface.js';
// Storage adapters - FileStorage for direct local file access

View File

@@ -12,7 +12,10 @@ import type {
IStorage,
LoadTasksOptions,
StorageStats,
UpdateStatusResult
UpdateStatusResult,
WatchEvent,
WatchOptions,
WatchSubscription
} from '../../../common/interfaces/storage.interface.js';
import { getLogger } from '../../../common/logger/factory.js';
import type {
@@ -941,6 +944,92 @@ export class ApiStorage implements IStorage {
this.tagsCache.clear();
}
/**
* Watch for changes to tasks via Supabase Realtime
* Subscribes to postgres_changes for the tasks table filtered by brief_id
*
* Optional debouncing is supported for collaborative scenarios where multiple
* rapid changes (e.g., bulk operations, multiple users) could cause UI flicker.
* Default: 300ms (higher than FileStorage's 100ms since Realtime events are cleaner)
*/
async watch(
callback: (event: WatchEvent) => void,
options?: WatchOptions
): Promise<WatchSubscription> {
await this.ensureInitialized();
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
if (!context?.briefId) {
throw new TaskMasterError(
'No brief context found for watching. Please connect to a brief first.',
ERROR_CODES.NO_BRIEF_SELECTED,
{ operation: 'watch' }
);
}
const supabase = authManager.supabaseClient.getClient();
const channelName = `tasks-watch-${context.briefId}-${Date.now()}`;
const debounceMs = options?.debounceMs ?? 300;
let debounceTimer: NodeJS.Timeout | undefined;
let closed = false;
const debouncedCallback = () => {
if (closed) return;
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
if (!closed) {
callback({
type: 'change',
timestamp: new Date()
});
}
}, debounceMs);
};
const channel = supabase
.channel(channelName)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'tasks',
filter: `brief_id=eq.${context.briefId}`
},
debouncedCallback
)
.subscribe((status, error) => {
if (
status === 'CHANNEL_ERROR' ||
status === 'TIMED_OUT' ||
status === 'CLOSED' ||
error
) {
callback({
type: 'error',
timestamp: new Date(),
error:
error || new Error(`Channel subscription ${status.toLowerCase()}`)
});
}
});
return {
unsubscribe: () => {
closed = true;
if (debounceTimer) {
clearTimeout(debounceTimer);
}
channel.unsubscribe();
}
};
}
/**
* Ensure storage is initialized
*/

View File

@@ -2,6 +2,7 @@
* @fileoverview Refactored file-based storage implementation for Task Master
*/
import fs from 'node:fs';
import path from 'node:path';
import {
ERROR_CODES,
@@ -11,7 +12,10 @@ import type {
IStorage,
LoadTasksOptions,
StorageStats,
UpdateStatusResult
UpdateStatusResult,
WatchEvent,
WatchOptions,
WatchSubscription
} from '../../../../common/interfaces/storage.interface.js';
import type {
Task,
@@ -882,6 +886,68 @@ export class FileStorage implements IStorage {
}
}
/**
* Watch for changes to tasks file
* Uses fs.watch with debouncing to detect file changes
*/
async watch(
callback: (event: WatchEvent) => void,
options?: WatchOptions
): Promise<WatchSubscription> {
const tasksPath = this.pathResolver.getTasksPath();
const debounceMs = options?.debounceMs ?? 100;
// Ensure file exists before watching
const fileExists = await this.fileOps.exists(tasksPath);
if (!fileExists) {
throw new TaskMasterError(
'Tasks file not found. Initialize the project first.',
ERROR_CODES.NOT_FOUND,
{ path: tasksPath }
);
}
let debounceTimer: NodeJS.Timeout | undefined;
let closed = false;
const watcher = fs.watch(tasksPath, (eventType, filename) => {
if (closed) return;
if (filename && eventType === 'change') {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
if (!closed) {
callback({
type: 'change',
timestamp: new Date()
});
}
}, debounceMs);
}
});
watcher.on('error', (error) => {
if (!closed) {
callback({
type: 'error',
timestamp: new Date(),
error
});
}
});
return {
unsubscribe: () => {
closed = true;
if (debounceTimer) {
clearTimeout(debounceTimer);
}
watcher.close();
}
};
}
/**
* Enrich tasks with complexity data from the complexity report
* Private helper method called by loadTasks()

View File

@@ -37,7 +37,8 @@ function createMockStorage(tasks: Task[] = []): IStorage {
getStats: vi.fn(),
getStorageType: vi.fn().mockReturnValue('file'),
getCurrentBriefName: vi.fn().mockReturnValue(null),
getTagsWithStats: vi.fn()
getTagsWithStats: vi.fn(),
watch: vi.fn().mockResolvedValue({ unsubscribe: vi.fn() })
};
}

View File

@@ -36,6 +36,11 @@ import type {
} from './services/preflight-checker.service.js';
import type { TaskValidationResult } from './services/task-loader.service.js';
import type { ExpandTaskResult } from '../integration/services/task-expansion.service.js';
import type {
WatchEvent,
WatchOptions,
WatchSubscription
} from '../../common/interfaces/storage.interface.js';
/**
* Tasks Domain - Unified API for all task operations
@@ -389,6 +394,25 @@ export class TasksDomain {
return this.taskService.getStorageType();
}
// ========== Watch ==========
/**
* Watch for changes to tasks
* For file storage: uses fs.watch on tasks.json with debouncing
* For API storage: uses Supabase Realtime subscriptions
*
* @param callback - Function called when tasks change
* @param options - Watch options (debounce, tag)
* @returns Subscription handle with unsubscribe method
*/
async watch(
callback: (event: WatchEvent) => void,
options?: WatchOptions
): Promise<WatchSubscription> {
const storage = this.taskService.getStorage();
return storage.watch(callback, options);
}
// ========== Task File Generation ==========
/**

View File

@@ -1,9 +1,9 @@
/**
* @fileoverview Time utilities for formatting relative timestamps
* @fileoverview Time utilities for formatting timestamps
* Shared across CLI, MCP, extension, and other interfaces
*/
import { formatDistanceToNow } from 'date-fns';
import { format, formatDistanceToNow } from 'date-fns';
/**
* Format a date as relative time from now (e.g., "2 hours ago", "3 days ago")
@@ -16,3 +16,12 @@ export function formatRelativeTime(date: string | Date): string {
// Use date-fns for robust formatting with proper edge case handling
return formatDistanceToNow(dateObj, { addSuffix: true });
}
/**
* Format a date as a time string (e.g., "02:30:45 PM")
* @param date - Date object to format
* @returns Formatted time string in 12-hour format with seconds
*/
export function formatTime(date: Date): string {
return format(date, 'hh:mm:ss a');
}