diff --git a/apps/cli/src/command-registry.ts b/apps/cli/src/command-registry.ts new file mode 100644 index 00000000..fffc8379 --- /dev/null +++ b/apps/cli/src/command-registry.ts @@ -0,0 +1,255 @@ +/** + * @fileoverview Centralized Command Registry + * Provides a single location for registering all CLI commands + */ + +import { Command } from 'commander'; + +// Import all commands +import { ListTasksCommand } from './commands/list.command.js'; +import { ShowCommand } from './commands/show.command.js'; +import { AuthCommand } from './commands/auth.command.js'; +import { ContextCommand } from './commands/context.command.js'; +import { StartCommand } from './commands/start.command.js'; +import { SetStatusCommand } from './commands/set-status.command.js'; +import { ExportCommand } from './commands/export.command.js'; + +/** + * Command metadata for registration + */ +export interface CommandMetadata { + name: string; + description: string; + commandClass: typeof Command; + category?: 'task' | 'auth' | 'utility' | 'development'; +} + +/** + * Registry of all available commands + */ +export class CommandRegistry { + /** + * All available commands with their metadata + */ + private static commands: CommandMetadata[] = [ + // Task Management Commands + { + name: 'list', + description: 'List all tasks with filtering and status overview', + commandClass: ListTasksCommand as any, + category: 'task' + }, + { + name: 'show', + description: 'Display detailed information about a specific task', + commandClass: ShowCommand as any, + category: 'task' + }, + { + name: 'start', + description: 'Start working on a task with claude-code', + commandClass: StartCommand as any, + category: 'task' + }, + { + name: 'set-status', + description: 'Update the status of one or more tasks', + commandClass: SetStatusCommand as any, + category: 'task' + }, + { + name: 'export', + description: 'Export tasks to external systems', + commandClass: ExportCommand as any, + category: 'task' + }, + + // Authentication & Context Commands + { + name: 'auth', + description: 'Manage authentication with tryhamster.com', + commandClass: AuthCommand as any, + category: 'auth' + }, + { + name: 'context', + description: 'Manage workspace context (organization/brief)', + commandClass: ContextCommand as any, + category: 'auth' + } + ]; + + /** + * Register all commands on a program instance + * @param program - Commander program to register commands on + */ + static registerAll(program: Command): void { + for (const cmd of this.commands) { + this.registerCommand(program, cmd); + } + } + + /** + * Register specific commands by category + * @param program - Commander program to register commands on + * @param category - Category of commands to register + */ + static registerByCategory( + program: Command, + category: 'task' | 'auth' | 'utility' | 'development' + ): void { + const categoryCommands = this.commands.filter( + (cmd) => cmd.category === category + ); + + for (const cmd of categoryCommands) { + this.registerCommand(program, cmd); + } + } + + /** + * Register a single command by name + * @param program - Commander program to register the command on + * @param name - Name of the command to register + */ + static registerByName(program: Command, name: string): void { + const cmd = this.commands.find((c) => c.name === name); + if (cmd) { + this.registerCommand(program, cmd); + } else { + throw new Error(`Command '${name}' not found in registry`); + } + } + + /** + * Register a single command + * @param program - Commander program to register the command on + * @param metadata - Command metadata + */ + private static registerCommand( + program: Command, + metadata: CommandMetadata + ): void { + const CommandClass = metadata.commandClass as any; + + // Use the static registration method that all commands have + if (CommandClass.registerOn) { + CommandClass.registerOn(program); + } else if (CommandClass.register) { + CommandClass.register(program); + } else { + // Fallback to creating instance and adding + const instance = new CommandClass(); + program.addCommand(instance); + } + } + + /** + * Get all registered command names + */ + static getCommandNames(): string[] { + return this.commands.map((cmd) => cmd.name); + } + + /** + * Get commands by category + */ + static getCommandsByCategory( + category: 'task' | 'auth' | 'utility' | 'development' + ): CommandMetadata[] { + return this.commands.filter((cmd) => cmd.category === category); + } + + /** + * Add a new command to the registry + * @param metadata - Command metadata to add + */ + static addCommand(metadata: CommandMetadata): void { + // Check if command already exists + if (this.commands.some((cmd) => cmd.name === metadata.name)) { + throw new Error(`Command '${metadata.name}' already exists in registry`); + } + + this.commands.push(metadata); + } + + /** + * Remove a command from the registry + * @param name - Name of the command to remove + */ + static removeCommand(name: string): boolean { + const index = this.commands.findIndex((cmd) => cmd.name === name); + if (index >= 0) { + this.commands.splice(index, 1); + return true; + } + return false; + } + + /** + * Get command metadata by name + * @param name - Name of the command + */ + static getCommand(name: string): CommandMetadata | undefined { + return this.commands.find((cmd) => cmd.name === name); + } + + /** + * Check if a command exists + * @param name - Name of the command + */ + static hasCommand(name: string): boolean { + return this.commands.some((cmd) => cmd.name === name); + } + + /** + * Get a formatted list of all commands for display + */ + static getFormattedCommandList(): string { + const categories = { + task: 'Task Management', + auth: 'Authentication & Context', + utility: 'Utilities', + development: 'Development' + }; + + let output = ''; + + for (const [category, title] of Object.entries(categories)) { + const cmds = this.getCommandsByCategory( + category as keyof typeof categories + ); + if (cmds.length > 0) { + output += `\n${title}:\n`; + for (const cmd of cmds) { + output += ` ${cmd.name.padEnd(20)} ${cmd.description}\n`; + } + } + } + + return output; + } +} + +/** + * Convenience function to register all CLI commands + * @param program - Commander program instance + */ +export function registerAllCommands(program: Command): void { + CommandRegistry.registerAll(program); +} + +/** + * Convenience function to register commands by category + * @param program - Commander program instance + * @param category - Category to register + */ +export function registerCommandsByCategory( + program: Command, + category: 'task' | 'auth' | 'utility' | 'development' +): void { + CommandRegistry.registerByCategory(program, category); +} + +// Export the registry for direct access if needed +export default CommandRegistry; diff --git a/apps/cli/src/commands/auth.command.ts b/apps/cli/src/commands/auth.command.ts index c79ff6bb..8053311d 100644 --- a/apps/cli/src/commands/auth.command.ts +++ b/apps/cli/src/commands/auth.command.ts @@ -493,18 +493,7 @@ export class AuthCommand extends Command { } /** - * 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 authCommand = new AuthCommand(); - program.addCommand(authCommand); - return authCommand; - } - - /** - * Alternative registration that returns the command for chaining - * Can also configure the command name if needed + * Register this command on an existing program */ static register(program: Command, name?: string): AuthCommand { const authCommand = new AuthCommand(name); diff --git a/apps/cli/src/commands/context.command.ts b/apps/cli/src/commands/context.command.ts index e4c0a73f..04d475f3 100644 --- a/apps/cli/src/commands/context.command.ts +++ b/apps/cli/src/commands/context.command.ts @@ -694,16 +694,7 @@ export class ContextCommand extends Command { } /** - * Static method to register this command on an existing program - */ - static registerOn(program: Command): Command { - const contextCommand = new ContextCommand(); - program.addCommand(contextCommand); - return contextCommand; - } - - /** - * Alternative registration that returns the command for chaining + * Register this command on an existing program */ static register(program: Command, name?: string): ContextCommand { const contextCommand = new ContextCommand(name); diff --git a/apps/cli/src/commands/export.command.ts b/apps/cli/src/commands/export.command.ts new file mode 100644 index 00000000..0d09efe3 --- /dev/null +++ b/apps/cli/src/commands/export.command.ts @@ -0,0 +1,379 @@ +/** + * @fileoverview Export command for exporting tasks to external systems + * Provides functionality to export tasks to Hamster briefs + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import ora, { Ora } from 'ora'; +import { + AuthManager, + AuthenticationError, + type UserContext +} from '@tm/core/auth'; +import { TaskMasterCore, type ExportResult } from '@tm/core'; +import * as ui from '../utils/ui.js'; + +/** + * Result type from export command + */ +export interface ExportCommandResult { + success: boolean; + action: 'export' | 'validate' | 'cancelled'; + result?: ExportResult; + message?: string; +} + +/** + * ExportCommand extending Commander's Command class + * Handles task export to external systems + */ +export class ExportCommand extends Command { + private authManager: AuthManager; + private taskMasterCore?: TaskMasterCore; + private lastResult?: ExportCommandResult; + + constructor(name?: string) { + super(name || 'export'); + + // Initialize auth manager + this.authManager = AuthManager.getInstance(); + + // Configure the command + this.description('Export tasks to external systems (e.g., Hamster briefs)'); + + // Add options + this.option('--org ', 'Organization ID to export to'); + this.option('--brief ', 'Brief ID to export tasks to'); + this.option('--tag ', 'Export tasks from a specific tag'); + this.option( + '--status ', + 'Filter tasks by status (pending, in-progress, done, etc.)' + ); + this.option('--exclude-subtasks', 'Exclude subtasks from export'); + this.option('-y, --yes', 'Skip confirmation prompt'); + + // Accept optional positional argument for brief ID or Hamster URL + this.argument('[briefOrUrl]', 'Brief ID or Hamster brief URL'); + + // Default action + this.action(async (briefOrUrl?: string, options?: any) => { + await this.executeExport(briefOrUrl, options); + }); + } + + /** + * Initialize the TaskMasterCore + */ + private async initializeServices(): Promise { + if (this.taskMasterCore) { + return; + } + + try { + // Initialize TaskMasterCore + this.taskMasterCore = await TaskMasterCore.create({ + projectPath: process.cwd() + }); + } catch (error) { + throw new Error( + `Failed to initialize services: ${(error as Error).message}` + ); + } + } + + /** + * Execute the export command + */ + private async executeExport( + briefOrUrl?: string, + options?: any + ): Promise { + let spinner: Ora | undefined; + + try { + // Check authentication + if (!this.authManager.isAuthenticated()) { + ui.displayError('Not authenticated. Run "tm auth login" first.'); + process.exit(1); + } + + // Initialize services + await this.initializeServices(); + + // Get current context + const context = this.authManager.getContext(); + + // Determine org and brief IDs + let orgId = options?.org || context?.orgId; + let briefId = options?.brief || briefOrUrl || context?.briefId; + + // If a URL/ID was provided as argument, resolve it + if (briefOrUrl && !options?.brief) { + spinner = ora('Resolving brief...').start(); + const resolvedBrief = await this.resolveBriefInput(briefOrUrl); + if (resolvedBrief) { + briefId = resolvedBrief.briefId; + orgId = resolvedBrief.orgId; + spinner.succeed('Brief resolved'); + } else { + spinner.fail('Could not resolve brief'); + process.exit(1); + } + } + + // Validate we have necessary IDs + if (!orgId) { + ui.displayError( + 'No organization selected. Run "tm context org" or use --org flag.' + ); + process.exit(1); + } + + if (!briefId) { + ui.displayError( + 'No brief specified. Run "tm context brief", provide a brief ID/URL, or use --brief flag.' + ); + process.exit(1); + } + + // Confirm export if not auto-confirmed + if (!options?.yes) { + const confirmed = await this.confirmExport(orgId, briefId, context); + if (!confirmed) { + ui.displayWarning('Export cancelled'); + this.lastResult = { + success: false, + action: 'cancelled', + message: 'User cancelled export' + }; + process.exit(0); + } + } + + // Perform export + spinner = ora('Exporting tasks...').start(); + + const exportResult = await this.taskMasterCore!.exportTasks({ + orgId, + briefId, + tag: options?.tag, + status: options?.status, + excludeSubtasks: options?.excludeSubtasks || false + }); + + if (exportResult.success) { + spinner.succeed( + `Successfully exported ${exportResult.taskCount} task(s) to brief` + ); + + // Display summary + console.log(chalk.cyan('\n📤 Export Summary\n')); + console.log(chalk.white(` Organization: ${orgId}`)); + console.log(chalk.white(` Brief: ${briefId}`)); + console.log(chalk.white(` Tasks exported: ${exportResult.taskCount}`)); + if (options?.tag) { + console.log(chalk.gray(` Tag: ${options.tag}`)); + } + if (options?.status) { + console.log(chalk.gray(` Status filter: ${options.status}`)); + } + + if (exportResult.message) { + console.log(chalk.gray(`\n ${exportResult.message}`)); + } + } else { + spinner.fail('Export failed'); + if (exportResult.error) { + console.error(chalk.red(`\n✗ ${exportResult.error.message}`)); + } + } + + this.lastResult = { + success: exportResult.success, + action: 'export', + result: exportResult + }; + } catch (error: any) { + if (spinner?.isSpinning) spinner.fail('Export failed'); + this.handleError(error); + process.exit(1); + } + } + + /** + * Resolve brief input to get brief and org IDs + */ + private async resolveBriefInput( + briefOrUrl: string + ): Promise<{ briefId: string; orgId: string } | null> { + try { + // Extract brief ID from input + const briefId = this.extractBriefId(briefOrUrl); + if (!briefId) { + return null; + } + + // Fetch brief to get organization + const brief = await this.authManager.getBrief(briefId); + if (!brief) { + ui.displayError('Brief not found or you do not have access'); + return null; + } + + return { + briefId: brief.id, + orgId: brief.accountId + }; + } catch (error) { + console.error(chalk.red(`Failed to resolve brief: ${error}`)); + return null; + } + } + + /** + * Extract a brief ID from raw input (ID or URL) + */ + private extractBriefId(input: string): string | null { + const raw = input?.trim() ?? ''; + if (!raw) return null; + + const parseUrl = (s: string): URL | null => { + try { + return new URL(s); + } catch {} + try { + return new URL(`https://${s}`); + } catch {} + return null; + }; + + const fromParts = (path: string): string | null => { + const parts = path.split('/').filter(Boolean); + const briefsIdx = parts.lastIndexOf('briefs'); + const candidate = + briefsIdx >= 0 && parts.length > briefsIdx + 1 + ? parts[briefsIdx + 1] + : parts[parts.length - 1]; + return candidate?.trim() || null; + }; + + // Try URL parsing + const url = parseUrl(raw); + if (url) { + const qId = url.searchParams.get('id') || url.searchParams.get('briefId'); + const candidate = (qId || fromParts(url.pathname)) ?? null; + if (candidate) { + if (this.isLikelyId(candidate) || candidate.length >= 8) { + return candidate; + } + } + } + + // Check if it looks like a path + if (raw.includes('/')) { + const candidate = fromParts(raw); + if (candidate && (this.isLikelyId(candidate) || candidate.length >= 8)) { + return candidate; + } + } + + // Return raw if it looks like an ID + return raw; + } + + /** + * Check if a string looks like a brief ID + */ + private isLikelyId(value: string): boolean { + const uuidRegex = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i; + const slugRegex = /^[A-Za-z0-9_-]{16,}$/; + return ( + uuidRegex.test(value) || ulidRegex.test(value) || slugRegex.test(value) + ); + } + + /** + * Confirm export with the user + */ + private async confirmExport( + orgId: string, + briefId: string, + context: UserContext | null + ): Promise { + console.log(chalk.cyan('\n📤 Export Tasks\n')); + + // Show org name if available + if (context?.orgName) { + console.log(chalk.white(` Organization: ${context.orgName}`)); + console.log(chalk.gray(` ID: ${orgId}`)); + } else { + console.log(chalk.white(` Organization ID: ${orgId}`)); + } + + // Show brief info + if (context?.briefName) { + console.log(chalk.white(`\n Brief: ${context.briefName}`)); + console.log(chalk.gray(` ID: ${briefId}`)); + } else { + console.log(chalk.white(`\n Brief ID: ${briefId}`)); + } + + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message: 'Do you want to proceed with export?', + default: true + } + ]); + + return confirmed; + } + + /** + * Handle errors + */ + private handleError(error: any): void { + if (error instanceof AuthenticationError) { + console.error(chalk.red(`\n✗ ${error.message}`)); + + if (error.code === 'NOT_AUTHENTICATED') { + ui.displayWarning('Please authenticate first: tm auth login'); + } + } else { + const msg = error?.message ?? String(error); + console.error(chalk.red(`Error: ${msg}`)); + + if (error.stack && process.env.DEBUG) { + console.error(chalk.gray(error.stack)); + } + } + } + + /** + * Get the last export result (useful for testing) + */ + public getLastResult(): ExportCommandResult | undefined { + return this.lastResult; + } + + /** + * Clean up resources + */ + async cleanup(): Promise { + // No resources to clean up + } + + /** + * Register this command on an existing program + */ + static register(program: Command, name?: string): ExportCommand { + const exportCommand = new ExportCommand(name); + program.addCommand(exportCommand); + return exportCommand; + } +} diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index a3ce900e..38e4184b 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -474,18 +474,7 @@ export class ListTasksCommand extends Command { } /** - * 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 + * Register this command on an existing program */ static register(program: Command, name?: string): ListTasksCommand { const listCommand = new ListTasksCommand(name); diff --git a/apps/cli/src/commands/set-status.command.ts b/apps/cli/src/commands/set-status.command.ts index 960febba..25dc40bd 100644 --- a/apps/cli/src/commands/set-status.command.ts +++ b/apps/cli/src/commands/set-status.command.ts @@ -287,18 +287,7 @@ export class SetStatusCommand extends Command { } /** - * 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 setStatusCommand = new SetStatusCommand(); - program.addCommand(setStatusCommand); - return setStatusCommand; - } - - /** - * Alternative registration that returns the command for chaining - * Can also configure the command name if needed + * Register this command on an existing program */ static register(program: Command, name?: string): SetStatusCommand { const setStatusCommand = new SetStatusCommand(name); diff --git a/apps/cli/src/commands/show.command.ts b/apps/cli/src/commands/show.command.ts index ba19779c..f41cb786 100644 --- a/apps/cli/src/commands/show.command.ts +++ b/apps/cli/src/commands/show.command.ts @@ -322,18 +322,7 @@ export class ShowCommand extends Command { } /** - * 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 showCommand = new ShowCommand(); - program.addCommand(showCommand); - return showCommand; - } - - /** - * Alternative registration that returns the command for chaining - * Can also configure the command name if needed + * Register this command on an existing program */ static register(program: Command, name?: string): ShowCommand { const showCommand = new ShowCommand(name); diff --git a/apps/cli/src/commands/start.command.ts b/apps/cli/src/commands/start.command.ts index f5ca0a5b..ec82c2e5 100644 --- a/apps/cli/src/commands/start.command.ts +++ b/apps/cli/src/commands/start.command.ts @@ -493,16 +493,7 @@ export class StartCommand extends Command { } /** - * Static method to register this command on an existing program - */ - static registerOn(program: Command): Command { - const startCommand = new StartCommand(); - program.addCommand(startCommand); - return startCommand; - } - - /** - * Alternative registration that returns the command for chaining + * Register this command on an existing program */ static register(program: Command, name?: string): StartCommand { const startCommand = new StartCommand(name); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index ce0405ee..fbf8757a 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -10,6 +10,15 @@ export { AuthCommand } from './commands/auth.command.js'; export { ContextCommand } from './commands/context.command.js'; export { StartCommand } from './commands/start.command.js'; export { SetStatusCommand } from './commands/set-status.command.js'; +export { ExportCommand } from './commands/export.command.js'; + +// Command Registry +export { + CommandRegistry, + registerAllCommands, + registerCommandsByCategory, + type CommandMetadata +} from './command-registry.js'; // UI utilities (for other commands to use) export * as ui from './utils/ui.js'; diff --git a/packages/tm-core/src/errors/task-master-error.ts b/packages/tm-core/src/errors/task-master-error.ts index f2dbb710..545e6353 100644 --- a/packages/tm-core/src/errors/task-master-error.ts +++ b/packages/tm-core/src/errors/task-master-error.ts @@ -51,7 +51,8 @@ export const ERROR_CODES = { INTERNAL_ERROR: 'INTERNAL_ERROR', INVALID_INPUT: 'INVALID_INPUT', NOT_IMPLEMENTED: 'NOT_IMPLEMENTED', - UNKNOWN_ERROR: 'UNKNOWN_ERROR' + UNKNOWN_ERROR: 'UNKNOWN_ERROR', + NOT_FOUND: 'NOT_FOUND' } as const; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; diff --git a/packages/tm-core/src/index.ts b/packages/tm-core/src/index.ts index 7702b868..0f96f694 100644 --- a/packages/tm-core/src/index.ts +++ b/packages/tm-core/src/index.ts @@ -11,7 +11,9 @@ export { type ListTasksResult, type StartTaskOptions, type StartTaskResult, - type ConflictCheckResult + type ConflictCheckResult, + type ExportTasksOptions, + type ExportResult } from './task-master-core.js'; // Re-export types diff --git a/packages/tm-core/src/interfaces/storage.interface.ts b/packages/tm-core/src/interfaces/storage.interface.ts index ee7297f5..14f0a148 100644 --- a/packages/tm-core/src/interfaces/storage.interface.ts +++ b/packages/tm-core/src/interfaces/storage.interface.ts @@ -5,6 +5,16 @@ import type { Task, TaskMetadata, TaskStatus } from '../types/index.js'; +/** + * Options for loading tasks from storage + */ +export interface LoadTasksOptions { + /** Filter tasks by status */ + status?: TaskStatus; + /** Exclude subtasks from loaded tasks (default: false) */ + excludeSubtasks?: boolean; +} + /** * Result type for updateTaskStatus operations */ @@ -21,11 +31,12 @@ export interface UpdateStatusResult { */ export interface IStorage { /** - * Load all tasks from storage, optionally filtered by tag + * Load all tasks from storage, optionally filtered by tag and other criteria * @param tag - Optional tag to filter tasks by + * @param options - Optional filtering options (status, excludeSubtasks) * @returns Promise that resolves to an array of tasks */ - loadTasks(tag?: string): Promise; + loadTasks(tag?: string, options?: LoadTasksOptions): Promise; /** * Load a single task by ID @@ -205,7 +216,7 @@ export abstract class BaseStorage implements IStorage { } // Abstract methods that must be implemented by concrete classes - abstract loadTasks(tag?: string): Promise; + abstract loadTasks(tag?: string, options?: LoadTasksOptions): Promise; abstract loadTask(taskId: string, tag?: string): Promise; abstract saveTasks(tasks: Task[], tag?: string): Promise; abstract appendTasks(tasks: Task[], tag?: string): Promise; diff --git a/packages/tm-core/src/repositories/supabase/supabase-task-repository.ts b/packages/tm-core/src/repositories/supabase/supabase-task-repository.ts index 14a26594..925de209 100644 --- a/packages/tm-core/src/repositories/supabase/supabase-task-repository.ts +++ b/packages/tm-core/src/repositories/supabase/supabase-task-repository.ts @@ -8,6 +8,7 @@ import { TaskWithRelations, TaskDatabaseUpdate } from '../../types/repository-types.js'; +import { LoadTasksOptions } from '../../interfaces/storage.interface.js'; import { z } from 'zod'; // Zod schema for task status validation @@ -56,11 +57,14 @@ export class SupabaseTaskRepository { return context.briefId; } - async getTasks(_projectId?: string): Promise { + async getTasks( + _projectId?: string, + options?: LoadTasksOptions + ): Promise { const briefId = this.getBriefIdOrThrow(); - // Get all tasks for the brief using the exact query structure - const { data: tasks, error } = await this.supabase + // Build query with filters + let query = this.supabase .from('tasks') .select(` *, @@ -71,7 +75,22 @@ export class SupabaseTaskRepository { description ) `) - .eq('brief_id', briefId) + .eq('brief_id', briefId); + + // Apply status filter at database level if specified + if (options?.status) { + const dbStatus = this.mapStatusToDatabase(options.status); + query = query.eq('status', dbStatus); + } + + // Apply subtask exclusion at database level if specified + if (options?.excludeSubtasks) { + // Only fetch parent tasks (where parent_task_id is null) + query = query.is('parent_task_id', null); + } + + // Execute query with ordering + const { data: tasks, error } = await query .order('position', { ascending: true }) .order('subtask_position', { ascending: true }) .order('created_at', { ascending: true }); diff --git a/packages/tm-core/src/repositories/task-repository.interface.ts b/packages/tm-core/src/repositories/task-repository.interface.ts index d256e551..0d5928d3 100644 --- a/packages/tm-core/src/repositories/task-repository.interface.ts +++ b/packages/tm-core/src/repositories/task-repository.interface.ts @@ -1,8 +1,9 @@ import { Task, TaskTag } from '../types/index.js'; +import { LoadTasksOptions } from '../interfaces/storage.interface.js'; export interface TaskRepository { // Task operations - getTasks(projectId: string): Promise; + getTasks(projectId: string, options?: LoadTasksOptions): Promise; getTask(projectId: string, taskId: string): Promise; createTask(projectId: string, task: Omit): Promise; updateTask( diff --git a/packages/tm-core/src/services/export.service.ts b/packages/tm-core/src/services/export.service.ts new file mode 100644 index 00000000..03037dbb --- /dev/null +++ b/packages/tm-core/src/services/export.service.ts @@ -0,0 +1,496 @@ +/** + * @fileoverview Export Service + * Core service for exporting tasks to external systems (e.g., Hamster briefs) + */ + +import type { Task, TaskStatus } from '../types/index.js'; +import type { UserContext } from '../auth/types.js'; +import { ConfigManager } from '../config/config-manager.js'; +import { AuthManager } from '../auth/auth-manager.js'; +import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js'; +import { FileStorage } from '../storage/file-storage/index.js'; + +// Type definitions for the bulk API response +interface TaskImportResult { + externalId?: string; + index: number; + success: boolean; + taskId?: string; + error?: string; + validationErrors?: string[]; +} + +interface BulkTasksResponse { + dryRun: boolean; + totalTasks: number; + successCount: number; + failedCount: number; + skippedCount: number; + results: TaskImportResult[]; + summary: { + message: string; + duration: number; + }; +} + +/** + * Options for exporting tasks + */ +export interface ExportTasksOptions { + /** Optional tag to export tasks from (uses active tag if not provided) */ + tag?: string; + /** Brief ID to export to */ + briefId?: string; + /** Organization ID (required if briefId is provided) */ + orgId?: string; + /** Filter by task status */ + status?: TaskStatus; + /** Exclude subtasks from export (default: false, subtasks included by default) */ + excludeSubtasks?: boolean; +} + +/** + * Result of the export operation + */ +export interface ExportResult { + /** Whether the export was successful */ + success: boolean; + /** Number of tasks exported */ + taskCount: number; + /** The brief ID tasks were exported to */ + briefId: string; + /** The organization ID */ + orgId: string; + /** Optional message */ + message?: string; + /** Error details if export failed */ + error?: { + code: string; + message: string; + }; +} + +/** + * Brief information from API + */ +export interface Brief { + id: string; + accountId: string; + createdAt: string; + name?: string; +} + +/** + * ExportService handles task export to external systems + */ +export class ExportService { + private configManager: ConfigManager; + private authManager: AuthManager; + + constructor(configManager: ConfigManager, authManager: AuthManager) { + this.configManager = configManager; + this.authManager = authManager; + } + + /** + * Export tasks to a brief + */ + async exportTasks(options: ExportTasksOptions): Promise { + // Validate authentication + if (!this.authManager.isAuthenticated()) { + throw new TaskMasterError( + 'Authentication required for export', + ERROR_CODES.AUTHENTICATION_ERROR + ); + } + + // Get current context + const context = this.authManager.getContext(); + + // Determine org and brief IDs + let orgId = options.orgId || context?.orgId; + let briefId = options.briefId || context?.briefId; + + // Validate we have necessary IDs + if (!orgId) { + throw new TaskMasterError( + 'Organization ID is required for export. Use "tm context org" to select one.', + ERROR_CODES.MISSING_CONFIGURATION + ); + } + + if (!briefId) { + throw new TaskMasterError( + 'Brief ID is required for export. Use "tm context brief" or provide --brief flag.', + ERROR_CODES.MISSING_CONFIGURATION + ); + } + + // Get tasks from the specified or active tag + const activeTag = this.configManager.getActiveTag(); + const tag = options.tag || activeTag; + + // Always read tasks from local file storage for export + // (we're exporting local tasks to a remote brief) + const fileStorage = new FileStorage(this.configManager.getProjectRoot()); + await fileStorage.initialize(); + + // Load tasks with filters applied at storage layer + const filteredTasks = await fileStorage.loadTasks(tag, { + status: options.status, + excludeSubtasks: options.excludeSubtasks + }); + + // Get total count (without filters) for comparison + const allTasks = await fileStorage.loadTasks(tag); + + const taskListResult = { + tasks: filteredTasks, + total: allTasks.length, + filtered: filteredTasks.length, + tag, + storageType: 'file' as const + }; + + if (taskListResult.tasks.length === 0) { + return { + success: false, + taskCount: 0, + briefId, + orgId, + message: 'No tasks found to export', + error: { + code: 'NO_TASKS', + message: 'No tasks match the specified criteria' + } + }; + } + + try { + // Call the export API with the original tasks + // performExport will handle the transformation based on the method used + await this.performExport(orgId, briefId, taskListResult.tasks); + + return { + success: true, + taskCount: taskListResult.tasks.length, + briefId, + orgId, + message: `Successfully exported ${taskListResult.tasks.length} task(s) to brief` + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + return { + success: false, + taskCount: 0, + briefId, + orgId, + error: { + code: 'EXPORT_FAILED', + message: errorMessage + } + }; + } + } + + /** + * Export tasks from a brief ID or URL + */ + async exportFromBriefInput(briefInput: string): Promise { + // Extract brief ID from input + const briefId = this.extractBriefId(briefInput); + if (!briefId) { + throw new TaskMasterError( + 'Invalid brief ID or URL provided', + ERROR_CODES.VALIDATION_ERROR + ); + } + + // Fetch brief to get organization + const brief = await this.authManager.getBrief(briefId); + if (!brief) { + throw new TaskMasterError( + 'Brief not found or you do not have access', + ERROR_CODES.NOT_FOUND + ); + } + + // Export with the resolved org and brief + return this.exportTasks({ + orgId: brief.accountId, + briefId: brief.id + }); + } + + /** + * Validate export context before prompting + */ + async validateContext(): Promise<{ + hasOrg: boolean; + hasBrief: boolean; + context: UserContext | null; + }> { + const context = this.authManager.getContext(); + + return { + hasOrg: !!context?.orgId, + hasBrief: !!context?.briefId, + context + }; + } + + /** + * Transform tasks for API bulk import format (flat structure) + */ + private transformTasksForBulkImport(tasks: Task[]): any[] { + const flatTasks: any[] = []; + + // Process each task and its subtasks + tasks.forEach((task) => { + // Add parent task + flatTasks.push({ + externalId: String(task.id), + title: task.title, + description: this.enrichDescription(task), + status: this.mapStatusForAPI(task.status), + priority: task.priority || 'medium', + dependencies: task.dependencies?.map(String) || [], + details: task.details, + testStrategy: task.testStrategy, + complexity: task.complexity, + metadata: { + complexity: task.complexity, + originalId: task.id, + originalDescription: task.description, + originalDetails: task.details, + originalTestStrategy: task.testStrategy + } + }); + + // Add subtasks if they exist + if (task.subtasks && task.subtasks.length > 0) { + task.subtasks.forEach((subtask) => { + flatTasks.push({ + externalId: `${task.id}.${subtask.id}`, + parentExternalId: String(task.id), + title: subtask.title, + description: this.enrichDescription(subtask), + status: this.mapStatusForAPI(subtask.status), + priority: subtask.priority || 'medium', + dependencies: + subtask.dependencies?.map((dep) => { + // Convert subtask dependencies to full ID format + if (String(dep).includes('.')) { + return String(dep); + } + return `${task.id}.${dep}`; + }) || [], + details: subtask.details, + testStrategy: subtask.testStrategy, + complexity: subtask.complexity, + metadata: { + complexity: subtask.complexity, + originalId: subtask.id, + originalDescription: subtask.description, + originalDetails: subtask.details, + originalTestStrategy: subtask.testStrategy + } + }); + }); + } + }); + + return flatTasks; + } + + /** + * Enrich task/subtask description with implementation details and test strategy + * Creates a comprehensive markdown-formatted description + */ + private enrichDescription(taskOrSubtask: Task | any): string { + const sections: string[] = []; + + // Start with original description if it exists + if (taskOrSubtask.description) { + sections.push(taskOrSubtask.description); + } + + // Add implementation details section + if (taskOrSubtask.details) { + sections.push('## Implementation Details\n'); + sections.push(taskOrSubtask.details); + } + + // Add test strategy section + if (taskOrSubtask.testStrategy) { + sections.push('## Test Strategy\n'); + sections.push(taskOrSubtask.testStrategy); + } + + // Join sections with double newlines for better markdown formatting + return sections.join('\n\n').trim() || 'No description provided'; + } + + /** + * Map internal status to API status format + */ + private mapStatusForAPI(status?: string): string { + switch (status) { + case 'pending': + return 'todo'; + case 'in-progress': + return 'in_progress'; + case 'done': + return 'done'; + default: + return 'todo'; + } + } + + /** + * Perform the actual export API call + */ + private async performExport( + orgId: string, + briefId: string, + tasks: any[] + ): Promise { + // Check if we should use the API endpoint or direct Supabase + const useAPIEndpoint = process.env.TM_PUBLIC_BASE_DOMAIN; + + if (useAPIEndpoint) { + // Use the new bulk import API endpoint + const apiUrl = `${process.env.TM_PUBLIC_BASE_DOMAIN}/ai/api/v1/briefs/${briefId}/tasks/bulk`; + + // Transform tasks to flat structure for API + const flatTasks = this.transformTasksForBulkImport(tasks); + + // Prepare request body + const requestBody = { + source: 'task-master-cli', + accountId: orgId, + options: { + dryRun: false, + stopOnError: false + }, + tasks: flatTasks + }; + + // Get auth token + const credentials = this.authManager.getCredentials(); + if (!credentials || !credentials.token) { + throw new Error('Not authenticated'); + } + + // Make API request + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${credentials.token}` + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `API request failed: ${response.status} - ${errorText}` + ); + } + + const result = (await response.json()) as BulkTasksResponse; + + if (result.failedCount > 0) { + const failedTasks = result.results + .filter((r) => !r.success) + .map((r) => `${r.externalId}: ${r.error}`) + .join(', '); + console.warn( + `Warning: ${result.failedCount} tasks failed to import: ${failedTasks}` + ); + } + + console.log( + `Successfully exported ${result.successCount} of ${result.totalTasks} tasks to brief ${briefId}` + ); + } else { + // Direct Supabase approach is no longer supported + // The extractTasks method has been removed from SupabaseTaskRepository + // as we now exclusively use the API endpoint for exports + throw new Error( + 'Export API endpoint not configured. Please set TM_PUBLIC_BASE_DOMAIN environment variable to enable task export.' + ); + } + } + + /** + * Extract a brief ID from raw input (ID or URL) + */ + private extractBriefId(input: string): string | null { + const raw = input?.trim() ?? ''; + if (!raw) return null; + + const parseUrl = (s: string): URL | null => { + try { + return new URL(s); + } catch {} + try { + return new URL(`https://${s}`); + } catch {} + return null; + }; + + const fromParts = (path: string): string | null => { + const parts = path.split('/').filter(Boolean); + const briefsIdx = parts.lastIndexOf('briefs'); + const candidate = + briefsIdx >= 0 && parts.length > briefsIdx + 1 + ? parts[briefsIdx + 1] + : parts[parts.length - 1]; + return candidate?.trim() || null; + }; + + // Try to parse as URL + const url = parseUrl(raw); + if (url) { + const qId = url.searchParams.get('id') || url.searchParams.get('briefId'); + const candidate = (qId || fromParts(url.pathname)) ?? null; + if (candidate) { + if (this.isLikelyId(candidate) || candidate.length >= 8) { + return candidate; + } + } + } + + // Check if it looks like a path without scheme + if (raw.includes('/')) { + const candidate = fromParts(raw); + if (candidate && (this.isLikelyId(candidate) || candidate.length >= 8)) { + return candidate; + } + } + + // Return as-is if it looks like an ID + if (this.isLikelyId(raw) || raw.length >= 8) { + return raw; + } + + return null; + } + + /** + * Check if a string looks like a brief ID (UUID-like) + */ + private isLikelyId(value: string): boolean { + const uuidRegex = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i; + const slugRegex = /^[A-Za-z0-9_-]{16,}$/; + return ( + uuidRegex.test(value) || ulidRegex.test(value) || slugRegex.test(value) + ); + } +} diff --git a/packages/tm-core/src/services/index.ts b/packages/tm-core/src/services/index.ts index d436f950..004c472b 100644 --- a/packages/tm-core/src/services/index.ts +++ b/packages/tm-core/src/services/index.ts @@ -5,4 +5,9 @@ export { TaskService } from './task-service.js'; export { OrganizationService } from './organization.service.js'; +export { ExportService } from './export.service.js'; export type { Organization, Brief } from './organization.service.js'; +export type { + ExportTasksOptions, + ExportResult +} from './export.service.js'; diff --git a/packages/tm-core/src/services/task-service.ts b/packages/tm-core/src/services/task-service.ts index c0c01839..60fb7d3c 100644 --- a/packages/tm-core/src/services/task-service.ts +++ b/packages/tm-core/src/services/task-service.ts @@ -14,6 +14,7 @@ import { ConfigManager } from '../config/config-manager.js'; import { StorageFactory } from '../storage/storage-factory.js'; import { TaskEntity } from '../entities/task.entity.js'; import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js'; +import { getLogger } from '../logger/factory.js'; /** * Result returned by getTaskList @@ -51,6 +52,7 @@ export class TaskService { private configManager: ConfigManager; private storage: IStorage; private initialized = false; + private logger = getLogger('TaskService'); constructor(configManager: ConfigManager) { this.configManager = configManager; @@ -90,37 +92,76 @@ export class TaskService { const tag = options.tag || activeTag; try { - // Load raw tasks from storage - storage only knows about tags - const rawTasks = await this.storage.loadTasks(tag); + // Determine if we can push filters to storage layer + const canPushStatusFilter = + options.filter?.status && + !options.filter.priority && + !options.filter.tags && + !options.filter.assignee && + !options.filter.search && + options.filter.hasSubtasks === undefined; + + // Build storage-level options + const storageOptions: any = {}; + + // Push status filter to storage if it's the only filter + if (canPushStatusFilter) { + const statuses = Array.isArray(options.filter!.status) + ? options.filter!.status + : [options.filter!.status]; + // Only push single status to storage (multiple statuses need in-memory filtering) + if (statuses.length === 1) { + storageOptions.status = statuses[0]; + } + } + + // Push subtask exclusion to storage + if (options.includeSubtasks === false) { + storageOptions.excludeSubtasks = true; + } + + // Load tasks from storage with pushed-down filters + const rawTasks = await this.storage.loadTasks(tag, storageOptions); + + // Get total count without status filters, but preserve subtask exclusion + const baseOptions: any = {}; + if (options.includeSubtasks === false) { + baseOptions.excludeSubtasks = true; + } + + const allTasks = + storageOptions.status !== undefined + ? await this.storage.loadTasks(tag, baseOptions) + : rawTasks; // Convert to TaskEntity for business logic operations const taskEntities = TaskEntity.fromArray(rawTasks); - // Apply filters if provided + // Apply remaining filters in-memory if needed let filteredEntities = taskEntities; - if (options.filter) { + if (options.filter && !canPushStatusFilter) { + filteredEntities = this.applyFilters(taskEntities, options.filter); + } else if ( + options.filter?.status && + Array.isArray(options.filter.status) && + options.filter.status.length > 1 + ) { + // Multiple statuses - filter in-memory filteredEntities = this.applyFilters(taskEntities, options.filter); } // Convert back to plain objects - let tasks = filteredEntities.map((entity) => entity.toJSON()); - - // Handle subtasks option - if (options.includeSubtasks === false) { - tasks = tasks.map((task) => ({ - ...task, - subtasks: [] - })); - } + const tasks = filteredEntities.map((entity) => entity.toJSON()); return { tasks, - total: rawTasks.length, + total: allTasks.length, filtered: filteredEntities.length, tag: tag, // Return the actual tag being used (either explicitly provided or active tag) storageType: this.getStorageType() }; } catch (error) { + this.logger.error('Failed to get task list', error); throw new TaskMasterError( 'Failed to get task list', ERROR_CODES.INTERNAL_ERROR, diff --git a/packages/tm-core/src/storage/api-storage.ts b/packages/tm-core/src/storage/api-storage.ts index d7fe906d..8a1fcd22 100644 --- a/packages/tm-core/src/storage/api-storage.ts +++ b/packages/tm-core/src/storage/api-storage.ts @@ -6,7 +6,8 @@ import type { IStorage, StorageStats, - UpdateStatusResult + UpdateStatusResult, + LoadTasksOptions } from '../interfaces/storage.interface.js'; import type { Task, @@ -146,7 +147,7 @@ export class ApiStorage implements IStorage { * Load tasks from API * In our system, the tag parameter represents a brief ID */ - async loadTasks(tag?: string): Promise { + async loadTasks(tag?: string, options?: LoadTasksOptions): Promise { await this.ensureInitialized(); try { @@ -160,9 +161,9 @@ export class ApiStorage implements IStorage { ); } - // Load tasks from the current brief context + // Load tasks from the current brief context with filters pushed to repository const tasks = await this.retryOperation(() => - this.repository.getTasks(this.projectId) + this.repository.getTasks(this.projectId, options) ); // Update the tag cache with the loaded task IDs diff --git a/packages/tm-core/src/storage/file-storage/file-storage.ts b/packages/tm-core/src/storage/file-storage/file-storage.ts index 73749e70..d3fe29fd 100644 --- a/packages/tm-core/src/storage/file-storage/file-storage.ts +++ b/packages/tm-core/src/storage/file-storage/file-storage.ts @@ -6,7 +6,8 @@ import type { Task, TaskMetadata, TaskStatus } from '../../types/index.js'; import type { IStorage, StorageStats, - UpdateStatusResult + UpdateStatusResult, + LoadTasksOptions } from '../../interfaces/storage.interface.js'; import { FormatHandler } from './format-handler.js'; import { FileOperations } from './file-operations.js'; @@ -92,15 +93,30 @@ export class FileStorage implements IStorage { * Load tasks from the single tasks.json file for a specific tag * Enriches tasks with complexity data from the complexity report */ - async loadTasks(tag?: string): Promise { + async loadTasks(tag?: string, options?: LoadTasksOptions): Promise { const filePath = this.pathResolver.getTasksPath(); const resolvedTag = tag || 'master'; try { const rawData = await this.fileOps.readJson(filePath); - const tasks = this.formatHandler.extractTasks(rawData, resolvedTag); + let tasks = this.formatHandler.extractTasks(rawData, resolvedTag); + + // Apply filters if provided + if (options) { + // Filter by status if specified + if (options.status) { + tasks = tasks.filter((task) => task.status === options.status); + } + + // Exclude subtasks if specified + if (options.excludeSubtasks) { + tasks = tasks.map((task) => ({ + ...task, + subtasks: [] + })); + } + } - // Enrich tasks with complexity data return await this.enrichTasksWithComplexity(tasks, resolvedTag); } catch (error: any) { if (error.code === 'ENOENT') { diff --git a/packages/tm-core/src/task-master-core.ts b/packages/tm-core/src/task-master-core.ts index 47616d91..8b0d07ee 100644 --- a/packages/tm-core/src/task-master-core.ts +++ b/packages/tm-core/src/task-master-core.ts @@ -14,7 +14,14 @@ import { type StartTaskResult, type ConflictCheckResult } from './services/task-execution-service.js'; +import { + ExportService, + type ExportTasksOptions, + type ExportResult +} from './services/export.service.js'; +import { AuthManager } from './auth/auth-manager.js'; import { ERROR_CODES, TaskMasterError } from './errors/task-master-error.js'; +import type { UserContext } from './auth/types.js'; import type { IConfiguration } from './interfaces/configuration.interface.js'; import type { Task, @@ -47,6 +54,10 @@ export type { StartTaskResult, ConflictCheckResult } from './services/task-execution-service.js'; +export type { + ExportTasksOptions, + ExportResult +} from './services/export.service.js'; /** * TaskMasterCore facade class @@ -56,6 +67,7 @@ export class TaskMasterCore { private configManager: ConfigManager; private taskService: TaskService; private taskExecutionService: TaskExecutionService; + private exportService: ExportService; private executorService: ExecutorService | null = null; /** @@ -80,6 +92,7 @@ export class TaskMasterCore { this.configManager = null as any; this.taskService = null as any; this.taskExecutionService = null as any; + this.exportService = null as any; } /** @@ -109,6 +122,10 @@ export class TaskMasterCore { // Create task execution service this.taskExecutionService = new TaskExecutionService(this.taskService); + + // Create export service + const authManager = AuthManager.getInstance(); + this.exportService = new ExportService(this.configManager, authManager); } catch (error) { throw new TaskMasterError( 'Failed to initialize TaskMasterCore', @@ -242,6 +259,33 @@ export class TaskMasterCore { return this.taskExecutionService.getNextAvailableTask(); } + // ==================== Export Service Methods ==================== + + /** + * Export tasks to an external system (e.g., Hamster brief) + */ + async exportTasks(options: ExportTasksOptions): Promise { + return this.exportService.exportTasks(options); + } + + /** + * Export tasks from a brief ID or URL + */ + async exportFromBriefInput(briefInput: string): Promise { + return this.exportService.exportFromBriefInput(briefInput); + } + + /** + * Validate export context before prompting + */ + async validateExportContext(): Promise<{ + hasOrg: boolean; + hasBrief: boolean; + context: UserContext | null; + }> { + return this.exportService.validateContext(); + } + // ==================== Executor Service Methods ==================== /** diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index 90e2d252..f9a45f67 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -12,17 +12,11 @@ import https from 'https'; import http from 'http'; import inquirer from 'inquirer'; import search from '@inquirer/search'; -import ora from 'ora'; // Import ora import { log, readJSON } from './utils.js'; -// Import new commands from @tm/cli +// Import command registry and utilities from @tm/cli import { - ListTasksCommand, - ShowCommand, - AuthCommand, - ContextCommand, - StartCommand, - SetStatusCommand, + registerAllCommands, checkForUpdate, performAutoUpdate, displayUpgradeNotification @@ -32,7 +26,6 @@ import { parsePRD, updateTasks, generateTaskFiles, - listTasks, expandTask, expandAllTasks, clearSubtasks, @@ -53,11 +46,7 @@ import { validateStrength } from './task-manager.js'; -import { - moveTasksBetweenTags, - MoveTaskError, - MOVE_ERROR_CODES -} from './task-manager/move-task.js'; +import { moveTasksBetweenTags } from './task-manager/move-task.js'; import { createTag, @@ -72,9 +61,7 @@ import { addDependency, removeDependency, validateDependenciesCommand, - fixDependenciesCommand, - DependencyError, - DEPENDENCY_ERROR_CODES + fixDependenciesCommand } from './dependency-manager.js'; import { @@ -103,7 +90,6 @@ import { displayBanner, displayHelp, displayNextTask, - displayTaskById, displayComplexityReport, getStatusWithColor, confirmTaskOverwrite, @@ -112,8 +98,6 @@ import { displayModelConfiguration, displayAvailableModels, displayApiKeyStatus, - displayAiUsageSummary, - displayMultipleTasksSummary, displayTaggedTasksFYI, displayCurrentTagIndicator, displayCrossTagDependencyError, @@ -137,10 +121,6 @@ import { setModel, getApiKeyStatusReport } from './task-manager/models.js'; -import { - isValidTaskStatus, - TASK_STATUS_OPTIONS -} from '../../src/constants/task-status.js'; import { isValidRulesAction, RULES_ACTIONS, @@ -1687,29 +1667,12 @@ function registerCommands(programInstance) { }); }); - // Register the set-status command from @tm/cli - // Handles task status updates with proper error handling and validation - SetStatusCommand.registerOn(programInstance); - - // NEW: Register the new list command from @tm/cli - // This command handles all its own configuration and logic - ListTasksCommand.registerOn(programInstance); - - // Register the auth command from @tm/cli - // Handles authentication with tryhamster.com - AuthCommand.registerOn(programInstance); - - // Register the context command from @tm/cli - // Manages workspace context (org/brief selection) - ContextCommand.registerOn(programInstance); - - // Register the show command from @tm/cli - // Displays detailed information about tasks - ShowCommand.registerOn(programInstance); - - // Register the start command from @tm/cli - // Starts working on a task by launching claude-code with a standardized prompt - StartCommand.registerOn(programInstance); + // ======================================== + // Register All Commands from @tm/cli + // ======================================== + // Use the centralized command registry to register all CLI commands + // This replaces individual command registrations and reduces duplication + registerAllCommands(programInstance); // expand command programInstance diff --git a/tsdown.config.ts b/tsdown.config.ts index 0d877c49..24a1aef5 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,6 +1,10 @@ import { defineConfig } from 'tsdown'; import { baseConfig, mergeConfig } from '@tm/build-config'; -import 'dotenv/config'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +// Load .env file explicitly with absolute path +config({ path: resolve(process.cwd(), '.env') }); // Get all TM_PUBLIC_* env variables for build-time injection const getBuildTimeEnvs = () => {