feat: implement export tasks (#1260)
This commit is contained in:
255
apps/cli/src/command-registry.ts
Normal file
255
apps/cli/src/command-registry.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
379
apps/cli/src/commands/export.command.ts
Normal file
379
apps/cli/src/commands/export.command.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(
|
||||
|
||||
496
packages/tm-core/src/services/export.service.ts
Normal file
496
packages/tm-core/src/services/export.service.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user