diff --git a/apps/cli/src/commands/auth.command.ts b/apps/cli/src/commands/auth.command.ts index e2878dd8..7c2e03c3 100644 --- a/apps/cli/src/commands/auth.command.ts +++ b/apps/cli/src/commands/auth.command.ts @@ -15,6 +15,7 @@ import { } from '@tm/core/auth'; import * as ui from '../utils/ui.js'; import { ContextCommand } from './context.command.js'; +import { displayError } from '../utils/error-handler.js'; /** * Result type from auth command @@ -117,8 +118,7 @@ export class AuthCommand extends Command { process.exit(0); }, 100); } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -134,8 +134,7 @@ export class AuthCommand extends Command { process.exit(1); } } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -147,8 +146,7 @@ export class AuthCommand extends Command { const result = this.displayStatus(); this.setLastResult(result); } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -164,8 +162,7 @@ export class AuthCommand extends Command { process.exit(1); } } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -390,7 +387,7 @@ export class AuthCommand extends Command { message: 'Authentication successful' }; } catch (error) { - this.handleAuthError(error as AuthenticationError); + displayError(error, { skipExit: true }); return { success: false, @@ -453,51 +450,6 @@ export class AuthCommand extends Command { } } - /** - * Handle authentication errors - */ - private handleAuthError(error: AuthenticationError): void { - console.error(chalk.red(`\nāœ— ${error.message}`)); - - switch (error.code) { - case 'NETWORK_ERROR': - ui.displayWarning( - 'Please check your internet connection and try again.' - ); - break; - case 'INVALID_CREDENTIALS': - ui.displayWarning('Please check your credentials and try again.'); - break; - case 'AUTH_EXPIRED': - ui.displayWarning( - 'Your session has expired. Please authenticate again.' - ); - break; - default: - if (process.env.DEBUG) { - console.error(chalk.gray(error.stack || '')); - } - } - } - - /** - * Handle general errors - */ - private handleError(error: any): void { - if (error instanceof AuthenticationError) { - this.handleAuthError(error); - } else { - const msg = error?.getSanitizedDetails?.() ?? { - message: error?.message ?? String(error) - }; - console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`)); - - if (error.stack && process.env.DEBUG) { - console.error(chalk.gray(error.stack)); - } - } - } - /** * Set the last result for programmatic access */ diff --git a/apps/cli/src/commands/context.command.ts b/apps/cli/src/commands/context.command.ts index dc2f0fc7..0d933799 100644 --- a/apps/cli/src/commands/context.command.ts +++ b/apps/cli/src/commands/context.command.ts @@ -8,12 +8,9 @@ import chalk from 'chalk'; import inquirer from 'inquirer'; import search from '@inquirer/search'; import ora, { Ora } from 'ora'; -import { - AuthManager, - AuthenticationError, - type UserContext -} from '@tm/core/auth'; +import { AuthManager, type UserContext } from '@tm/core/auth'; import * as ui from '../utils/ui.js'; +import { displayError } from '../utils/error-handler.js'; /** * Result type from context command @@ -119,8 +116,7 @@ export class ContextCommand extends Command { const result = this.displayContext(); this.setLastResult(result); } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -216,8 +212,7 @@ export class ContextCommand extends Command { process.exit(1); } } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -258,6 +253,7 @@ export class ContextCommand extends Command { this.authManager.updateContext({ orgId: selectedOrg.id, orgName: selectedOrg.name, + orgSlug: selectedOrg.slug, // Clear brief when changing org briefId: undefined, briefName: undefined @@ -304,8 +300,7 @@ export class ContextCommand extends Command { process.exit(1); } } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -429,8 +424,7 @@ export class ContextCommand extends Command { process.exit(1); } } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -476,8 +470,7 @@ export class ContextCommand extends Command { process.exit(1); } } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -513,11 +506,13 @@ export class ContextCommand extends Command { process.exit(1); } - // Fetch org to get a friendly name (optional) + // Fetch org to get a friendly name and slug (optional) let orgName: string | undefined; + let orgSlug: string | undefined; try { const org = await this.authManager.getOrganization(brief.accountId); orgName = org?.name; + orgSlug = org?.slug; } catch { // Non-fatal if org lookup fails } @@ -528,6 +523,7 @@ export class ContextCommand extends Command { this.authManager.updateContext({ orgId: brief.accountId, orgName, + orgSlug, briefId: brief.id, briefName }); @@ -549,8 +545,7 @@ export class ContextCommand extends Command { try { if (spinner?.isSpinning) spinner.stop(); } catch {} - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -679,26 +674,6 @@ export class ContextCommand extends Command { } } - /** - * 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)); - } - } - } - /** * Set the last result for programmatic access */ diff --git a/apps/cli/src/commands/export.command.ts b/apps/cli/src/commands/export.command.ts index 25b55a93..2b45a835 100644 --- a/apps/cli/src/commands/export.command.ts +++ b/apps/cli/src/commands/export.command.ts @@ -7,13 +7,10 @@ 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 { AuthManager, type UserContext } from '@tm/core/auth'; import { TaskMasterCore, type ExportResult } from '@tm/core'; import * as ui from '../utils/ui.js'; +import { displayError } from '../utils/error-handler.js'; /** * Result type from export command @@ -197,8 +194,7 @@ export class ExportCommand extends Command { }; } catch (error: any) { if (spinner?.isSpinning) spinner.fail('Export failed'); - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -334,26 +330,6 @@ export class ExportCommand extends Command { 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) */ diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index 63065fd3..e6bafcc0 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -18,8 +18,8 @@ import { import type { StorageType } from '@tm/core/types'; import * as ui from '../utils/ui.js'; import { displayError } from '../utils/error-handler.js'; +import { displayCommandHeader } from '../utils/display-helpers.js'; import { - displayHeader, displayDashboards, calculateTaskStatistics, calculateSubtaskStatistics, @@ -251,15 +251,12 @@ export class ListTasksCommand extends Command { * Display in text format with tables */ private displayText(data: ListTasksResult, withSubtasks?: boolean): void { - const { tasks, tag } = data; + const { tasks, tag, storageType } = data; - // Get file path for display - const filePath = this.tmCore ? `.taskmaster/tasks/tasks.json` : undefined; - - // Display header without banner (banner already shown by main CLI) - displayHeader({ + // Display header using utility function + displayCommandHeader(this.tmCore, { tag: tag || 'master', - filePath: filePath + storageType }); // No tasks message diff --git a/apps/cli/src/commands/next.command.ts b/apps/cli/src/commands/next.command.ts index 631a6528..64ade64f 100644 --- a/apps/cli/src/commands/next.command.ts +++ b/apps/cli/src/commands/next.command.ts @@ -11,7 +11,7 @@ import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core'; import type { StorageType } from '@tm/core/types'; import { displayError } from '../utils/error-handler.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js'; -import { displayHeader } from '../ui/index.js'; +import { displayCommandHeader } from '../utils/display-helpers.js'; /** * Options interface for the next command @@ -166,9 +166,10 @@ export class NextCommand extends Command { * Display in text format */ private displayText(result: NextTaskResult): void { - // Display header with tag (no file path for next command) - displayHeader({ - tag: result.tag || 'master' + // Display header with storage info + displayCommandHeader(this.tmCore, { + tag: result.tag || 'master', + storageType: result.storageType }); if (!result.found || !result.task) { @@ -187,7 +188,6 @@ export class NextCommand extends Command { } ) ); - console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`); console.log( `\n${chalk.dim('Tip: Try')} ${chalk.cyan('task-master list --status pending')} ${chalk.dim('to see all pending tasks')}` ); @@ -204,8 +204,6 @@ export class NextCommand extends Command { headerColor: 'green', showSuggestedActions: true }); - - console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`); } /** diff --git a/apps/cli/src/commands/show.command.ts b/apps/cli/src/commands/show.command.ts index 9b58a98c..121c0819 100644 --- a/apps/cli/src/commands/show.command.ts +++ b/apps/cli/src/commands/show.command.ts @@ -11,6 +11,7 @@ import type { StorageType } from '@tm/core/types'; import * as ui from '../utils/ui.js'; import { displayError } from '../utils/error-handler.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js'; +import { displayCommandHeader } from '../utils/display-helpers.js'; /** * Options interface for the show command @@ -251,6 +252,15 @@ export class ShowCommand extends Command { return; } + // Display header with storage info + const activeTag = this.tmCore?.getActiveTag() || 'master'; + displayCommandHeader(this.tmCore, { + tag: activeTag, + storageType: result.storageType + }); + + console.log(); // Add spacing + // Use the global task details display function displayTaskDetails(result.task, { statusFilter: options.status, @@ -265,8 +275,12 @@ export class ShowCommand extends Command { result: ShowMultipleTasksResult, _options: ShowCommandOptions ): void { - // Header - ui.displayBanner(`Tasks (${result.tasks.length} found)`); + // Display header with storage info + const activeTag = this.tmCore?.getActiveTag() || 'master'; + displayCommandHeader(this.tmCore, { + tag: activeTag, + storageType: result.storageType + }); if (result.notFound.length > 0) { console.log(chalk.yellow(`\n⚠ Not found: ${result.notFound.join(', ')}`)); @@ -285,8 +299,6 @@ export class ShowCommand extends Command { showDependencies: true }) ); - - console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`); } /** diff --git a/apps/cli/src/ui/components/header.component.ts b/apps/cli/src/ui/components/header.component.ts index e47337c2..9f3e9984 100644 --- a/apps/cli/src/ui/components/header.component.ts +++ b/apps/cli/src/ui/components/header.component.ts @@ -5,6 +5,16 @@ import chalk from 'chalk'; +/** + * Brief information for API storage + */ +export interface BriefInfo { + briefId: string; + briefName: string; + orgSlug?: string; + webAppUrl?: string; +} + /** * Header configuration options */ @@ -12,16 +22,44 @@ export interface HeaderOptions { title?: string; tag?: string; filePath?: string; + storageType?: 'api' | 'file'; + briefInfo?: BriefInfo; } /** * Display the Task Master header with project info */ export function displayHeader(options: HeaderOptions = {}): void { - const { filePath, tag } = options; + const { filePath, tag, storageType, briefInfo } = options; - // Display tag and file path info - if (tag) { + // Display different header based on storage type + if (storageType === 'api' && briefInfo) { + // API storage: Show brief information + const briefDisplay = `šŸ· Brief: ${chalk.cyan(briefInfo.briefName)} ${chalk.gray(`(${briefInfo.briefId})`)}`; + console.log(briefDisplay); + + // Construct and display the brief URL or ID + if (briefInfo.webAppUrl && briefInfo.orgSlug) { + const briefUrl = `${briefInfo.webAppUrl}/home/${briefInfo.orgSlug}/briefs/${briefInfo.briefId}/plan`; + console.log(`Listing tasks from: ${chalk.dim(briefUrl)}`); + } else if (briefInfo.webAppUrl) { + // Show web app URL and brief ID if org slug is missing + console.log( + `Listing tasks from: ${chalk.dim(`${briefInfo.webAppUrl} (Brief: ${briefInfo.briefId})`)}` + ); + console.log( + chalk.yellow( + `šŸ’” Tip: Run ${chalk.cyan('tm context select')} to set your organization and see the full URL` + ) + ); + } else { + // Fallback: just show the brief ID if we can't get web app URL + console.log( + `Listing tasks from: ${chalk.dim(`API (Brief ID: ${briefInfo.briefId})`)}` + ); + } + } else if (tag) { + // File storage: Show tag information let tagInfo = ''; if (tag && tag !== 'master') { diff --git a/apps/cli/src/utils/display-helpers.ts b/apps/cli/src/utils/display-helpers.ts new file mode 100644 index 00000000..0e731f04 --- /dev/null +++ b/apps/cli/src/utils/display-helpers.ts @@ -0,0 +1,73 @@ +/** + * @fileoverview Display helper utilities for commands + * Provides DRY utilities for displaying headers and other command output + */ + +import type { TaskMasterCore } from '@tm/core'; +import type { StorageType } from '@tm/core/types'; +import { displayHeader, type BriefInfo } from '../ui/index.js'; + +/** + * Get web app base URL from environment + */ +function getWebAppUrl(): string | undefined { + const baseDomain = + process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN; + + if (!baseDomain) { + return undefined; + } + + // If it already includes protocol, use as-is + if (baseDomain.startsWith('http://') || baseDomain.startsWith('https://')) { + return baseDomain; + } + + // Otherwise, add protocol based on domain + if (baseDomain.includes('localhost') || baseDomain.includes('127.0.0.1')) { + return `http://${baseDomain}`; + } + + return `https://${baseDomain}`; +} + +/** + * Display the command header with appropriate storage information + * Handles both API and file storage displays + */ +export function displayCommandHeader( + tmCore: TaskMasterCore | undefined, + options: { + tag?: string; + storageType: Exclude; + } +): void { + const { tag, storageType } = options; + + // Get brief info if using API storage + let briefInfo: BriefInfo | undefined; + if (storageType === 'api' && tmCore) { + const storageInfo = tmCore.getStorageDisplayInfo(); + if (storageInfo) { + // Construct full brief info with web app URL + briefInfo = { + ...storageInfo, + webAppUrl: getWebAppUrl() + }; + } + } + + // Get file path for display (only for file storage) + const filePath = + storageType === 'file' && tmCore + ? `.taskmaster/tasks/tasks.json` + : undefined; + + // Display header + displayHeader({ + tag: tag || 'master', + filePath: filePath, + storageType: storageType === 'api' ? 'api' : 'file', + briefInfo: briefInfo + }); +} diff --git a/packages/tm-core/src/auth/types.ts b/packages/tm-core/src/auth/types.ts index f6108f31..4a6b8c76 100644 --- a/packages/tm-core/src/auth/types.ts +++ b/packages/tm-core/src/auth/types.ts @@ -16,6 +16,7 @@ export interface AuthCredentials { export interface UserContext { orgId?: string; orgName?: string; + orgSlug?: string; briefId?: string; briefName?: string; updatedAt: string; diff --git a/packages/tm-core/src/services/task-service.ts b/packages/tm-core/src/services/task-service.ts index 7f119e03..a30289bf 100644 --- a/packages/tm-core/src/services/task-service.ts +++ b/packages/tm-core/src/services/task-service.ts @@ -162,7 +162,10 @@ export class TaskService { }; } catch (error) { // If it's a user-facing error (like NO_BRIEF_SELECTED), don't log it as an internal error - if (error instanceof TaskMasterError && error.is(ERROR_CODES.NO_BRIEF_SELECTED)) { + if ( + error instanceof TaskMasterError && + error.is(ERROR_CODES.NO_BRIEF_SELECTED) + ) { // Just re-throw user-facing errors without wrapping throw error; } @@ -194,7 +197,10 @@ export class TaskService { return await this.storage.loadTask(String(taskId), activeTag); } catch (error) { // If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it - if (error instanceof TaskMasterError && error.is(ERROR_CODES.NO_BRIEF_SELECTED)) { + if ( + error instanceof TaskMasterError && + error.is(ERROR_CODES.NO_BRIEF_SELECTED) + ) { throw error; } @@ -535,7 +541,10 @@ export class TaskService { ); } catch (error) { // If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it - if (error instanceof TaskMasterError && error.is(ERROR_CODES.NO_BRIEF_SELECTED)) { + if ( + error instanceof TaskMasterError && + error.is(ERROR_CODES.NO_BRIEF_SELECTED) + ) { throw error; } diff --git a/packages/tm-core/src/task-master-core.ts b/packages/tm-core/src/task-master-core.ts index 8b0d07ee..6c958825 100644 --- a/packages/tm-core/src/task-master-core.ts +++ b/packages/tm-core/src/task-master-core.ts @@ -201,6 +201,44 @@ export class TaskMasterCore { return this.taskService.getStorageType(); } + /** + * Get storage configuration + */ + getStorageConfig() { + return this.configManager.getStorageConfig(); + } + + /** + * Get storage display information for headers + * Returns context info for API storage, null for file storage + */ + getStorageDisplayInfo(): { + briefId: string; + briefName: string; + orgSlug?: string; + } | null { + // Only return info if using API storage + const storageType = this.getStorageType(); + if (storageType !== 'api') { + return null; + } + + // Get credentials from auth manager + const authManager = AuthManager.getInstance(); + const credentials = authManager.getCredentials(); + const selectedContext = credentials?.selectedContext; + + if (!selectedContext?.briefId || !selectedContext?.briefName) { + return null; + } + + return { + briefId: selectedContext.briefId, + briefName: selectedContext.briefName, + orgSlug: selectedContext.orgSlug + }; + } + /** * Get current active tag */