feat: create tm-core and apps/cli (#1093)

- add typescript
- add npm workspaces
This commit is contained in:
Ralph Khreish
2025-09-01 21:44:43 +02:00
parent e81040def5
commit dc811eb45e
162 changed files with 22235 additions and 706 deletions

49
apps/cli/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "@tm/cli",
"version": "1.0.0",
"description": "Task Master CLI - Command line interface for task management",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"lint": "biome check src",
"format": "biome format --write src",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tm/core": "*",
"boxen": "^7.1.1",
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
"commander": "^12.1.0",
"inquirer": "^9.2.10",
"ora": "^8.1.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/inquirer": "^9.0.3",
"@types/node": "^22.10.5",
"tsup": "^8.3.0",
"tsx": "^4.20.4",
"typescript": "^5.7.3",
"vitest": "^2.1.8"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": ["task-master", "cli", "task-management", "productivity"],
"author": "",
"license": "MIT"
}

View File

@@ -0,0 +1,327 @@
/**
* @fileoverview ListTasks command using Commander's native class pattern
* Extends Commander.Command for better integration with the framework
*/
import { Command } from 'commander';
import chalk from 'chalk';
import {
createTaskMasterCore,
type Task,
type TaskStatus,
type TaskMasterCore,
TASK_STATUSES,
OUTPUT_FORMATS,
STATUS_ICONS,
type OutputFormat
} from '@tm/core';
import * as ui from '../utils/ui.js';
/**
* Options interface for the list command
*/
export interface ListCommandOptions {
status?: string;
tag?: string;
withSubtasks?: boolean;
format?: OutputFormat;
silent?: boolean;
project?: string;
}
/**
* Result type from list command
*/
export interface ListTasksResult {
tasks: Task[];
total: number;
filtered: number;
tag?: string;
storageType: 'file' | 'api';
}
/**
* ListTasksCommand extending Commander's Command class
* This is a thin presentation layer over @tm/core
*/
export class ListTasksCommand extends Command {
private tmCore?: TaskMasterCore;
private lastResult?: ListTasksResult;
constructor(name?: string) {
super(name || 'list');
// Configure the command
this.description('List tasks with optional filtering')
.alias('ls')
.option('-s, --status <status>', 'Filter by status (comma-separated)')
.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('--silent', 'Suppress output (useful for programmatic usage)')
.option('-p, --project <path>', 'Project root directory', process.cwd())
.action(async (options: ListCommandOptions) => {
await this.executeCommand(options);
});
}
/**
* Execute the list command
*/
private async executeCommand(options: ListCommandOptions): Promise<void> {
try {
// Validate options
if (!this.validateOptions(options)) {
process.exit(1);
}
// Initialize tm-core
await this.initializeCore(options.project || process.cwd());
// Get tasks from core
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) {
const msg = error?.getSanitizedDetails?.() ?? {
message: error?.message ?? String(error)
};
console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`));
if (error.stack && process.env.DEBUG) {
console.error(chalk.gray(error.stack));
}
process.exit(1);
}
}
/**
* 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 TaskMasterCore
*/
private async initializeCore(projectRoot: string): Promise<void> {
if (!this.tmCore) {
this.tmCore = await createTaskMasterCore({ projectPath: projectRoot });
}
}
/**
* Get tasks from tm-core
*/
private async getTasks(
options: ListCommandOptions
): Promise<ListTasksResult> {
if (!this.tmCore) {
throw new Error('TaskMasterCore 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.getTaskList({
tag: options.tag,
filter,
includeSubtasks: options.withSubtasks
});
return result as ListTasksResult;
}
/**
* Display results based on format
*/
private displayResults(
result: ListTasksResult,
options: ListCommandOptions
): void {
const format = (options.format || 'text') as OutputFormat | 'text';
switch (format) {
case 'json':
this.displayJson(result);
break;
case 'compact':
this.displayCompact(result.tasks, options.withSubtasks);
break;
case 'text':
default:
this.displayText(result, options.withSubtasks);
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(tasks: Task[], withSubtasks?: boolean): void {
tasks.forEach((task) => {
const icon = STATUS_ICONS[task.status];
console.log(`${chalk.cyan(task.id)} ${icon} ${task.title}`);
if (withSubtasks && task.subtasks?.length) {
task.subtasks.forEach((subtask) => {
const subIcon = STATUS_ICONS[subtask.status];
console.log(
` ${chalk.gray(`${task.id}.${subtask.id}`)} ${subIcon} ${chalk.gray(subtask.title)}`
);
});
}
});
}
/**
* Display in text format with tables
*/
private displayText(data: ListTasksResult, withSubtasks?: boolean): void {
const { tasks, total, filtered, tag, storageType } = data;
// Header
ui.displayBanner(`Task List${tag ? ` (${tag})` : ''}`);
// Statistics
console.log(chalk.blue.bold('\n📊 Statistics:\n'));
console.log(` Total tasks: ${chalk.cyan(total)}`);
console.log(` Filtered: ${chalk.cyan(filtered)}`);
if (tag) {
console.log(` Tag: ${chalk.cyan(tag)}`);
}
console.log(` Storage: ${chalk.cyan(storageType)}`);
// No tasks message
if (tasks.length === 0) {
ui.displayWarning('No tasks found matching the criteria.');
return;
}
// Task table
console.log(chalk.blue.bold(`\n📋 Tasks (${tasks.length}):\n`));
console.log(
ui.createTaskTable(tasks, {
showSubtasks: withSubtasks,
showDependencies: true
})
);
// Progress bar
const completedCount = tasks.filter(
(t: Task) => t.status === 'done'
).length;
console.log(chalk.blue.bold('\n📊 Overall Progress:\n'));
console.log(` ${ui.createProgressBar(completedCount, tasks.length)}`);
}
/**
* Set the last result for programmatic access
*/
private setLastResult(result: ListTasksResult): void {
this.lastResult = result;
}
/**
* Get the last result (for programmatic usage)
*/
getLastResult(): ListTasksResult | undefined {
return this.lastResult;
}
/**
* Clean up resources
*/
async cleanup(): Promise<void> {
if (this.tmCore) {
await this.tmCore.close();
this.tmCore = undefined;
}
}
/**
* Static method to register this command on an existing program
* This is for gradual migration - allows commands.js to use this
*/
static registerOn(program: Command): Command {
const listCommand = new ListTasksCommand();
program.addCommand(listCommand);
return listCommand;
}
/**
* Alternative registration that returns the command for chaining
* Can also configure the command name if needed
*/
static register(program: Command, name?: string): ListTasksCommand {
const listCommand = new ListTasksCommand(name);
program.addCommand(listCommand);
return listCommand;
}
}

18
apps/cli/src/index.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* @fileoverview Main entry point for @tm/cli package
* Exports all public APIs for the CLI presentation layer
*/
// Commands
export { ListTasksCommand } from './commands/list.command.js';
// UI utilities (for other commands to use)
export * as ui from './utils/ui.js';
// Re-export commonly used types from tm-core
export type {
Task,
TaskStatus,
TaskPriority,
TaskMasterCore
} from '@tm/core';

326
apps/cli/src/utils/ui.ts Normal file
View File

@@ -0,0 +1,326 @@
/**
* @fileoverview UI utilities for Task Master CLI
* Provides formatting, display, and visual components for the command line interface
*/
import chalk from 'chalk';
import boxen from 'boxen';
import Table from 'cli-table3';
import type { Task, TaskStatus, TaskPriority } from '@tm/core';
/**
* Get colored status display with ASCII icons (matches scripts/modules/ui.js style)
*/
export function getStatusWithColor(
status: TaskStatus,
forTable: boolean = false
): string {
const statusConfig = {
done: {
color: chalk.green,
icon: String.fromCharCode(8730),
tableIcon: String.fromCharCode(8730)
}, // √
pending: { color: chalk.yellow, icon: 'o', tableIcon: 'o' },
'in-progress': {
color: chalk.hex('#FFA500'),
icon: String.fromCharCode(9654),
tableIcon: '>'
}, // ▶
deferred: { color: chalk.gray, icon: 'x', tableIcon: 'x' },
blocked: { color: chalk.red, icon: '!', tableIcon: '!' },
review: { color: chalk.magenta, icon: '?', tableIcon: '?' },
cancelled: { color: chalk.gray, icon: 'X', tableIcon: 'X' }
};
const config = statusConfig[status] || {
color: chalk.red,
icon: 'X',
tableIcon: 'X'
};
// Use simple ASCII characters for stable display
const simpleIcons = {
done: String.fromCharCode(8730), // √
pending: 'o',
'in-progress': '>',
deferred: 'x',
blocked: '!',
review: '?',
cancelled: 'X'
};
const icon = forTable ? simpleIcons[status] || 'X' : config.icon;
return config.color(`${icon} ${status}`);
}
/**
* Get colored priority display
*/
export function getPriorityWithColor(priority: TaskPriority): string {
const priorityColors: Record<TaskPriority, (text: string) => string> = {
critical: chalk.red.bold,
high: chalk.red,
medium: chalk.yellow,
low: chalk.gray
};
const colorFn = priorityColors[priority] || chalk.white;
return colorFn(priority);
}
/**
* Get colored complexity display
*/
export function getComplexityWithColor(complexity: number | string): string {
const score =
typeof complexity === 'string' ? parseInt(complexity, 10) : complexity;
if (isNaN(score)) {
return chalk.gray('N/A');
}
if (score >= 8) {
return chalk.red.bold(`${score} (High)`);
} else if (score >= 5) {
return chalk.yellow(`${score} (Medium)`);
} else {
return chalk.green(`${score} (Low)`);
}
}
/**
* Truncate text to specified length
*/
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength - 3) + '...';
}
/**
* Create a progress bar
*/
export function createProgressBar(
completed: number,
total: number,
width: number = 30
): string {
if (total === 0) {
return chalk.gray('No tasks');
}
const percentage = Math.round((completed / total) * 100);
const filled = Math.round((completed / total) * width);
const empty = width - filled;
const bar = chalk.green('█').repeat(filled) + chalk.gray('░').repeat(empty);
return `${bar} ${chalk.cyan(`${percentage}%`)} (${completed}/${total})`;
}
/**
* Display a fancy banner
*/
export function displayBanner(title: string = 'Task Master'): void {
console.log(
boxen(chalk.white.bold(title), {
padding: 1,
margin: { top: 1, bottom: 1 },
borderStyle: 'round',
borderColor: 'blue',
textAlignment: 'center'
})
);
}
/**
* Display an error message (matches scripts/modules/ui.js style)
*/
export function displayError(message: string, details?: string): void {
console.error(
boxen(
chalk.red.bold('X Error: ') +
chalk.white(message) +
(details ? '\n\n' + chalk.gray(details) : ''),
{
padding: 1,
borderStyle: 'round',
borderColor: 'red'
}
)
);
}
/**
* Display a success message
*/
export function displaySuccess(message: string): void {
console.log(
boxen(
chalk.green.bold(String.fromCharCode(8730) + ' ') + chalk.white(message),
{
padding: 1,
borderStyle: 'round',
borderColor: 'green'
}
)
);
}
/**
* Display a warning message
*/
export function displayWarning(message: string): void {
console.log(
boxen(chalk.yellow.bold('⚠ ') + chalk.white(message), {
padding: 1,
borderStyle: 'round',
borderColor: 'yellow'
})
);
}
/**
* Display info message
*/
export function displayInfo(message: string): void {
console.log(
boxen(chalk.blue.bold('i ') + chalk.white(message), {
padding: 1,
borderStyle: 'round',
borderColor: 'blue'
})
);
}
/**
* Format dependencies with their status
*/
export function formatDependenciesWithStatus(
dependencies: string[] | number[],
tasks: Task[]
): string {
if (!dependencies || dependencies.length === 0) {
return chalk.gray('none');
}
const taskMap = new Map(tasks.map((t) => [t.id.toString(), t]));
return dependencies
.map((depId) => {
const task = taskMap.get(depId.toString());
if (!task) {
return chalk.red(`${depId} (not found)`);
}
const statusIcon =
task.status === 'done'
? '✓'
: task.status === 'in-progress'
? '►'
: '○';
return `${depId}${statusIcon}`;
})
.join(', ');
}
/**
* Create a task table for display
*/
export function createTaskTable(
tasks: Task[],
options?: {
showSubtasks?: boolean;
showComplexity?: boolean;
showDependencies?: boolean;
}
): string {
const {
showSubtasks = false,
showComplexity = false,
showDependencies = true
} = options || {};
// Calculate dynamic column widths based on terminal width
const terminalWidth = process.stdout.columns || 100;
const baseColWidths = showComplexity
? [8, Math.floor(terminalWidth * 0.35), 18, 12, 15, 12] // ID, Title, Status, Priority, Dependencies, Complexity
: [8, Math.floor(terminalWidth * 0.4), 18, 12, 20]; // ID, Title, Status, Priority, Dependencies
const headers = [
chalk.blue.bold('ID'),
chalk.blue.bold('Title'),
chalk.blue.bold('Status'),
chalk.blue.bold('Priority')
];
const colWidths = baseColWidths.slice(0, 4);
if (showDependencies) {
headers.push(chalk.blue.bold('Dependencies'));
colWidths.push(baseColWidths[4]);
}
if (showComplexity) {
headers.push(chalk.blue.bold('Complexity'));
colWidths.push(baseColWidths[5] || 12);
}
const table = new Table({
head: headers,
style: { head: [], border: [] },
colWidths,
wordWrap: true
});
tasks.forEach((task) => {
const row: string[] = [
chalk.cyan(task.id.toString()),
truncate(task.title, colWidths[1] - 3),
getStatusWithColor(task.status, true), // Use table version
getPriorityWithColor(task.priority)
];
if (showDependencies) {
row.push(formatDependenciesWithStatus(task.dependencies, tasks));
}
if (showComplexity && 'complexity' in task) {
row.push(getComplexityWithColor(task.complexity as number | string));
}
table.push(row);
// Add subtasks if requested
if (showSubtasks && task.subtasks && task.subtasks.length > 0) {
task.subtasks.forEach((subtask) => {
const subRow: string[] = [
chalk.gray(` └─ ${subtask.id}`),
chalk.gray(truncate(subtask.title, colWidths[1] - 6)),
chalk.gray(getStatusWithColor(subtask.status, true)),
chalk.gray(subtask.priority || 'medium')
];
if (showDependencies) {
subRow.push(
chalk.gray(
subtask.dependencies && subtask.dependencies.length > 0
? subtask.dependencies.map((dep) => String(dep)).join(', ')
: 'None'
)
);
}
if (showComplexity) {
subRow.push(chalk.gray('--'));
}
table.push(subRow);
});
}
});
return table.toString();
}

27
apps/cli/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"allowJs": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}

15
apps/cli/tsup.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
target: 'node18',
splitting: false,
sourcemap: true,
clean: true,
dts: true,
shims: true,
esbuildOptions(options) {
options.platform = 'node';
}
});