feat: implement export tasks (#1260)

This commit is contained in:
Ralph Khreish
2025-10-06 16:03:56 +02:00
committed by GitHub
parent db6f405f23
commit 7265a6cf53
22 changed files with 1333 additions and 148 deletions

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 <id>', 'Organization ID to export to');
this.option('--brief <id>', 'Brief ID to export tasks to');
this.option('--tag <tag>', 'Export tasks from a specific tag');
this.option(
'--status <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<void> {
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<void> {
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<boolean> {
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<void> {
// 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;
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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';

View File

@@ -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];

View File

@@ -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

View File

@@ -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<Task[]>;
loadTasks(tag?: string, options?: LoadTasksOptions): Promise<Task[]>;
/**
* 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<Task[]>;
abstract loadTasks(tag?: string, options?: LoadTasksOptions): Promise<Task[]>;
abstract loadTask(taskId: string, tag?: string): Promise<Task | null>;
abstract saveTasks(tasks: Task[], tag?: string): Promise<void>;
abstract appendTasks(tasks: Task[], tag?: string): Promise<void>;

View File

@@ -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<Task[]> {
async getTasks(
_projectId?: string,
options?: LoadTasksOptions
): Promise<Task[]> {
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 });

View File

@@ -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<Task[]>;
getTasks(projectId: string, options?: LoadTasksOptions): Promise<Task[]>;
getTask(projectId: string, taskId: string): Promise<Task | null>;
createTask(projectId: string, task: Omit<Task, 'id'>): Promise<Task>;
updateTask(

View File

@@ -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<ExportResult> {
// 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<ExportResult> {
// 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<void> {
// 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)
);
}
}

View File

@@ -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';

View File

@@ -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,

View File

@@ -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<Task[]> {
async loadTasks(tag?: string, options?: LoadTasksOptions): Promise<Task[]> {
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

View File

@@ -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<Task[]> {
async loadTasks(tag?: string, options?: LoadTasksOptions): Promise<Task[]> {
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') {

View File

@@ -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<ExportResult> {
return this.exportService.exportTasks(options);
}
/**
* Export tasks from a brief ID or URL
*/
async exportFromBriefInput(briefInput: string): Promise<ExportResult> {
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 ====================
/**

View File

@@ -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

View File

@@ -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 = () => {