feat: add tm tags command to remote (#1386)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ralph Khreish
2025-11-12 20:08:27 +01:00
committed by GitHub
parent e59c16c707
commit 63134a222c
154 changed files with 6037 additions and 1391 deletions

2
.gitignore vendored
View File

@@ -100,3 +100,5 @@ apps/extension/vsix-build/
# TaskMaster Workflow State (now stored in ~/.taskmaster/sessions/)
# No longer needed in .gitignore as state is stored globally
.scannerwork

View File

@@ -1,6 +1,6 @@
{
"currentTag": "master",
"lastSwitched": "2025-10-27T09:28:03.574Z",
"currentTag": "tdd-phase-1-core-rails",
"lastSwitched": "2025-11-10T19:45:04.383Z",
"branchTagMapping": {
"v017-adds": "v017-adds",
"next": "next"

View File

@@ -23,5 +23,9 @@
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
}
}

View File

@@ -130,6 +130,16 @@ function getTasks() {
- ✅ MCP calls: `await tmCore.tasks.get(taskId)` (same intelligent ID parsing)
- ✅ Single source of truth in tm-core
## Code Quality & Reusability Guidelines
Apply standard software engineering principles:
- **DRY (Don't Repeat Yourself)**: Extract patterns that appear 2+ times into reusable components or utilities
- **YAGNI (You Aren't Gonna Need It)**: Don't over-engineer. Create abstractions when duplication appears, not before
- **Maintainable**: Single source of truth. Change once, update everywhere
- **Readable**: Clear naming, proper structure, export from index files
- **Flexible**: Accept configuration options with sensible defaults
## Documentation Guidelines
- **Documentation location**: Write docs in `apps/docs/` (Mintlify site source), not `docs/`

View File

@@ -3,18 +3,20 @@
* Provides a single location for registering all CLI commands
*/
import { Command } from 'commander';
import type { Command } from 'commander';
import { AuthCommand } from './commands/auth.command.js';
import { AutopilotCommand } from './commands/autopilot/index.js';
import { BriefsCommand } from './commands/briefs.command.js';
import { ContextCommand } from './commands/context.command.js';
import { ExportCommand } from './commands/export.command.js';
// Import all commands
import { ListTasksCommand } from './commands/list.command.js';
import { ShowCommand } from './commands/show.command.js';
import { NextCommand } from './commands/next.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';
import { AutopilotCommand } from './commands/autopilot/index.js';
import { ShowCommand } from './commands/show.command.js';
import { StartCommand } from './commands/start.command.js';
import { TagsCommand } from './commands/tags.command.js';
/**
* Command metadata for registration
@@ -91,6 +93,18 @@ export class CommandRegistry {
description: 'Manage workspace context (organization/brief)',
commandClass: ContextCommand as any,
category: 'auth'
},
{
name: 'tags',
description: 'Manage tags for task organization',
commandClass: TagsCommand as any,
category: 'task'
},
{
name: 'briefs',
description: 'Manage briefs (Hamster only)',
commandClass: BriefsCommand as any,
category: 'task'
}
];

View File

@@ -3,19 +3,19 @@
* Extends Commander.Command for better integration with the framework
*/
import { Command } from 'commander';
import chalk from 'chalk';
import inquirer from 'inquirer';
import ora, { type Ora } from 'ora';
import open from 'open';
import {
type AuthCredentials,
AuthManager,
AuthenticationError,
type AuthCredentials
AuthenticationError
} from '@tm/core';
import chalk from 'chalk';
import { Command } from 'commander';
import inquirer from 'inquirer';
import open from 'open';
import ora, { type Ora } from 'ora';
import { displayError } from '../utils/error-handler.js';
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

View File

@@ -2,16 +2,16 @@
* @fileoverview Abort Command - Safely terminate workflow
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import { Command } from 'commander';
import inquirer from 'inquirer';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
OutputFormatter,
deleteWorkflowState,
OutputFormatter
hasWorkflowState,
loadWorkflowState
} from './shared.js';
import inquirer from 'inquirer';
import { getProjectRoot } from '../../utils/project-root.js';
interface AbortOptions extends AutopilotBaseOptions {

View File

@@ -2,8 +2,8 @@
* @fileoverview Commit Command - Create commit with enhanced message generation
*/
import { CommitMessageGenerator, GitAdapter, WorkflowService } from '@tm/core';
import { Command } from 'commander';
import { WorkflowService, GitAdapter, CommitMessageGenerator } from '@tm/core';
import { AutopilotBaseOptions, OutputFormatter } from './shared.js';
import { getProjectRoot } from '../../utils/project-root.js';

View File

@@ -2,14 +2,14 @@
* @fileoverview Complete Command - Complete current TDD phase with validation
*/
import { type TestResult, WorkflowOrchestrator } from '@tm/core';
import { Command } from 'commander';
import { WorkflowOrchestrator, TestResult } from '@tm/core';
import { getProjectRoot } from '../../utils/project-root.js';
import {
AutopilotBaseOptions,
type AutopilotBaseOptions,
OutputFormatter,
hasWorkflowState,
loadWorkflowState,
OutputFormatter
loadWorkflowState
} from './shared.js';
interface CompleteOptions extends AutopilotBaseOptions {

View File

@@ -5,13 +5,13 @@
*/
import { Command } from 'commander';
import { StartCommand } from './start.command.js';
import { ResumeCommand } from './resume.command.js';
import { NextCommand } from './next.command.js';
import { CompleteCommand } from './complete.command.js';
import { CommitCommand } from './commit.command.js';
import { StatusCommand } from './status.command.js';
import { AbortCommand } from './abort.command.js';
import { CommitCommand } from './commit.command.js';
import { CompleteCommand } from './complete.command.js';
import { NextCommand } from './next.command.js';
import { ResumeCommand } from './resume.command.js';
import { StartCommand } from './start.command.js';
import { StatusCommand } from './status.command.js';
/**
* Shared command options for all autopilot commands

View File

@@ -2,14 +2,14 @@
* @fileoverview Next Command - Get next action in TDD workflow
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import { Command } from 'commander';
import { getProjectRoot } from '../../utils/project-root.js';
import {
AutopilotBaseOptions,
type AutopilotBaseOptions,
OutputFormatter,
hasWorkflowState,
loadWorkflowState,
OutputFormatter
loadWorkflowState
} from './shared.js';
type NextOptions = AutopilotBaseOptions;

View File

@@ -2,13 +2,13 @@
* @fileoverview Resume Command - Restore and resume TDD workflow
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import { Command } from 'commander';
import {
AutopilotBaseOptions,
OutputFormatter,
hasWorkflowState,
loadWorkflowState,
OutputFormatter
loadWorkflowState
} from './shared.js';
import { getProjectRoot } from '../../utils/project-root.js';

View File

@@ -3,12 +3,12 @@
*/
import {
WorkflowOrchestrator,
WorkflowStateManager,
CommitMessageGenerator,
GitAdapter,
CommitMessageGenerator
WorkflowOrchestrator,
WorkflowStateManager
} from '@tm/core';
import type { WorkflowState, WorkflowContext, SubtaskInfo } from '@tm/core';
import type { SubtaskInfo, WorkflowContext, WorkflowState } from '@tm/core';
import chalk from 'chalk';
/**

View File

@@ -2,16 +2,16 @@
* @fileoverview Start Command - Initialize and start TDD workflow
*/
import { type WorkflowContext, createTmCore } from '@tm/core';
import { Command } from 'commander';
import { createTmCore, type WorkflowContext } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
createOrchestrator,
createGitAdapter,
OutputFormatter,
validateTaskId,
parseSubtasks
createGitAdapter,
createOrchestrator,
hasWorkflowState,
parseSubtasks,
validateTaskId
} from './shared.js';
import { getProjectRoot } from '../../utils/project-root.js';

View File

@@ -2,13 +2,13 @@
* @fileoverview Status Command - Show workflow progress
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import { Command } from 'commander';
import {
AutopilotBaseOptions,
OutputFormatter,
hasWorkflowState,
loadWorkflowState,
OutputFormatter
loadWorkflowState
} from './shared.js';
import { getProjectRoot } from '../../utils/project-root.js';

View File

@@ -0,0 +1,382 @@
/**
* @fileoverview Briefs Command - Friendly alias for tag management in API storage
* Provides brief-specific commands that only work with API storage
*/
import {
type LogLevel,
type TagInfo,
tryAddTagViaRemote,
tryListTagsViaRemote
} from '@tm/bridge';
import type { TmCore } from '@tm/core';
import { AuthManager, createTmCore } from '@tm/core';
import { Command } from 'commander';
import { checkAuthentication } from '../utils/auth-helpers.js';
import {
selectBriefFromInput,
selectBriefInteractive
} from '../utils/brief-selection.js';
import * as ui from '../utils/ui.js';
/**
* Result type from briefs command
*/
export interface BriefsResult {
success: boolean;
action: 'list' | 'select' | 'create';
briefs?: TagInfo[];
currentBrief?: string | null;
message?: string;
}
/**
* BriefsCommand - Manage briefs for API storage (friendly alias)
* Only works when using API storage (tryhamster.com)
*/
export class BriefsCommand extends Command {
private tmCore?: TmCore;
private authManager: AuthManager;
private lastResult?: BriefsResult;
constructor(name?: string) {
super(name || 'briefs');
// Initialize auth manager
this.authManager = AuthManager.getInstance();
// Configure the command
this.description('Manage briefs (API storage only)');
this.alias('brief');
// Add subcommands
this.addListCommand();
this.addSelectCommand();
this.addCreateCommand();
// Accept optional positional argument for brief URL/ID
this.argument('[briefOrUrl]', 'Brief ID or Hamster brief URL');
// Default action: if argument provided, select brief; else list briefs
this.action(async (briefOrUrl?: string) => {
if (briefOrUrl && briefOrUrl.trim().length > 0) {
await this.executeSelectFromUrl(briefOrUrl.trim());
return;
}
await this.executeList();
});
}
/**
* Check if user is authenticated (required for briefs)
*/
private async checkAuth(): Promise<boolean> {
return checkAuthentication(this.authManager, {
message:
'The "briefs" command requires you to be logged in to your Hamster account.',
footer:
'Working locally instead?\n' +
' → Use "task-master tags" for local tag management.',
authCommand: 'task-master auth login'
});
}
/**
* Add list subcommand
*/
private addListCommand(): void {
this.command('list')
.description('List all briefs (default action)')
.option('--show-metadata', 'Show additional brief metadata')
.addHelpText(
'after',
`
Examples:
$ tm briefs # List all briefs (default)
$ tm briefs list # List all briefs (explicit)
$ tm briefs list --show-metadata # List with metadata
Note: This command only works with API storage (tryhamster.com).
`
)
.action(async (options) => {
await this.executeList(options);
});
}
/**
* Add select subcommand
*/
private addSelectCommand(): void {
this.command('select')
.description('Select a brief to work with')
.argument(
'[briefOrUrl]',
'Brief ID or Hamster URL (optional, interactive if omitted)'
)
.addHelpText(
'after',
`
Examples:
$ tm brief select # Interactive selection
$ tm brief select abc12345 # Select by ID
$ tm brief select https://app.tryhamster.com/... # Select by URL
Shortcuts:
$ tm brief <brief-url> # Same as "select"
$ tm brief # List all briefs
Note: Works exactly like "tm context brief" - reuses the same interactive interface.
`
)
.action(async (briefOrUrl) => {
await this.executeSelect(briefOrUrl);
});
}
/**
* Add create subcommand
*/
private addCreateCommand(): void {
this.command('create')
.description('Create a new brief (redirects to web UI)')
.argument('[name]', 'Brief name (optional)')
.addHelpText(
'after',
`
Examples:
$ tm briefs create # Redirect to web UI to create brief
$ tm briefs create my-new-brief # Redirect with suggested name
Note: Briefs must be created through the Hamster Studio web interface.
`
)
.action(async (name) => {
await this.executeCreate(name);
});
}
/**
* Initialize TmCore if not already initialized
*/
private async initTmCore(): Promise<void> {
if (!this.tmCore) {
this.tmCore = await createTmCore({
projectPath: process.cwd()
});
}
}
/**
* Execute list briefs
*/
private async executeList(options?: {
showMetadata?: boolean;
}): Promise<void> {
try {
// Check authentication
if (!(await this.checkAuth())) {
process.exit(1);
}
// Use the bridge to list briefs
const remoteResult = await tryListTagsViaRemote({
projectRoot: process.cwd(),
showMetadata: options?.showMetadata || false,
report: (level: LogLevel, ...args: unknown[]) => {
const message = args[0] as string;
if (level === 'error') ui.displayError(message);
else if (level === 'warn') ui.displayWarning(message);
else if (level === 'info') ui.displayInfo(message);
}
});
if (!remoteResult) {
throw new Error('Failed to fetch briefs from API');
}
this.setLastResult({
success: remoteResult.success,
action: 'list',
briefs: remoteResult.tags,
currentBrief: remoteResult.currentTag,
message: remoteResult.message
});
} catch (error) {
ui.displayError(`Failed to list briefs: ${(error as Error).message}`);
this.setLastResult({
success: false,
action: 'list',
message: (error as Error).message
});
process.exit(1);
}
}
/**
* Execute select brief interactively or by name/ID
*/
private async executeSelect(nameOrId?: string): Promise<void> {
try {
// Check authentication
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
process.exit(1);
}
// If name/ID provided, treat it as URL/ID selection
if (nameOrId && nameOrId.trim().length > 0) {
await this.executeSelectFromUrl(nameOrId.trim());
return;
}
// Check if org is selected for interactive selection
const context = this.authManager.getContext();
if (!context?.orgId) {
ui.displayErrorBox(
'No organization selected. Run "tm context org" first.'
);
process.exit(1);
}
// Use shared utility for interactive selection
const result = await selectBriefInteractive(
this.authManager,
context.orgId
);
this.setLastResult({
success: result.success,
action: 'select',
currentBrief: result.briefId,
message: result.message
});
if (!result.success) {
process.exit(1);
}
} catch (error) {
ui.displayErrorBox(`Failed to select brief: ${(error as Error).message}`);
this.setLastResult({
success: false,
action: 'select',
message: (error as Error).message
});
process.exit(1);
}
}
/**
* Execute select brief from any input (URL, ID, or name)
* All parsing logic is in tm-core
*/
private async executeSelectFromUrl(input: string): Promise<void> {
try {
// Check authentication
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
process.exit(1);
}
// Initialize tmCore to access business logic
await this.initTmCore();
// Use shared utility - tm-core handles ALL parsing
const result = await selectBriefFromInput(
this.authManager,
input,
this.tmCore
);
this.setLastResult({
success: result.success,
action: 'select',
currentBrief: result.briefId,
message: result.message
});
if (!result.success) {
process.exit(1);
}
} catch (error) {
ui.displayErrorBox(`Failed to select brief: ${(error as Error).message}`);
this.setLastResult({
success: false,
action: 'select',
message: (error as Error).message
});
process.exit(1);
}
}
/**
* Execute create brief (redirect to web UI)
*/
private async executeCreate(name?: string): Promise<void> {
try {
// Check authentication
if (!(await this.checkAuth())) {
process.exit(1);
}
// Use the bridge to redirect to web UI
const remoteResult = await tryAddTagViaRemote({
tagName: name || 'new-brief',
projectRoot: process.cwd(),
report: (level: LogLevel, ...args: unknown[]) => {
const message = args[0] as string;
if (level === 'error') ui.displayError(message);
else if (level === 'warn') ui.displayWarning(message);
else if (level === 'info') ui.displayInfo(message);
}
});
if (!remoteResult) {
throw new Error('Failed to get brief creation URL');
}
this.setLastResult({
success: remoteResult.success,
action: 'create',
message: remoteResult.message
});
if (!remoteResult.success) {
process.exit(1);
}
} catch (error) {
ui.displayErrorBox(`Failed to create brief: ${(error as Error).message}`);
this.setLastResult({
success: false,
action: 'create',
message: (error as Error).message
});
process.exit(1);
}
}
/**
* Set the last result for programmatic access
*/
private setLastResult(result: BriefsResult): void {
this.lastResult = result;
}
/**
* Get the last result (for programmatic usage)
*/
getLastResult(): BriefsResult | undefined {
return this.lastResult;
}
/**
* Register this command on an existing program
*/
static register(program: Command, name?: string): BriefsCommand {
const briefsCommand = new BriefsCommand(name);
program.addCommand(briefsCommand);
return briefsCommand;
}
}

View File

@@ -6,11 +6,20 @@
import { Command } from 'commander';
import chalk from 'chalk';
import inquirer from 'inquirer';
import search from '@inquirer/search';
import ora, { Ora } from 'ora';
import { AuthManager, type UserContext } from '@tm/core';
import ora from 'ora';
import {
AuthManager,
createTmCore,
type UserContext,
type TmCore
} from '@tm/core';
import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
import { checkAuthentication } from '../utils/auth-helpers.js';
import { getBriefStatusWithColor } from '../ui/formatters/status-formatters.js';
import {
selectBriefInteractive,
selectBriefFromInput
} from '../utils/brief-selection.js';
/**
* Result type from context command
@@ -28,6 +37,7 @@ export interface ContextResult {
*/
export class ContextCommand extends Command {
private authManager: AuthManager;
private tmCore?: TmCore;
private lastResult?: ContextResult;
constructor(name?: string) {
@@ -116,7 +126,8 @@ export class ContextCommand extends Command {
const result = await this.displayContext();
this.setLastResult(result);
} catch (error: any) {
displayError(error);
ui.displayError(`Failed to show context: ${(error as Error).message}`);
process.exit(1);
}
}
@@ -125,11 +136,12 @@ export class ContextCommand extends Command {
*/
private async displayContext(): Promise<ContextResult> {
// Check authentication first
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
console.log(chalk.yellow('✗ Not authenticated'));
console.log(chalk.gray('\n Run "tm auth login" to authenticate first'));
const isAuthenticated = await checkAuthentication(this.authManager, {
message:
'The "context" command requires you to be logged in to your Hamster account.'
});
if (!isAuthenticated) {
return {
success: false,
action: 'show',
@@ -155,7 +167,7 @@ export class ContextCommand extends Command {
if (context.briefName || context.briefId) {
console.log(chalk.green('\n✓ Brief'));
if (context.briefName && context.briefId) {
const shortId = context.briefId.slice(0, 8);
const shortId = context.briefId.slice(-8);
console.log(
chalk.white(` ${context.briefName} `) + chalk.gray(`(${shortId})`)
);
@@ -164,6 +176,26 @@ export class ContextCommand extends Command {
} else if (context.briefId) {
console.log(chalk.gray(` ID: ${context.briefId}`));
}
// Show brief status if available
if (context.briefStatus) {
const statusDisplay = getBriefStatusWithColor(context.briefStatus);
console.log(chalk.gray(` Status: `) + statusDisplay);
}
// Show brief updated date if available
if (context.briefUpdatedAt) {
const updatedDate = new Date(
context.briefUpdatedAt
).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
console.log(chalk.gray(` Updated: ${updatedDate}`));
}
}
if (context.updatedAt) {
@@ -201,9 +233,7 @@ export class ContextCommand extends Command {
private async executeSelectOrg(): Promise<void> {
try {
// Check authentication
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
if (!(await checkAuthentication(this.authManager))) {
process.exit(1);
}
@@ -214,7 +244,10 @@ export class ContextCommand extends Command {
process.exit(1);
}
} catch (error: any) {
displayError(error);
ui.displayError(
`Failed to select organization: ${(error as Error).message}`
);
process.exit(1);
}
}
@@ -281,9 +314,7 @@ export class ContextCommand extends Command {
private async executeSelectBrief(): Promise<void> {
try {
// Check authentication
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
if (!(await checkAuthentication(this.authManager))) {
process.exit(1);
}
@@ -296,116 +327,25 @@ export class ContextCommand extends Command {
process.exit(1);
}
const result = await this.selectBrief(context.orgId);
this.setLastResult(result);
// Use shared utility for interactive selection
const result = await selectBriefInteractive(
this.authManager,
context.orgId
);
this.setLastResult({
success: result.success,
action: 'select-brief',
context: this.authManager.getContext() || undefined,
message: result.message
});
if (!result.success) {
process.exit(1);
}
} catch (error: any) {
displayError(error);
}
}
/**
* Select a brief within the current organization
*/
private async selectBrief(orgId: string): Promise<ContextResult> {
const spinner = ora('Fetching briefs...').start();
try {
// Fetch briefs from API
const briefs = await this.authManager.getBriefs(orgId);
spinner.stop();
if (briefs.length === 0) {
ui.displayWarning('No briefs available in this organization');
return {
success: false,
action: 'select-brief',
message: 'No briefs available'
};
}
// Prompt for selection with search
const selectedBrief = await search<(typeof briefs)[0] | null>({
message: 'Search for a brief:',
source: async (input) => {
const searchTerm = input?.toLowerCase() || '';
// Static option for no brief
const noBriefOption = {
name: '(No brief - organization level)',
value: null as any,
description: 'Clear brief selection'
};
// Filter and map brief options
const briefOptions = briefs
.filter((brief) => {
if (!searchTerm) return true;
const title = brief.document?.title || '';
const shortId = brief.id.slice(0, 8);
// Search by title first, then by UUID
return (
title.toLowerCase().includes(searchTerm) ||
brief.id.toLowerCase().includes(searchTerm) ||
shortId.toLowerCase().includes(searchTerm)
);
})
.map((brief) => {
const title =
brief.document?.title || `Brief ${brief.id.slice(0, 8)}`;
const shortId = brief.id.slice(0, 8);
return {
name: `${title} ${chalk.gray(`(${shortId})`)}`,
value: brief
};
});
return [noBriefOption, ...briefOptions];
}
});
if (selectedBrief) {
// Update context with brief
const briefName =
selectedBrief.document?.title ||
`Brief ${selectedBrief.id.slice(0, 8)}`;
await this.authManager.updateContext({
briefId: selectedBrief.id,
briefName: briefName
});
ui.displaySuccess(`Selected brief: ${briefName}`);
return {
success: true,
action: 'select-brief',
context: this.authManager.getContext() || undefined,
message: `Selected brief: ${selectedBrief.document?.title}`
};
} else {
// Clear brief selection
await this.authManager.updateContext({
briefId: undefined,
briefName: undefined
});
ui.displaySuccess('Cleared brief selection (organization level)');
return {
success: true,
action: 'select-brief',
context: this.authManager.getContext() || undefined,
message: 'Cleared brief selection'
};
}
} catch (error) {
spinner.fail('Failed to fetch briefs');
throw error;
ui.displayError(`Failed to select brief: ${(error as Error).message}`);
process.exit(1);
}
}
@@ -415,9 +355,7 @@ export class ContextCommand extends Command {
private async executeClear(): Promise<void> {
try {
// Check authentication
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
if (!(await checkAuthentication(this.authManager))) {
process.exit(1);
}
@@ -428,7 +366,8 @@ export class ContextCommand extends Command {
process.exit(1);
}
} catch (error: any) {
displayError(error);
ui.displayError(`Failed to clear context: ${(error as Error).message}`);
process.exit(1);
}
}
@@ -462,9 +401,7 @@ export class ContextCommand extends Command {
private async executeSet(options: any): Promise<void> {
try {
// Check authentication
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
if (!(await checkAuthentication(this.authManager))) {
process.exit(1);
}
@@ -475,148 +412,59 @@ export class ContextCommand extends Command {
process.exit(1);
}
} catch (error: any) {
displayError(error);
ui.displayError(`Failed to set context: ${(error as Error).message}`);
process.exit(1);
}
}
/**
* Initialize TmCore if not already initialized
*/
private async initTmCore(): Promise<void> {
if (!this.tmCore) {
this.tmCore = await createTmCore({
projectPath: process.cwd()
});
}
}
/**
* Execute setting context from a brief ID or Hamster URL
* All parsing logic is in tm-core
*/
private async executeSetFromBriefInput(briefOrUrl: string): Promise<void> {
let spinner: Ora | undefined;
private async executeSetFromBriefInput(input: string): Promise<void> {
try {
// Check authentication
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
if (!(await checkAuthentication(this.authManager))) {
process.exit(1);
}
spinner = ora('Resolving brief...');
spinner.start();
// Initialize tmCore to access business logic
await this.initTmCore();
// Extract brief ID
const briefId = this.extractBriefId(briefOrUrl);
if (!briefId) {
spinner.fail('Could not extract a brief ID from the provided input');
ui.displayError(
`Provide a valid brief ID or a Hamster brief URL, e.g. https://${process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN}/home/hamster/briefs/<id>`
);
process.exit(1);
}
// Fetch brief and resolve its organization
const brief = await this.authManager.getBrief(briefId);
if (!brief) {
spinner.fail('Brief not found or you do not have access');
process.exit(1);
}
// 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
}
// Update context: set org and brief
const briefName =
brief.document?.title || `Brief ${brief.id.slice(0, 8)}`;
await this.authManager.updateContext({
orgId: brief.accountId,
orgName,
orgSlug,
briefId: brief.id,
briefName
});
spinner.succeed('Context set from brief');
console.log(
chalk.gray(
` Organization: ${orgName || brief.accountId}\n Brief: ${briefName}`
)
// Use shared utility - tm-core handles ALL parsing
const result = await selectBriefFromInput(
this.authManager,
input,
this.tmCore
);
this.setLastResult({
success: true,
success: result.success,
action: 'set',
context: this.authManager.getContext() || undefined,
message: 'Context set from brief'
message: result.message
});
if (!result.success) {
process.exit(1);
}
} catch (error: any) {
try {
if (spinner?.isSpinning) spinner.stop();
} catch {}
displayError(error);
}
}
/**
* Extract a brief ID from raw input (ID or Hamster 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;
};
// 1) URL (absolute or schemeless)
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) {
// Light sanity check; let API be the final validator
if (this.isLikelyId(candidate) || candidate.length >= 8)
return candidate;
}
}
// 2) Looks like a path without scheme
if (raw.includes('/')) {
const candidate = fromParts(raw);
if (candidate && (this.isLikelyId(candidate) || candidate.length >= 8)) {
return candidate;
}
}
// 3) Fallback: raw token
return raw;
}
/**
* Heuristic to 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; // ULID
const slugRegex = /^[A-Za-z0-9_-]{16,}$/; // general token
return (
uuidRegex.test(value) || ulidRegex.test(value) || slugRegex.test(value)
ui.displayError(
`Failed to set context from brief: ${(error as Error).message}`
);
process.exit(1);
}
}
/**
@@ -731,8 +579,11 @@ export class ContextCommand extends Command {
return { success: false, orgSelected: false, briefSelected: false };
}
// Select brief
const briefResult = await this.selectBrief(orgResult.context.orgId);
// Select brief using shared utility
const briefResult = await selectBriefInteractive(
this.authManager,
orgResult.context.orgId
);
return {
success: true,
orgSelected: true,

View File

@@ -3,19 +3,19 @@
* 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,
type UserContext,
type ExportResult,
createTmCore,
type TmCore
type TmCore,
type UserContext,
createTmCore
} from '@tm/core';
import * as ui from '../utils/ui.js';
import chalk from 'chalk';
import { Command } from 'commander';
import inquirer from 'inquirer';
import ora, { Ora } from 'ora';
import { displayError } from '../utils/error-handler.js';
import * as ui from '../utils/ui.js';
import { getProjectRoot } from '../utils/project-root.js';
/**

View File

@@ -3,34 +3,34 @@
* Extends Commander.Command for better integration with the framework
*/
import { Command } from 'commander';
import chalk from 'chalk';
import {
createTmCore,
OUTPUT_FORMATS,
type OutputFormat,
STATUS_ICONS,
TASK_STATUSES,
type Task,
type TaskStatus,
type TmCore,
TASK_STATUSES,
OUTPUT_FORMATS,
STATUS_ICONS,
type OutputFormat
createTmCore
} from '@tm/core';
import type { StorageType } from '@tm/core';
import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
import { displayCommandHeader } from '../utils/display-helpers.js';
import { getProjectRoot } from '../utils/project-root.js';
import chalk from 'chalk';
import { Command } from 'commander';
import {
displayDashboards,
calculateTaskStatistics,
calculateSubtaskStatistics,
type NextTaskInfo,
calculateDependencyStatistics,
getPriorityBreakdown,
calculateSubtaskStatistics,
calculateTaskStatistics,
displayDashboards,
displayRecommendedNextTask,
getTaskDescription,
displaySuggestedNextSteps,
type NextTaskInfo
getPriorityBreakdown,
getTaskDescription
} from '../ui/index.js';
import { displayCommandHeader } from '../utils/display-helpers.js';
import { displayError } from '../utils/error-handler.js';
import { getProjectRoot } from '../utils/project-root.js';
import * as ui from '../utils/ui.js';
/**
* Options interface for the list command

View File

@@ -2,16 +2,16 @@
* @fileoverview Custom provider handlers for model setup
*/
import { CUSTOM_PROVIDERS } from '@tm/core';
import chalk from 'chalk';
import inquirer from 'inquirer';
import { CUSTOM_PROVIDERS } from '@tm/core';
import { validateOllamaModel, validateOpenRouterModel } from './fetchers.js';
import { CUSTOM_PROVIDER_IDS } from './types.js';
import type {
CustomProviderConfig,
CustomProviderId,
CUSTOM_PROVIDER_IDS,
ModelRole
} from './types.js';
import { validateOpenRouterModel, validateOllamaModel } from './fetchers.js';
/**
* Configuration for all custom providers

View File

@@ -2,9 +2,9 @@
* @fileoverview Model fetching utilities for OpenRouter, Ollama, and other providers
*/
import https from 'https';
import http from 'http';
import type { FetchResult, OpenRouterModel, OllamaModel } from './types.js';
import https from 'https';
import type { FetchResult, OllamaModel, OpenRouterModel } from './types.js';
/**
* Fetch available models from OpenRouter API

View File

@@ -2,17 +2,17 @@
* @fileoverview Interactive prompt logic for model selection
*/
import chalk from 'chalk';
import search, { Separator } from '@inquirer/search';
import chalk from 'chalk';
import { getAvailableModels } from '../../lib/model-management.js';
import type {
ModelRole,
ModelInfo,
CurrentModels,
PromptData,
ModelChoice
} from './types.js';
import { getCustomProviderOptions } from './custom-providers.js';
import type {
CurrentModels,
ModelChoice,
ModelInfo,
ModelRole,
PromptData
} from './types.js';
/**
* Build prompt choices for a specific role

View File

@@ -4,21 +4,21 @@
import chalk from 'chalk';
import {
getConfig,
getModelConfiguration,
setModel,
getConfig,
writeConfig
} from '../../lib/model-management.js';
import type { ModelRole, CurrentModels, CustomProviderId } from './types.js';
import {
customProviderConfigs,
handleCustomProvider
} from './custom-providers.js';
import {
buildPromptChoices,
displaySetupIntro,
promptForModel
} from './prompts.js';
import {
handleCustomProvider,
customProviderConfigs
} from './custom-providers.js';
import type { CurrentModels, CustomProviderId, ModelRole } from './types.js';
/**
* Check if a value is a custom provider ID

View File

@@ -4,14 +4,14 @@
*/
import path from 'node:path';
import { Command } from 'commander';
import chalk from 'chalk';
import boxen from 'boxen';
import { createTmCore, type Task, type TmCore } from '@tm/core';
import { type Task, type TmCore, createTmCore } from '@tm/core';
import type { StorageType } from '@tm/core';
import { displayError } from '../utils/error-handler.js';
import boxen from 'boxen';
import chalk from 'chalk';
import { Command } from 'commander';
import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import { displayCommandHeader } from '../utils/display-helpers.js';
import { displayError } from '../utils/error-handler.js';
import { getProjectRoot } from '../utils/project-root.js';
/**

View File

@@ -3,11 +3,11 @@
* Extends Commander.Command for better integration with the framework
*/
import { Command } from 'commander';
import chalk from 'chalk';
import boxen from 'boxen';
import { createTmCore, type TmCore, type TaskStatus } from '@tm/core';
import { type TaskStatus, type TmCore, createTmCore } from '@tm/core';
import type { StorageType } from '@tm/core';
import boxen from 'boxen';
import chalk from 'chalk';
import { Command } from 'commander';
import { displayError } from '../utils/error-handler.js';
import { getProjectRoot } from '../utils/project-root.js';

View File

@@ -3,15 +3,15 @@
* Extends Commander.Command for better integration with the framework
*/
import { Command } from 'commander';
import chalk from 'chalk';
import boxen from 'boxen';
import { createTmCore, type Task, type TmCore } from '@tm/core';
import { type Task, type TmCore, createTmCore } from '@tm/core';
import type { StorageType, Subtask } from '@tm/core';
import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
import boxen from 'boxen';
import chalk from 'chalk';
import { Command } from 'commander';
import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import { displayCommandHeader } from '../utils/display-helpers.js';
import { displayError } from '../utils/error-handler.js';
import * as ui from '../utils/ui.js';
import { getProjectRoot } from '../utils/project-root.js';
/**

View File

@@ -4,19 +4,19 @@
* This is a thin presentation layer over @tm/core's TaskExecutionService
*/
import { Command } from 'commander';
import chalk from 'chalk';
import boxen from 'boxen';
import ora, { type Ora } from 'ora';
import { spawn } from 'child_process';
import {
createTmCore,
type StartTaskResult as CoreStartTaskResult,
type TmCore,
type StartTaskResult as CoreStartTaskResult
createTmCore
} from '@tm/core';
import boxen from 'boxen';
import chalk from 'chalk';
import { Command } from 'commander';
import ora, { type Ora } from 'ora';
import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
import * as ui from '../utils/ui.js';
import { getProjectRoot } from '../utils/project-root.js';
/**

View File

@@ -0,0 +1,545 @@
/**
* @fileoverview Tags Command - Manage task organization with tags
* Provides tag/brief management with file and API storage support
*/
import { Command } from 'commander';
import type { TmCore } from '@tm/core';
import { createTmCore, getProjectPaths } from '@tm/core';
import { displayError } from '../utils/index.js';
/**
* TODO: TECH DEBT - Architectural Refactor Needed
*
* Current State:
* - This command imports legacy JS functions from scripts/modules/task-manager/tag-management.js
* - These functions contain business logic that violates architecture guidelines (see CLAUDE.md)
*
* Target State:
* - Move all business logic to TagService in @tm/core
* - CLI should only handle presentation (argument parsing, output formatting)
* - Remove dependency on legacy scripts/ directory
*
* Complexity:
* - Legacy functions handle both API and file storage via bridge pattern
* - Need to migrate API integration logic to @tm/core first
* - Affects MCP layer as well (should share same @tm/core APIs)
*
* Priority: Medium (improves testability, maintainability, and code reuse)
*/
import {
createTag as legacyCreateTag,
deleteTag as legacyDeleteTag,
tags as legacyListTags,
useTag as legacyUseTag,
renameTag as legacyRenameTag,
copyTag as legacyCopyTag
} from '../../../../scripts/modules/task-manager/tag-management.js';
/**
* Result type from tags command
*/
export interface TagsResult {
success: boolean;
action: 'list' | 'add' | 'use' | 'remove' | 'rename' | 'copy';
tags?: any[];
currentTag?: string | null;
message?: string;
}
/**
* Legacy function return types
*/
interface LegacyListTagsResult {
tags: any[];
currentTag: string | null;
totalTags: number;
}
interface LegacyUseTagResult {
currentTag: string;
}
interface LegacyCreateTagOptions {
description?: string;
copyFromTag?: string;
fromBranch?: boolean;
}
/**
* TagsCommand - Manage tags/briefs for task organization
*/
export class TagsCommand extends Command {
private tmCore?: TmCore;
private lastResult?: TagsResult;
private throwOnError: boolean = false;
constructor(name?: string) {
super(name || 'tags');
// Configure the command
this.description('Manage tags for task organization');
// Add subcommands
this.addListCommand();
this.addAddCommand();
this.addUseCommand();
this.addRemoveCommand();
this.addRenameCommand();
this.addCopyCommand();
// Default action: list tags
this.action(async () => {
await this.executeList();
});
}
/**
* Add list subcommand
*/
private addListCommand(): void {
this.command('list')
.description('List all tags with statistics (default action)')
.option('--show-metadata', 'Show additional tag metadata')
.addHelpText(
'after',
`
Examples:
$ tm tags # List all tags (default)
$ tm tags list # List all tags (explicit)
$ tm tags list --show-metadata # List with metadata
`
)
.action(async (options) => {
await this.executeList(options);
});
}
/**
* Add add subcommand
*/
private addAddCommand(): void {
this.command('add')
.description('Create a new tag')
.argument('<name>', 'Name of the tag to create')
.option('--description <desc>', 'Tag description')
.option('--copy-from <tag>', 'Copy tasks from another tag')
.option('--from-branch', 'Create tag from current git branch name')
.addHelpText(
'after',
`
Examples:
$ tm tags add feature-auth # Create new tag
$ tm tags add sprint-2 --copy-from sprint-1 # Create with tasks copied
$ tm tags add --from-branch # Create from current git branch
Note: When using API storage, this will redirect you to the web UI to create a brief.
`
)
.action(async (name, options) => {
await this.executeAdd(name, options);
});
}
/**
* Add use subcommand
*/
private addUseCommand(): void {
this.command('use')
.description('Switch to a different tag')
.argument('<name>', 'Name or ID of the tag to switch to')
.addHelpText(
'after',
`
Examples:
$ tm tags use feature-auth # Switch by name
$ tm tags use abc123 # Switch by ID (last 8 chars)
Note: For API storage, this switches the active brief in your context.
`
)
.action(async (name) => {
await this.executeUse(name);
});
}
/**
* Add remove subcommand
*/
private addRemoveCommand(): void {
this.command('remove')
.description('Remove a tag')
.argument('<name>', 'Name or ID of the tag to remove')
.option('-y, --yes', 'Skip confirmation prompt')
.addHelpText(
'after',
`
Examples:
$ tm tags remove old-feature # Remove tag with confirmation
$ tm tags remove old-feature -y # Remove without confirmation
Warning: This will delete all tasks in the tag!
`
)
.action(async (name, options) => {
await this.executeRemove(name, options);
});
}
/**
* Add rename subcommand
*/
private addRenameCommand(): void {
this.command('rename')
.description('Rename a tag')
.argument('<oldName>', 'Current tag name')
.argument('<newName>', 'New tag name')
.addHelpText(
'after',
`
Examples:
$ tm tags rename old-name new-name
`
)
.action(async (oldName, newName) => {
await this.executeRename(oldName, newName);
});
}
/**
* Add copy subcommand
*/
private addCopyCommand(): void {
this.command('copy')
.description('Copy a tag with all its tasks')
.argument('<source>', 'Source tag name')
.argument('<target>', 'Target tag name')
.option('--description <desc>', 'Description for the new tag')
.addHelpText(
'after',
`
Examples:
$ tm tags copy sprint-1 sprint-2
$ tm tags copy sprint-1 sprint-2 --description "Next sprint tasks"
`
)
.action(async (source, target, options) => {
await this.executeCopy(source, target, options);
});
}
/**
* Initialize TmCore if not already initialized
* Required for bridge functions to work properly
*/
private async initTmCore(): Promise<void> {
if (!this.tmCore) {
this.tmCore = await createTmCore({
projectPath: process.cwd()
});
}
}
/**
* Execute list tags
*/
private async executeList(options?: {
showMetadata?: boolean;
}): Promise<void> {
try {
// Initialize tmCore first (needed by bridge functions)
await this.initTmCore();
const { projectRoot, tasksPath } = getProjectPaths();
// Use legacy function which handles both API and file storage
const listResult = (await legacyListTags(
tasksPath,
{
showTaskCounts: true,
showMetadata: options?.showMetadata || false
},
{ projectRoot },
'text'
)) as LegacyListTagsResult;
this.setLastResult({
success: true,
action: 'list',
tags: listResult.tags,
currentTag: listResult.currentTag,
message: `Found ${listResult.totalTags} tag(s)`
});
} catch (error: any) {
displayError(error);
this.setLastResult({
success: false,
action: 'list',
message: error.message
});
this.handleError(
error instanceof Error
? error
: new Error(error.message || String(error))
);
}
}
/**
* Execute add tag
*/
private async executeAdd(
name: string,
options?: {
description?: string;
copyFrom?: string;
fromBranch?: boolean;
}
): Promise<void> {
try {
// Initialize tmCore first (needed by bridge functions)
await this.initTmCore();
const { projectRoot, tasksPath } = getProjectPaths();
// Use legacy function which handles both API and file storage
await legacyCreateTag(
tasksPath,
name,
{
description: options?.description,
copyFromTag: options?.copyFrom,
fromBranch: options?.fromBranch
} as LegacyCreateTagOptions,
{ projectRoot },
'text'
);
this.setLastResult({
success: true,
action: 'add',
message: `Created tag: ${name}`
});
} catch (error: any) {
displayError(error);
this.setLastResult({
success: false,
action: 'add',
message: error.message
});
this.handleError(
error instanceof Error
? error
: new Error(error.message || String(error))
);
}
}
/**
* Execute use/switch tag
*/
private async executeUse(name: string): Promise<void> {
try {
// Initialize tmCore first (needed by bridge functions)
await this.initTmCore();
const { projectRoot, tasksPath } = getProjectPaths();
// Use legacy function which handles both API and file storage
const useResult = (await legacyUseTag(
tasksPath,
name,
{},
{ projectRoot },
'text'
)) as LegacyUseTagResult;
this.setLastResult({
success: true,
action: 'use',
currentTag: useResult.currentTag,
message: `Switched to tag: ${name}`
});
} catch (error: any) {
displayError(error);
this.setLastResult({
success: false,
action: 'use',
message: error.message
});
this.handleError(
error instanceof Error
? error
: new Error(error.message || String(error))
);
}
}
/**
* Execute remove tag
*/
private async executeRemove(
name: string,
options?: { yes?: boolean }
): Promise<void> {
try {
// Initialize tmCore first (needed by bridge functions)
await this.initTmCore();
const { projectRoot, tasksPath } = getProjectPaths();
// Use legacy function which handles both API and file storage
await legacyDeleteTag(
tasksPath,
name,
{ yes: options?.yes || false },
{ projectRoot },
'text'
);
this.setLastResult({
success: true,
action: 'remove',
message: `Removed tag: ${name}`
});
} catch (error: any) {
displayError(error);
this.setLastResult({
success: false,
action: 'remove',
message: error.message
});
this.handleError(
error instanceof Error
? error
: new Error(error.message || String(error))
);
}
}
/**
* Execute rename tag
*/
private async executeRename(oldName: string, newName: string): Promise<void> {
try {
// Initialize tmCore first (needed by bridge functions)
await this.initTmCore();
const { projectRoot, tasksPath } = getProjectPaths();
// Use legacy function which handles both API and file storage
await legacyRenameTag(
tasksPath,
oldName,
newName,
{},
{ projectRoot },
'text'
);
this.setLastResult({
success: true,
action: 'rename',
message: `Renamed tag from "${oldName}" to "${newName}"`
});
} catch (error: any) {
displayError(error);
this.setLastResult({
success: false,
action: 'rename',
message: error.message
});
this.handleError(
error instanceof Error
? error
: new Error(error.message || String(error))
);
}
}
/**
* Execute copy tag
*/
private async executeCopy(
source: string,
target: string,
options?: { description?: string }
): Promise<void> {
try {
// Initialize tmCore first (needed by bridge functions)
await this.initTmCore();
const { projectRoot, tasksPath } = getProjectPaths();
// Use legacy function which handles both API and file storage
await legacyCopyTag(
tasksPath,
source,
target,
{ description: options?.description },
{ projectRoot },
'text'
);
this.setLastResult({
success: true,
action: 'copy',
message: `Copied tag from "${source}" to "${target}"`
});
} catch (error: any) {
displayError(error);
this.setLastResult({
success: false,
action: 'copy',
message: error.message
});
this.handleError(
error instanceof Error
? error
: new Error(error.message || String(error))
);
}
}
/**
* Set the last result for programmatic access
*/
private setLastResult(result: TagsResult): void {
this.lastResult = result;
}
/**
* Get the last result (for programmatic usage)
*/
getLastResult(): TagsResult | undefined {
return this.lastResult;
}
/**
* Enable throwing errors instead of process.exit for programmatic usage
* @param shouldThrow If true, throws errors; if false, calls process.exit (default)
*/
public setThrowOnError(shouldThrow: boolean): this {
this.throwOnError = shouldThrow;
return this;
}
/**
* Handle error by either exiting or throwing based on throwOnError flag
*/
private handleError(error: Error): never {
if (this.throwOnError) {
throw error;
}
process.exit(1);
}
/**
* Register this command on an existing program
*/
static register(program: Command, name?: string): TagsCommand {
const tagsCommand = new TagsCommand(name);
program.addCommand(tagsCommand);
return tagsCommand;
}
}

View File

@@ -12,6 +12,8 @@ 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';
export { TagsCommand } from './commands/tags.command.js';
export { BriefsCommand } from './commands/briefs.command.js';
// Command Registry
export {
@@ -21,20 +23,12 @@ export {
type CommandMetadata
} from './command-registry.js';
// UI utilities (for other commands to use)
export * as ui from './utils/ui.js';
// General utilities (error handling, auto-update, etc.)
export * from './utils/index.js';
// Error handling utilities
export { displayError, isDebugMode } from './utils/error-handler.js';
// Auto-update utilities
export {
checkForUpdate,
performAutoUpdate,
displayUpgradeNotification,
compareVersions,
restartWithNewVersion
} from './utils/auto-update.js';
// UI utilities - exported only via ui namespace to avoid naming conflicts
// Import via: import { ui } from '@tm/cli'; ui.displayBanner();
export * as ui from './ui/index.js';
export { runInteractiveSetup } from './commands/models/index.js';

View File

@@ -4,10 +4,10 @@
* Will remove once we move models.js and config-manager to new structure
*/
// @ts-ignore - JavaScript module without types
import * as modelsJs from '../../../../scripts/modules/task-manager/models.js';
// @ts-ignore - JavaScript module without types
import * as configManagerJs from '../../../../scripts/modules/config-manager.js';
// @ts-ignore - JavaScript module without types
import * as modelsJs from '../../../../scripts/modules/task-manager/models.js';
// ========== Types ==========

55
apps/cli/src/types/tag-management.d.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
/**
* Type declarations for legacy tag-management.js
* TODO: Remove when refactored to use @tm/core
*/
declare module '*/tag-management.js' {
export function createTag(
tasksPath: string,
tagName: string,
options?: any,
context?: any,
outputFormat?: string
): Promise<any>;
export function deleteTag(
tasksPath: string,
tagName: string,
options?: any,
context?: any,
outputFormat?: string
): Promise<any>;
export function tags(
tasksPath: string,
options?: any,
context?: any,
outputFormat?: string
): Promise<any>;
export function useTag(
tasksPath: string,
tagName: string,
options?: any,
context?: any,
outputFormat?: string
): Promise<any>;
export function renameTag(
tasksPath: string,
oldName: string,
newName: string,
options?: any,
context?: any,
outputFormat?: string
): Promise<any>;
export function copyTag(
tasksPath: string,
sourceName: string,
targetName: string,
options?: any,
context?: any,
outputFormat?: string
): Promise<any>;
}

View File

@@ -0,0 +1,60 @@
import boxen from 'boxen';
import chalk from 'chalk';
/**
* Configuration for the card box component
*/
export interface CardBoxConfig {
/** Header text displayed in yellow bold */
header: string;
/** Body paragraphs displayed in white */
body: string[];
/** Call to action section with label and URL */
callToAction: {
label: string;
action: string;
};
/** Footer text displayed in gray (usage instructions) */
footer?: string;
}
/**
* Creates a formatted boxen card with header, body, call-to-action, and optional footer.
* A reusable component for displaying informational messages in a styled box.
*
* @param config - Configuration for the box sections
* @returns Formatted string ready for console.log
*/
export function displayCardBox(config: CardBoxConfig): string {
const { header, body, callToAction, footer } = config;
// Build the content sections
const sections: string[] = [
// Header
chalk.yellow.bold(header),
// Body paragraphs
...body.map((paragraph) => chalk.white(paragraph)),
// Call to action
chalk.cyan(callToAction.label) +
'\n' +
chalk.blue.underline(callToAction.action)
];
// Add footer if provided
if (footer) {
sections.push(chalk.gray(footer));
}
// Join sections with double newlines
const content = sections.join('\n\n');
// Wrap in boxen
return boxen(content, {
padding: 1,
borderColor: 'yellow',
borderStyle: 'round',
margin: { top: 1, bottom: 1 }
});
}

View File

@@ -3,9 +3,9 @@
* Displays project statistics and dependency information
*/
import chalk from 'chalk';
import boxen from 'boxen';
import type { Task, TaskPriority } from '@tm/core';
import boxen from 'boxen';
import chalk from 'chalk';
import { getComplexityWithColor } from '../../utils/ui.js';
/**

View File

@@ -2,8 +2,9 @@
* @fileoverview UI components exports
*/
export * from './header.component.js';
export * from './cardBox.component.js';
export * from './dashboard.component.js';
export * from './header.component.js';
export * from './next-task.component.js';
export * from './suggested-steps.component.js';
export * from './task-detail.component.js';

View File

@@ -3,10 +3,10 @@
* Displays detailed information about the recommended next task
*/
import chalk from 'chalk';
import boxen from 'boxen';
import type { Task } from '@tm/core';
import { getComplexityWithColor, getBoxWidth } from '../../utils/ui.js';
import boxen from 'boxen';
import chalk from 'chalk';
import { getBoxWidth, getComplexityWithColor } from '../../utils/ui.js';
/**
* Next task display options

View File

@@ -3,8 +3,8 @@
* Displays helpful command suggestions at the end of the list
*/
import chalk from 'chalk';
import boxen from 'boxen';
import chalk from 'chalk';
import { getBoxWidth } from '../../utils/ui.js';
/**

View File

@@ -3,16 +3,16 @@
* Displays detailed task information in a structured format
*/
import chalk from 'chalk';
import boxen from 'boxen';
import Table from 'cli-table3';
import { marked, MarkedExtension } from 'marked';
import { markedTerminal } from 'marked-terminal';
import type { Subtask, Task } from '@tm/core';
import boxen from 'boxen';
import chalk from 'chalk';
import Table from 'cli-table3';
import { MarkedExtension, marked } from 'marked';
import { markedTerminal } from 'marked-terminal';
import {
getStatusWithColor,
getComplexityWithColor,
getPriorityWithColor,
getComplexityWithColor
getStatusWithColor
} from '../../utils/ui.js';
// Configure marked to use terminal renderer with subtle colors

View File

@@ -0,0 +1,102 @@
/**
* @fileoverview Display message utilities
* Provides formatted message boxes for errors, success, warnings, info, and banners
*/
import boxen from 'boxen';
import chalk from 'chalk';
import { getBoxWidth } from '../layout/helpers.js';
/**
* Display a fancy banner
*/
export function displayBanner(title: string = 'Task Master'): void {
console.log(
boxen(chalk.white.bold(title), {
padding: 1,
margin: { top: 1, bottom: 1 },
borderStyle: 'round',
borderColor: 'blue',
textAlignment: 'center'
})
);
}
/**
* Display an error message in a boxed format (matches scripts/modules/ui.js style)
* Note: For general CLI error handling, use displayError from utils/error-handler.ts
* This function is for displaying formatted error messages as part of UI output.
*/
export function displayErrorBox(message: string, details?: string): void {
const boxWidth = getBoxWidth();
console.error(
boxen(
chalk.red.bold('X Error: ') +
chalk.white(message) +
(details ? '\n\n' + chalk.gray(details) : ''),
{
padding: 1,
borderStyle: 'round',
borderColor: 'red',
width: boxWidth
}
)
);
}
/**
* Alias for displayErrorBox
*/
export const displayError = displayErrorBox;
/**
* Display a success message
*/
export function displaySuccess(message: string): void {
const boxWidth = getBoxWidth();
console.log(
boxen(
chalk.green.bold(String.fromCharCode(8730) + ' ') + chalk.white(message),
{
padding: 1,
borderStyle: 'round',
borderColor: 'green',
width: boxWidth
}
)
);
}
/**
* Display a warning message
*/
export function displayWarning(message: string): void {
const boxWidth = getBoxWidth();
console.log(
boxen(chalk.yellow.bold('⚠️ ') + chalk.white(message), {
padding: 1,
borderStyle: 'round',
borderColor: 'yellow',
width: boxWidth
})
);
}
/**
* Display info message
*/
export function displayInfo(message: string): void {
const boxWidth = getBoxWidth();
console.log(
boxen(chalk.blue.bold('i ') + chalk.white(message), {
padding: 1,
borderStyle: 'round',
borderColor: 'blue',
width: boxWidth
})
);
}

View File

@@ -0,0 +1,145 @@
/**
* @fileoverview Table display utilities
* Provides table creation and formatting for tasks
*/
import type { Subtask, Task, TaskPriority } from '@tm/core';
import chalk from 'chalk';
import Table from 'cli-table3';
import { getComplexityWithColor } from '../formatters/complexity-formatters.js';
import { getPriorityWithColor } from '../formatters/priority-formatters.js';
import { getStatusWithColor } from '../formatters/status-formatters.js';
import { getBoxWidth, truncate } from '../layout/helpers.js';
/**
* Default priority for tasks/subtasks when not specified
*/
const DEFAULT_PRIORITY: TaskPriority = 'medium';
/**
* Create a task table for display
*/
export function createTaskTable(
tasks: (Task | Subtask)[],
options?: {
showSubtasks?: boolean;
showComplexity?: boolean;
showDependencies?: boolean;
}
): string {
const {
showSubtasks = false,
showComplexity = false,
showDependencies = true
} = options || {};
// Calculate dynamic column widths based on terminal width
const tableWidth = getBoxWidth(0.9, 100);
// Adjust column widths to better match the original layout
const baseColWidths = showComplexity
? [
Math.floor(tableWidth * 0.1),
Math.floor(tableWidth * 0.4),
Math.floor(tableWidth * 0.15),
Math.floor(tableWidth * 0.1),
Math.floor(tableWidth * 0.2),
Math.floor(tableWidth * 0.1)
] // ID, Title, Status, Priority, Dependencies, Complexity
: [
Math.floor(tableWidth * 0.08),
Math.floor(tableWidth * 0.4),
Math.floor(tableWidth * 0.18),
Math.floor(tableWidth * 0.12),
Math.floor(tableWidth * 0.2)
]; // ID, Title, Status, Priority, Dependencies
const headers = [
chalk.blue.bold('ID'),
chalk.blue.bold('Title'),
chalk.blue.bold('Status'),
chalk.blue.bold('Priority')
];
const colWidths = baseColWidths.slice(0, 4);
if (showDependencies) {
headers.push(chalk.blue.bold('Dependencies'));
colWidths.push(baseColWidths[4]);
}
if (showComplexity) {
headers.push(chalk.blue.bold('Complexity'));
colWidths.push(baseColWidths[5] || 12);
}
const table = new Table({
head: headers,
style: { head: [], border: [] },
colWidths,
wordWrap: true
});
tasks.forEach((task) => {
const row: string[] = [
chalk.cyan(task.id.toString()),
truncate(task.title, colWidths[1] - 3),
getStatusWithColor(task.status, true), // Use table version
getPriorityWithColor(task.priority)
];
if (showDependencies) {
// For table display, show simple format without status icons
if (!task.dependencies || task.dependencies.length === 0) {
row.push(chalk.gray('None'));
} else {
row.push(
chalk.cyan(task.dependencies.map((d) => String(d)).join(', '))
);
}
}
if (showComplexity) {
// Show complexity score from report if available
if (typeof task.complexity === 'number') {
row.push(getComplexityWithColor(task.complexity));
} else {
row.push(chalk.gray('N/A'));
}
}
table.push(row);
// Add subtasks if requested
if (showSubtasks && task.subtasks && task.subtasks.length > 0) {
task.subtasks.forEach((subtask) => {
const subRow: string[] = [
chalk.gray(` └─ ${subtask.id}`),
chalk.gray(truncate(subtask.title, colWidths[1] - 6)),
chalk.gray(getStatusWithColor(subtask.status, true)),
chalk.gray(subtask.priority || DEFAULT_PRIORITY)
];
if (showDependencies) {
subRow.push(
chalk.gray(
subtask.dependencies && subtask.dependencies.length > 0
? subtask.dependencies.map((dep) => String(dep)).join(', ')
: 'None'
)
);
}
if (showComplexity) {
const complexityDisplay =
typeof subtask.complexity === 'number'
? getComplexityWithColor(subtask.complexity)
: '--';
subRow.push(chalk.gray(complexityDisplay));
}
table.push(subRow);
});
}
});
return table.toString();
}

View File

@@ -0,0 +1,49 @@
/**
* @fileoverview Complexity formatting utilities
* Provides colored complexity displays with labels and scores
*/
import chalk from 'chalk';
/**
* Get complexity color and label based on score thresholds
*/
function getComplexityLevel(score: number): {
color: (text: string) => string;
label: string;
} {
if (score >= 7) {
return { color: chalk.hex('#CC0000'), label: 'High' };
} else if (score >= 4) {
return { color: chalk.hex('#FF8800'), label: 'Medium' };
} else {
return { color: chalk.green, label: 'Low' };
}
}
/**
* Get colored complexity display with dot indicator (simple format)
*/
export function getComplexityWithColor(complexity: number | string): string {
const score =
typeof complexity === 'string' ? Number(complexity.trim()) : complexity;
if (isNaN(score)) {
return chalk.gray('N/A');
}
const { color } = getComplexityLevel(score);
return color(`${score}`);
}
/**
* Get colored complexity display with /10 format (for dashboards)
*/
export function getComplexityWithScore(complexity: number | undefined): string {
if (typeof complexity !== 'number') {
return chalk.gray('N/A');
}
const { color, label } = getComplexityLevel(complexity);
return color(`${complexity}/10 (${label})`);
}

View File

@@ -0,0 +1,39 @@
/**
* @fileoverview Dependency formatting utilities
* Provides formatted dependency displays with status indicators
*/
import type { Task } from '@tm/core';
import chalk from 'chalk';
/**
* Format dependencies with their status
*/
export function formatDependenciesWithStatus(
dependencies: string[] | number[],
tasks: Task[]
): string {
if (!dependencies || dependencies.length === 0) {
return chalk.gray('none');
}
const taskMap = new Map(tasks.map((t) => [t.id.toString(), t]));
return dependencies
.map((depId) => {
const task = taskMap.get(depId.toString());
if (!task) {
return chalk.red(`${depId} (not found)`);
}
const statusIcon =
task.status === 'done'
? '✓'
: task.status === 'in-progress'
? '►'
: '○';
return `${depId}${statusIcon}`;
})
.join(', ');
}

View File

@@ -0,0 +1,25 @@
/**
* @fileoverview Priority formatting utilities
* Provides colored priority displays for tasks
*/
import type { TaskPriority } from '@tm/core';
import chalk from 'chalk';
/**
* Module-level priority color map to avoid recreating on every call
*/
const PRIORITY_COLORS: Record<TaskPriority, (text: string) => string> = {
critical: chalk.red.bold,
high: chalk.red,
medium: chalk.yellow,
low: chalk.gray
};
/**
* Get colored priority display
*/
export function getPriorityWithColor(priority: TaskPriority): string {
const colorFn = PRIORITY_COLORS[priority] || chalk.white;
return colorFn(priority);
}

View File

@@ -0,0 +1,139 @@
/**
* Status formatter tests
* Tests for apps/cli/src/utils/formatters/status-formatters.ts
*/
import { describe, expect, it } from 'vitest';
import {
capitalizeStatus,
getBriefStatusColor,
getBriefStatusIcon,
getBriefStatusWithColor
} from './status-formatters.js';
describe('Status Formatters', () => {
describe('getBriefStatusWithColor', () => {
it('should format draft status with gray color and circle icon', () => {
const result = getBriefStatusWithColor('draft', true);
expect(result).toContain('Draft');
expect(result).toContain('○');
});
it('should format refining status with yellow color and half-circle icon', () => {
const result = getBriefStatusWithColor('refining', true);
expect(result).toContain('Refining');
expect(result).toContain('◐');
});
it('should format aligned status with cyan color and target icon', () => {
const result = getBriefStatusWithColor('aligned', true);
expect(result).toContain('Aligned');
expect(result).toContain('◎');
});
it('should format delivering status with orange color and play icon', () => {
const result = getBriefStatusWithColor('delivering', true);
expect(result).toContain('Delivering');
expect(result).toContain('▶');
});
it('should format delivered status with blue color and diamond icon', () => {
const result = getBriefStatusWithColor('delivered', true);
expect(result).toContain('Delivered');
expect(result).toContain('◆');
});
it('should format done status with green color and checkmark icon', () => {
const result = getBriefStatusWithColor('done', true);
expect(result).toContain('Done');
expect(result).toContain('✓');
});
it('should format archived status with gray color and square icon', () => {
const result = getBriefStatusWithColor('archived', true);
expect(result).toContain('Archived');
expect(result).toContain('■');
});
it('should handle unknown status with red color and question mark', () => {
const result = getBriefStatusWithColor('unknown-status', true);
expect(result).toContain('Unknown-status');
expect(result).toContain('?');
});
it('should handle undefined status with gray color', () => {
const result = getBriefStatusWithColor(undefined, true);
expect(result).toContain('Unknown');
expect(result).toContain('○');
});
it('should use same icon for table and non-table display', () => {
const tableResult = getBriefStatusWithColor('done', true);
const nonTableResult = getBriefStatusWithColor('done', false);
expect(tableResult).toBe(nonTableResult);
});
it('should handle case-insensitive status names', () => {
const lowerResult = getBriefStatusWithColor('draft', true);
const upperResult = getBriefStatusWithColor('DRAFT', true);
const mixedResult = getBriefStatusWithColor('DrAfT', true);
expect(lowerResult).toContain('Draft');
expect(upperResult).toContain('Draft');
expect(mixedResult).toContain('Draft');
});
});
describe('getBriefStatusIcon', () => {
it('should return correct icon for status', () => {
expect(getBriefStatusIcon('draft')).toBe('○');
expect(getBriefStatusIcon('done')).toBe('✓');
expect(getBriefStatusIcon('delivering')).toBe('▶');
});
it('should return default icon for unknown status', () => {
expect(getBriefStatusIcon('unknown-status')).toBe('?');
});
it('should return default icon for undefined', () => {
expect(getBriefStatusIcon(undefined)).toBe('○');
});
it('should return same icon for table and non-table', () => {
expect(getBriefStatusIcon('done', true)).toBe(
getBriefStatusIcon('done', false)
);
});
});
describe('getBriefStatusColor', () => {
it('should return a color function', () => {
const colorFn = getBriefStatusColor('draft');
expect(typeof colorFn).toBe('function');
const result = colorFn('test');
expect(typeof result).toBe('string');
});
it('should return gray color for undefined', () => {
const colorFn = getBriefStatusColor(undefined);
expect(typeof colorFn).toBe('function');
});
});
describe('capitalizeStatus', () => {
it('should capitalize first letter and lowercase rest', () => {
expect(capitalizeStatus('draft')).toBe('Draft');
expect(capitalizeStatus('DRAFT')).toBe('Draft');
expect(capitalizeStatus('DrAfT')).toBe('Draft');
expect(capitalizeStatus('in-progress')).toBe('In-progress');
});
it('should handle single character', () => {
expect(capitalizeStatus('a')).toBe('A');
expect(capitalizeStatus('A')).toBe('A');
});
it('should handle empty string', () => {
expect(capitalizeStatus('')).toBe('');
});
});
});

View File

@@ -0,0 +1,179 @@
/**
* @fileoverview Status formatting utilities
* Provides colored status displays with ASCII icons for tasks and briefs
*/
import type { TaskStatus } from '@tm/core';
import chalk from 'chalk';
/**
* Module-level task status configuration to avoid recreating on every call
*/
const TASK_STATUS_CONFIG: Record<
TaskStatus,
{ color: (text: string) => string; icon: string; tableIcon: string }
> = {
done: {
color: chalk.green,
icon: '✓',
tableIcon: '✓'
},
pending: {
color: chalk.yellow,
icon: '○',
tableIcon: '○'
},
'in-progress': {
color: chalk.hex('#FFA500'),
icon: '▶',
tableIcon: '▶'
},
deferred: {
color: chalk.gray,
icon: 'x',
tableIcon: 'x'
},
review: {
color: chalk.magenta,
icon: '?',
tableIcon: '?'
},
cancelled: {
color: chalk.gray,
icon: 'x',
tableIcon: 'x'
},
blocked: {
color: chalk.red,
icon: '!',
tableIcon: '!'
},
completed: {
color: chalk.green,
icon: '✓',
tableIcon: '✓'
}
};
/**
* Get colored status display with ASCII icons (matches scripts/modules/ui.js style)
*/
export function getStatusWithColor(
status: TaskStatus,
forTable: boolean = false
): string {
const config = TASK_STATUS_CONFIG[status] || {
color: chalk.red,
icon: 'X',
tableIcon: 'X'
};
const icon = forTable ? config.tableIcon : config.icon;
return config.color(`${icon} ${status}`);
}
/**
* Brief status configuration
*/
const BRIEF_STATUS_CONFIG: Record<
string,
{ color: (text: string) => string; icon: string; tableIcon: string }
> = {
draft: {
color: chalk.gray,
icon: '○',
tableIcon: '○'
},
refining: {
color: chalk.yellow,
icon: '◐',
tableIcon: '◐'
},
aligned: {
color: chalk.cyan,
icon: '◎',
tableIcon: '◎'
},
delivering: {
color: chalk.hex('#FFA500'), // orange
icon: '▶',
tableIcon: '▶'
},
delivered: {
color: chalk.blue,
icon: '◆',
tableIcon: '◆'
},
done: {
color: chalk.green,
icon: '✓',
tableIcon: '✓'
},
archived: {
color: chalk.gray,
icon: '■',
tableIcon: '■'
}
};
/**
* Get the configuration for a brief status
*/
function getBriefStatusConfig(status: string) {
// Normalize to lowercase for lookup
const normalizedStatus = status.toLowerCase();
return (
BRIEF_STATUS_CONFIG[normalizedStatus] || {
color: chalk.red,
icon: '?',
tableIcon: '?'
}
);
}
/**
* Get the icon for a brief status
*/
export function getBriefStatusIcon(
status: string | undefined,
forTable: boolean = false
): string {
if (!status) return '○';
const config = getBriefStatusConfig(status);
return forTable ? config.tableIcon : config.icon;
}
/**
* Get the color function for a brief status
*/
export function getBriefStatusColor(
status: string | undefined
): (text: string) => string {
if (!status) return chalk.gray;
return getBriefStatusConfig(status).color;
}
/**
* Capitalize the first letter of a status
*/
export function capitalizeStatus(status: string): string {
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
}
/**
* Get colored brief/tag status display with ASCII icons
* Brief statuses: draft, refining, aligned, delivering, delivered, done, archived
*/
export function getBriefStatusWithColor(
status: string | undefined,
forTable: boolean = false
): string {
if (!status) {
return chalk.gray('○ Unknown');
}
const config = getBriefStatusConfig(status);
const icon = forTable ? config.tableIcon : config.icon;
const displayStatus = capitalizeStatus(status);
return config.color(`${icon} ${displayStatus}`);
}

View File

@@ -1,9 +1,50 @@
/**
* @fileoverview Main UI exports
* Organized UI system with components, formatters, display primitives, and layout helpers
*/
// Export all components
// High-level UI components
export * from './components/index.js';
// Re-export existing UI utilities
export * from '../utils/ui.js';
// Status formatters
export {
getStatusWithColor,
getBriefStatusWithColor,
getBriefStatusIcon,
getBriefStatusColor,
capitalizeStatus
} from './formatters/status-formatters.js';
// Priority formatters
export { getPriorityWithColor } from './formatters/priority-formatters.js';
// Complexity formatters
export {
getComplexityWithColor,
getComplexityWithScore
} from './formatters/complexity-formatters.js';
// Dependency formatters
export { formatDependenciesWithStatus } from './formatters/dependency-formatters.js';
// Layout helpers
export {
getBoxWidth,
truncate,
createProgressBar
} from './layout/helpers.js';
// Display messages
// Note: displayError alias is available via namespace (ui.displayError) for backward compat
// but not exported at package level to avoid conflicts with utils/error-handler.ts
export {
displayBanner,
displayErrorBox,
displayError, // Backward compatibility alias
displaySuccess,
displayWarning,
displayInfo
} from './display/messages.js';
// Display tables
export { createTaskTable } from './display/tables.js';

View File

@@ -0,0 +1,158 @@
/**
* Layout helper utilities tests
* Tests for apps/cli/src/utils/layout/helpers.ts
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MockInstance } from 'vitest';
import { getBoxWidth } from './helpers.js';
describe('Layout Helpers', () => {
describe('getBoxWidth', () => {
let columnsSpy: MockInstance;
let originalDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
// Store original descriptor if it exists
originalDescriptor = Object.getOwnPropertyDescriptor(
process.stdout,
'columns'
);
// If columns doesn't exist or isn't a getter, define it as one
if (!originalDescriptor || !originalDescriptor.get) {
const currentValue = process.stdout.columns || 80;
Object.defineProperty(process.stdout, 'columns', {
get() {
return currentValue;
},
configurable: true
});
}
// Now spy on the getter
columnsSpy = vi.spyOn(process.stdout, 'columns', 'get');
});
afterEach(() => {
// Restore the spy
columnsSpy.mockRestore();
// Restore original descriptor or delete the property
if (originalDescriptor) {
Object.defineProperty(process.stdout, 'columns', originalDescriptor);
} else {
delete (process.stdout as any).columns;
}
});
it('should calculate width as percentage of terminal width', () => {
columnsSpy.mockReturnValue(100);
const width = getBoxWidth(0.9, 40);
expect(width).toBe(90);
});
it('should use default percentage of 0.9 when not specified', () => {
columnsSpy.mockReturnValue(100);
const width = getBoxWidth();
expect(width).toBe(90);
});
it('should use default minimum width of 40 when not specified', () => {
columnsSpy.mockReturnValue(30);
const width = getBoxWidth();
expect(width).toBe(40); // Should enforce minimum
});
it('should enforce minimum width when terminal is too narrow', () => {
columnsSpy.mockReturnValue(50);
const width = getBoxWidth(0.9, 60);
expect(width).toBe(60); // Should use minWidth instead of 45
});
it('should handle undefined process.stdout.columns', () => {
columnsSpy.mockReturnValue(undefined);
const width = getBoxWidth(0.9, 40);
// Should fall back to 80 columns: Math.floor(80 * 0.9) = 72
expect(width).toBe(72);
});
it('should handle custom percentage values', () => {
columnsSpy.mockReturnValue(100);
expect(getBoxWidth(0.95, 40)).toBe(95);
expect(getBoxWidth(0.8, 40)).toBe(80);
expect(getBoxWidth(0.5, 40)).toBe(50);
});
it('should handle custom minimum width values', () => {
columnsSpy.mockReturnValue(60);
expect(getBoxWidth(0.9, 70)).toBe(70); // 60 * 0.9 = 54, but min is 70
expect(getBoxWidth(0.9, 50)).toBe(54); // 60 * 0.9 = 54, min is 50
});
it('should floor the calculated width', () => {
columnsSpy.mockReturnValue(99);
const width = getBoxWidth(0.9, 40);
// 99 * 0.9 = 89.1, should floor to 89
expect(width).toBe(89);
});
it('should match warning box width calculation', () => {
// Test the specific case from displayWarning()
columnsSpy.mockReturnValue(80);
const width = getBoxWidth(0.9, 40);
expect(width).toBe(72);
});
it('should match table width calculation', () => {
// Test the specific case from createTaskTable()
columnsSpy.mockReturnValue(111);
const width = getBoxWidth(0.9, 100);
// 111 * 0.9 = 99.9, floor to 99, but max(99, 100) = 100
expect(width).toBe(100);
});
it('should match recommended task box width calculation', () => {
// Test the specific case from displayRecommendedNextTask()
columnsSpy.mockReturnValue(120);
const width = getBoxWidth(0.97, 40);
// 120 * 0.97 = 116.4, floor to 116
expect(width).toBe(116);
});
it('should handle edge case of zero terminal width', () => {
columnsSpy.mockReturnValue(0);
const width = getBoxWidth(0.9, 40);
// When columns is 0, it uses fallback of 80: Math.floor(80 * 0.9) = 72
expect(width).toBe(72);
});
it('should handle very large terminal widths', () => {
columnsSpy.mockReturnValue(1000);
const width = getBoxWidth(0.9, 40);
expect(width).toBe(900);
});
it('should handle very small percentages', () => {
columnsSpy.mockReturnValue(100);
const width = getBoxWidth(0.1, 5);
// 100 * 0.1 = 10, which is greater than min 5
expect(width).toBe(10);
});
it('should handle percentage of 1.0 (100%)', () => {
columnsSpy.mockReturnValue(80);
const width = getBoxWidth(1.0, 40);
expect(width).toBe(80);
});
it('should consistently return same value for same inputs', () => {
columnsSpy.mockReturnValue(100);
const width1 = getBoxWidth(0.9, 40);
const width2 = getBoxWidth(0.9, 40);
const width3 = getBoxWidth(0.9, 40);
expect(width1).toBe(width2);
expect(width2).toBe(width3);
});
});
});

View File

@@ -0,0 +1,51 @@
/**
* @fileoverview Layout helper utilities
* Provides utilities for calculating dimensions, truncating text, and creating visual elements
*/
import chalk from 'chalk';
/**
* Calculate box width as percentage of terminal width
* @param percentage - Percentage of terminal width to use (default: 0.9)
* @param minWidth - Minimum width to enforce (default: 40)
* @returns Calculated box width
*/
export function getBoxWidth(
percentage: number = 0.9,
minWidth: number = 40
): number {
const terminalWidth = process.stdout.columns || 80;
return Math.max(Math.floor(terminalWidth * percentage), minWidth);
}
/**
* Truncate text to specified length
*/
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength - 3) + '...';
}
/**
* Create a progress bar
*/
export function createProgressBar(
completed: number,
total: number,
width: number = 30
): string {
if (total === 0) {
return chalk.gray('No tasks');
}
const percentage = Math.round((completed / total) * 100);
const filled = Math.round((completed / total) * width);
const empty = width - filled;
const bar = chalk.green('█').repeat(filled) + chalk.gray('░').repeat(empty);
return `${bar} ${chalk.cyan(`${percentage}%`)} (${completed}/${total})`;
}

View File

@@ -0,0 +1,68 @@
/**
* @fileoverview Authentication helpers for CLI commands
*/
import type { AuthManager } from '@tm/core';
import { displayCardBox } from '../ui/components/cardBox.component.js';
/**
* Options for authentication check
*/
export interface CheckAuthOptions {
/** Custom message describing what requires authentication (defaults to generic message) */
message?: string;
/** Optional footer text (e.g., alternative local commands) */
footer?: string;
/** Command to run to authenticate (defaults to "tm auth login") */
authCommand?: string;
}
/**
* Check if user is authenticated and display a friendly card box if not.
* Used by commands that require Hamster authentication (briefs, context, etc.)
*
* @param authManager - AuthManager instance
* @param options - Optional customization for the authentication prompt
* @returns true if authenticated, false if not
*
* @example
* ```typescript
* const isAuthenticated = await checkAuthentication(authManager, {
* message: 'The "briefs" command requires you to be logged in to your Hamster account.',
* footer: 'Working locally instead?\n → Use "tm tags" for local tag management.'
* });
*
* if (!isAuthenticated) {
* process.exit(1);
* }
* ```
*/
export async function checkAuthentication(
authManager: AuthManager,
options: CheckAuthOptions = {}
): Promise<boolean> {
const hasSession = await authManager.hasValidSession();
if (!hasSession) {
const {
message = 'This command requires you to be logged in to your Hamster account.',
footer,
authCommand = 'tm auth login'
} = options;
console.log(
displayCardBox({
header: '[!] Not logged in to Hamster',
body: [message],
callToAction: {
label: 'To get started:',
action: authCommand
},
footer
})
);
return false;
}
return true;
}

View File

@@ -4,9 +4,9 @@
import { spawn } from 'child_process';
import https from 'https';
import boxen from 'boxen';
import chalk from 'chalk';
import ora from 'ora';
import boxen from 'boxen';
import process from 'process';
export interface UpdateInfo {

View File

@@ -0,0 +1,284 @@
/**
* @fileoverview Shared brief selection utilities
* Reusable functions for selecting briefs interactively or via URL/ID
*/
import chalk from 'chalk';
import search from '@inquirer/search';
import ora, { Ora } from 'ora';
import { AuthManager } from '@tm/core';
import * as ui from './ui.js';
import { getBriefStatusWithColor } from '../ui/formatters/status-formatters.js';
export interface BriefSelectionResult {
success: boolean;
briefId?: string;
briefName?: string;
orgId?: string;
orgName?: string;
message?: string;
}
/**
* Select a brief interactively using search
*/
export async function selectBriefInteractive(
authManager: AuthManager,
orgId: string
): Promise<BriefSelectionResult> {
const spinner = ora('Fetching briefs...').start();
try {
// Fetch briefs from API
const briefs = await authManager.getBriefs(orgId);
spinner.stop();
if (briefs.length === 0) {
ui.displayWarning('No briefs available in this organization');
return {
success: false,
message: 'No briefs available'
};
}
// Prompt for selection with search
const selectedBrief = await search<(typeof briefs)[0] | null>({
message: 'Search for a brief:',
source: async (input) => {
const searchTerm = input?.toLowerCase() || '';
// Static option for no brief
const noBriefOption = {
name: '(No brief - organization level)',
value: null as any,
description: 'Clear brief selection'
};
// Filter briefs based on search term
const filteredBriefs = briefs.filter((brief) => {
if (!searchTerm) return true;
const title = brief.document?.title || '';
const shortId = brief.id.slice(0, 8);
const lastChars = brief.id.slice(-8);
// Search by title, full UUID, first 8 chars, or last 8 chars
return (
title.toLowerCase().includes(searchTerm) ||
brief.id.toLowerCase().includes(searchTerm) ||
shortId.toLowerCase().includes(searchTerm) ||
lastChars.toLowerCase().includes(searchTerm)
);
});
// Group briefs by status
const briefsByStatus = filteredBriefs.reduce(
(acc, brief) => {
const status = brief.status || 'unknown';
if (!acc[status]) {
acc[status] = [];
}
acc[status].push(brief);
return acc;
},
{} as Record<string, typeof briefs>
);
// Define status order (most active first)
const statusOrder = [
'delivering',
'aligned',
'refining',
'draft',
'delivered',
'done',
'archived'
];
// Build grouped options
const groupedOptions: any[] = [];
for (const status of statusOrder) {
const statusBriefs = briefsByStatus[status];
if (!statusBriefs || statusBriefs.length === 0) continue;
// Add status header as separator
const statusHeader = getBriefStatusWithColor(status);
groupedOptions.push({
type: 'separator',
separator: `\n${statusHeader}`
});
// Add briefs under this status
statusBriefs.forEach((brief) => {
const title =
brief.document?.title || `Brief ${brief.id.slice(-8)}`;
const shortId = brief.id.slice(-8);
const description = brief.document?.description || '';
const taskCountDisplay =
brief.taskCount !== undefined && brief.taskCount > 0
? chalk.gray(
` (${brief.taskCount} ${brief.taskCount === 1 ? 'task' : 'tasks'})`
)
: '';
groupedOptions.push({
name: ` ${title}${taskCountDisplay} ${chalk.gray(`(${shortId})`)}`,
value: brief,
description: description
? chalk.gray(` ${description.slice(0, 80)}`)
: undefined
});
});
}
// Handle any briefs with statuses not in our order
const unorderedStatuses = Object.keys(briefsByStatus).filter(
(s) => !statusOrder.includes(s)
);
for (const status of unorderedStatuses) {
const statusBriefs = briefsByStatus[status];
if (!statusBriefs || statusBriefs.length === 0) continue;
const statusHeader = getBriefStatusWithColor(status);
groupedOptions.push({
type: 'separator',
separator: `\n${statusHeader}`
});
statusBriefs.forEach((brief) => {
const title =
brief.document?.title || `Brief ${brief.id.slice(-8)}`;
const shortId = brief.id.slice(-8);
const description = brief.document?.description || '';
const taskCountDisplay =
brief.taskCount !== undefined && brief.taskCount > 0
? chalk.gray(
` (${brief.taskCount} ${brief.taskCount === 1 ? 'task' : 'tasks'})`
)
: '';
groupedOptions.push({
name: ` ${title}${taskCountDisplay} ${chalk.gray(`(${shortId})`)}`,
value: brief,
description: description
? chalk.gray(` ${description.slice(0, 80)}`)
: undefined
});
});
}
return [noBriefOption, ...groupedOptions];
}
});
if (selectedBrief) {
// Update context with brief
const briefName =
selectedBrief.document?.title ||
`Brief ${selectedBrief.id.slice(0, 8)}`;
await authManager.updateContext({
briefId: selectedBrief.id,
briefName: briefName,
briefStatus: selectedBrief.status,
briefUpdatedAt: selectedBrief.updatedAt
});
ui.displaySuccess(`Selected brief: ${briefName}`);
return {
success: true,
briefId: selectedBrief.id,
briefName,
message: `Selected brief: ${briefName}`
};
} else {
// Clear brief selection
await authManager.updateContext({
briefId: undefined,
briefName: undefined,
briefStatus: undefined,
briefUpdatedAt: undefined
});
ui.displaySuccess('Cleared brief selection (organization level)');
return {
success: true,
message: 'Cleared brief selection'
};
}
} catch (error) {
spinner.fail('Failed to fetch briefs');
throw error;
}
}
/**
* Select a brief from any input format (URL, ID, name) using tm-core
* Presentation layer - handles display and context updates only
*
* All business logic (URL parsing, ID matching, name resolution) is in tm-core
*/
export async function selectBriefFromInput(
authManager: AuthManager,
input: string,
tmCore: any
): Promise<BriefSelectionResult> {
let spinner: Ora | undefined;
try {
spinner = ora('Resolving brief...');
spinner.start();
// Let tm-core handle ALL business logic:
// - URL parsing
// - ID extraction
// - UUID matching (full or last 8 chars)
// - Name matching
const brief = await tmCore.tasks.resolveBrief(input);
// Fetch org to get a friendly name and slug (optional)
let orgName: string | undefined;
let orgSlug: string | undefined;
try {
const org = await authManager.getOrganization(brief.accountId);
orgName = org?.name;
orgSlug = org?.slug;
} catch {
// Non-fatal if org lookup fails
}
// Update context: set org and brief
const briefName = brief.document?.title || `Brief ${brief.id.slice(0, 8)}`;
await authManager.updateContext({
orgId: brief.accountId,
orgName,
orgSlug,
briefId: brief.id,
briefName,
briefStatus: brief.status,
briefUpdatedAt: brief.updatedAt
});
spinner.succeed('Context set from brief');
console.log(
chalk.gray(
` Organization: ${orgName || brief.accountId}\n Brief: ${briefName}`
)
);
return {
success: true,
briefId: brief.id,
briefName,
orgId: brief.accountId,
orgName,
message: 'Context set from brief'
};
} catch (error) {
try {
if (spinner?.isSpinning) spinner.stop();
} catch {}
throw error;
}
}

View File

@@ -0,0 +1,28 @@
/**
* @fileoverview CLI utilities main export
* General-purpose utilities (non-UI)
*
* Note: UI-related utilities have been moved to src/ui/
* For backward compatibility, use src/utils/ui.ts which re-exports from src/ui/
*/
// Authentication helpers
export {
checkAuthentication,
type CheckAuthOptions
} from './auth-helpers.js';
// Error handling utilities
export { displayError, isDebugMode } from './error-handler.js';
// Auto-update utilities
export {
checkForUpdate,
performAutoUpdate,
displayUpgradeNotification,
compareVersions,
restartWithNewVersion
} from './auto-update.js';
// Display helpers (command-specific helpers)
export { displayCommandHeader } from './display-helpers.js';

View File

@@ -1,158 +1,39 @@
/**
* CLI UI utilities tests
* CLI UI utilities tests (Backward compatibility tests)
* Tests for apps/cli/src/utils/ui.ts
*
* This file ensures backward compatibility with the old ui.ts module.
* The actual implementation has been moved to src/ui/ organized modules.
* See:
* - ui/layout/helpers.spec.ts
* - ui/formatters/status-formatters.spec.ts
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { MockInstance } from 'vitest';
import { getBoxWidth } from './ui.js';
import { describe, expect, it } from 'vitest';
import { getBoxWidth, getBriefStatusWithColor } from './ui.js';
describe('CLI UI Utilities', () => {
describe('getBoxWidth', () => {
let columnsSpy: MockInstance;
let originalDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
// Store original descriptor if it exists
originalDescriptor = Object.getOwnPropertyDescriptor(
process.stdout,
'columns'
);
// If columns doesn't exist or isn't a getter, define it as one
if (!originalDescriptor || !originalDescriptor.get) {
const currentValue = process.stdout.columns || 80;
Object.defineProperty(process.stdout, 'columns', {
get() {
return currentValue;
},
configurable: true
});
}
// Now spy on the getter
columnsSpy = vi.spyOn(process.stdout, 'columns', 'get');
describe('CLI UI Utilities (Backward Compatibility)', () => {
describe('Re-exports work correctly from ui/', () => {
it('should re-export getBoxWidth', () => {
expect(typeof getBoxWidth).toBe('function');
});
afterEach(() => {
// Restore the spy
columnsSpy.mockRestore();
// Restore original descriptor or delete the property
if (originalDescriptor) {
Object.defineProperty(process.stdout, 'columns', originalDescriptor);
} else {
delete (process.stdout as any).columns;
}
it('should re-export getBriefStatusWithColor', () => {
expect(typeof getBriefStatusWithColor).toBe('function');
});
it('should calculate width as percentage of terminal width', () => {
columnsSpy.mockReturnValue(100);
it('should maintain functional behavior for getBoxWidth', () => {
// Simple smoke test - detailed tests are in ui/layout/helpers.spec.ts
const width = getBoxWidth(0.9, 40);
expect(width).toBe(90);
expect(typeof width).toBe('number');
expect(width).toBeGreaterThanOrEqual(40);
});
it('should use default percentage of 0.9 when not specified', () => {
columnsSpy.mockReturnValue(100);
const width = getBoxWidth();
expect(width).toBe(90);
});
it('should use default minimum width of 40 when not specified', () => {
columnsSpy.mockReturnValue(30);
const width = getBoxWidth();
expect(width).toBe(40); // Should enforce minimum
});
it('should enforce minimum width when terminal is too narrow', () => {
columnsSpy.mockReturnValue(50);
const width = getBoxWidth(0.9, 60);
expect(width).toBe(60); // Should use minWidth instead of 45
});
it('should handle undefined process.stdout.columns', () => {
columnsSpy.mockReturnValue(undefined);
const width = getBoxWidth(0.9, 40);
// Should fall back to 80 columns: Math.floor(80 * 0.9) = 72
expect(width).toBe(72);
});
it('should handle custom percentage values', () => {
columnsSpy.mockReturnValue(100);
expect(getBoxWidth(0.95, 40)).toBe(95);
expect(getBoxWidth(0.8, 40)).toBe(80);
expect(getBoxWidth(0.5, 40)).toBe(50);
});
it('should handle custom minimum width values', () => {
columnsSpy.mockReturnValue(60);
expect(getBoxWidth(0.9, 70)).toBe(70); // 60 * 0.9 = 54, but min is 70
expect(getBoxWidth(0.9, 50)).toBe(54); // 60 * 0.9 = 54, min is 50
});
it('should floor the calculated width', () => {
columnsSpy.mockReturnValue(99);
const width = getBoxWidth(0.9, 40);
// 99 * 0.9 = 89.1, should floor to 89
expect(width).toBe(89);
});
it('should match warning box width calculation', () => {
// Test the specific case from displayWarning()
columnsSpy.mockReturnValue(80);
const width = getBoxWidth(0.9, 40);
expect(width).toBe(72);
});
it('should match table width calculation', () => {
// Test the specific case from createTaskTable()
columnsSpy.mockReturnValue(111);
const width = getBoxWidth(0.9, 100);
// 111 * 0.9 = 99.9, floor to 99, but max(99, 100) = 100
expect(width).toBe(100);
});
it('should match recommended task box width calculation', () => {
// Test the specific case from displayRecommendedNextTask()
columnsSpy.mockReturnValue(120);
const width = getBoxWidth(0.97, 40);
// 120 * 0.97 = 116.4, floor to 116
expect(width).toBe(116);
});
it('should handle edge case of zero terminal width', () => {
columnsSpy.mockReturnValue(0);
const width = getBoxWidth(0.9, 40);
// When columns is 0, it uses fallback of 80: Math.floor(80 * 0.9) = 72
expect(width).toBe(72);
});
it('should handle very large terminal widths', () => {
columnsSpy.mockReturnValue(1000);
const width = getBoxWidth(0.9, 40);
expect(width).toBe(900);
});
it('should handle very small percentages', () => {
columnsSpy.mockReturnValue(100);
const width = getBoxWidth(0.1, 5);
// 100 * 0.1 = 10, which is greater than min 5
expect(width).toBe(10);
});
it('should handle percentage of 1.0 (100%)', () => {
columnsSpy.mockReturnValue(80);
const width = getBoxWidth(1.0, 40);
expect(width).toBe(80);
});
it('should consistently return same value for same inputs', () => {
columnsSpy.mockReturnValue(100);
const width1 = getBoxWidth(0.9, 40);
const width2 = getBoxWidth(0.9, 40);
const width3 = getBoxWidth(0.9, 40);
expect(width1).toBe(width2);
expect(width2).toBe(width3);
it('should maintain functional behavior for getBriefStatusWithColor', () => {
// Simple smoke test - detailed tests are in ui/formatters/status-formatters.spec.ts
const result = getBriefStatusWithColor('done', true);
expect(result).toContain('Done');
expect(result).toContain('✓');
});
});
});

View File

@@ -1,419 +1,15 @@
/**
* @fileoverview UI utilities for Task Master CLI
* Provides formatting, display, and visual components for the command line interface
* @fileoverview UI utilities for Task Master CLI (Re-export module)
*
* @deprecated: This file is kept for backward compatibility.
* All functionality has been moved to organized modules under src/ui/:
* - ui/formatters/ (status, priority, complexity, dependencies)
* - ui/display/ (messages, tables)
* - ui/layout/ (helpers)
* - ui/components/ (high-level UI components)
*
* Please import from '../ui/index.js' or specific modules for new code.
*/
import chalk from 'chalk';
import boxen from 'boxen';
import Table from 'cli-table3';
import type { Task, TaskStatus, TaskPriority, Subtask } from '@tm/core';
/**
* Get colored status display with ASCII icons (matches scripts/modules/ui.js style)
*/
export function getStatusWithColor(
status: TaskStatus,
forTable: boolean = false
): string {
const statusConfig = {
done: {
color: chalk.green,
icon: '✓',
tableIcon: '✓'
},
pending: {
color: chalk.yellow,
icon: '○',
tableIcon: '○'
},
'in-progress': {
color: chalk.hex('#FFA500'),
icon: '▶',
tableIcon: '▶'
},
deferred: {
color: chalk.gray,
icon: 'x',
tableIcon: 'x'
},
review: {
color: chalk.magenta,
icon: '?',
tableIcon: '?'
},
cancelled: {
color: chalk.gray,
icon: 'x',
tableIcon: 'x'
},
blocked: {
color: chalk.red,
icon: '!',
tableIcon: '!'
},
completed: {
color: chalk.green,
icon: '✓',
tableIcon: '✓'
}
};
const config = statusConfig[status] || {
color: chalk.red,
icon: 'X',
tableIcon: 'X'
};
const icon = forTable ? config.tableIcon : config.icon;
return config.color(`${icon} ${status}`);
}
/**
* Get colored priority display
*/
export function getPriorityWithColor(priority: TaskPriority): string {
const priorityColors: Record<TaskPriority, (text: string) => string> = {
critical: chalk.red.bold,
high: chalk.red,
medium: chalk.yellow,
low: chalk.gray
};
const colorFn = priorityColors[priority] || chalk.white;
return colorFn(priority);
}
/**
* Get complexity color and label based on score thresholds
*/
function getComplexityLevel(score: number): {
color: (text: string) => string;
label: string;
} {
if (score >= 7) {
return { color: chalk.hex('#CC0000'), label: 'High' };
} else if (score >= 4) {
return { color: chalk.hex('#FF8800'), label: 'Medium' };
} else {
return { color: chalk.green, label: 'Low' };
}
}
/**
* Get colored complexity display with dot indicator (simple format)
*/
export function getComplexityWithColor(complexity: number | string): string {
const score =
typeof complexity === 'string' ? parseInt(complexity, 10) : complexity;
if (isNaN(score)) {
return chalk.gray('N/A');
}
const { color } = getComplexityLevel(score);
return color(`${score}`);
}
/**
* Get colored complexity display with /10 format (for dashboards)
*/
export function getComplexityWithScore(complexity: number | undefined): string {
if (typeof complexity !== 'number') {
return chalk.gray('N/A');
}
const { color, label } = getComplexityLevel(complexity);
return color(`${complexity}/10 (${label})`);
}
/**
* Calculate box width as percentage of terminal width
* @param percentage - Percentage of terminal width to use (default: 0.9)
* @param minWidth - Minimum width to enforce (default: 40)
* @returns Calculated box width
*/
export function getBoxWidth(
percentage: number = 0.9,
minWidth: number = 40
): number {
const terminalWidth = process.stdout.columns || 80;
return Math.max(Math.floor(terminalWidth * percentage), minWidth);
}
/**
* Truncate text to specified length
*/
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength - 3) + '...';
}
/**
* Create a progress bar
*/
export function createProgressBar(
completed: number,
total: number,
width: number = 30
): string {
if (total === 0) {
return chalk.gray('No tasks');
}
const percentage = Math.round((completed / total) * 100);
const filled = Math.round((completed / total) * width);
const empty = width - filled;
const bar = chalk.green('█').repeat(filled) + chalk.gray('░').repeat(empty);
return `${bar} ${chalk.cyan(`${percentage}%`)} (${completed}/${total})`;
}
/**
* Display a fancy banner
*/
export function displayBanner(title: string = 'Task Master'): void {
console.log(
boxen(chalk.white.bold(title), {
padding: 1,
margin: { top: 1, bottom: 1 },
borderStyle: 'round',
borderColor: 'blue',
textAlignment: 'center'
})
);
}
/**
* Display an error message (matches scripts/modules/ui.js style)
*/
export function displayError(message: string, details?: string): void {
const boxWidth = getBoxWidth();
console.error(
boxen(
chalk.red.bold('X Error: ') +
chalk.white(message) +
(details ? '\n\n' + chalk.gray(details) : ''),
{
padding: 1,
borderStyle: 'round',
borderColor: 'red',
width: boxWidth
}
)
);
}
/**
* Display a success message
*/
export function displaySuccess(message: string): void {
const boxWidth = getBoxWidth();
console.log(
boxen(
chalk.green.bold(String.fromCharCode(8730) + ' ') + chalk.white(message),
{
padding: 1,
borderStyle: 'round',
borderColor: 'green',
width: boxWidth
}
)
);
}
/**
* Display a warning message
*/
export function displayWarning(message: string): void {
const boxWidth = getBoxWidth();
console.log(
boxen(chalk.yellow.bold('⚠️ ') + chalk.white(message), {
padding: 1,
borderStyle: 'round',
borderColor: 'yellow',
width: boxWidth
})
);
}
/**
* Display info message
*/
export function displayInfo(message: string): void {
const boxWidth = getBoxWidth();
console.log(
boxen(chalk.blue.bold('i ') + chalk.white(message), {
padding: 1,
borderStyle: 'round',
borderColor: 'blue',
width: boxWidth
})
);
}
/**
* Format dependencies with their status
*/
export function formatDependenciesWithStatus(
dependencies: string[] | number[],
tasks: Task[]
): string {
if (!dependencies || dependencies.length === 0) {
return chalk.gray('none');
}
const taskMap = new Map(tasks.map((t) => [t.id.toString(), t]));
return dependencies
.map((depId) => {
const task = taskMap.get(depId.toString());
if (!task) {
return chalk.red(`${depId} (not found)`);
}
const statusIcon =
task.status === 'done'
? '✓'
: task.status === 'in-progress'
? '►'
: '○';
return `${depId}${statusIcon}`;
})
.join(', ');
}
/**
* Create a task table for display
*/
export function createTaskTable(
tasks: (Task | Subtask)[],
options?: {
showSubtasks?: boolean;
showComplexity?: boolean;
showDependencies?: boolean;
}
): string {
const {
showSubtasks = false,
showComplexity = false,
showDependencies = true
} = options || {};
// Calculate dynamic column widths based on terminal width
const tableWidth = getBoxWidth(0.9, 100);
// Adjust column widths to better match the original layout
const baseColWidths = showComplexity
? [
Math.floor(tableWidth * 0.1),
Math.floor(tableWidth * 0.4),
Math.floor(tableWidth * 0.15),
Math.floor(tableWidth * 0.1),
Math.floor(tableWidth * 0.2),
Math.floor(tableWidth * 0.1)
] // ID, Title, Status, Priority, Dependencies, Complexity
: [
Math.floor(tableWidth * 0.08),
Math.floor(tableWidth * 0.4),
Math.floor(tableWidth * 0.18),
Math.floor(tableWidth * 0.12),
Math.floor(tableWidth * 0.2)
]; // ID, Title, Status, Priority, Dependencies
const headers = [
chalk.blue.bold('ID'),
chalk.blue.bold('Title'),
chalk.blue.bold('Status'),
chalk.blue.bold('Priority')
];
const colWidths = baseColWidths.slice(0, 4);
if (showDependencies) {
headers.push(chalk.blue.bold('Dependencies'));
colWidths.push(baseColWidths[4]);
}
if (showComplexity) {
headers.push(chalk.blue.bold('Complexity'));
colWidths.push(baseColWidths[5] || 12);
}
const table = new Table({
head: headers,
style: { head: [], border: [] },
colWidths,
wordWrap: true
});
tasks.forEach((task) => {
const row: string[] = [
chalk.cyan(task.id.toString()),
truncate(task.title, colWidths[1] - 3),
getStatusWithColor(task.status, true), // Use table version
getPriorityWithColor(task.priority)
];
if (showDependencies) {
// For table display, show simple format without status icons
if (!task.dependencies || task.dependencies.length === 0) {
row.push(chalk.gray('None'));
} else {
row.push(
chalk.cyan(task.dependencies.map((d) => String(d)).join(', '))
);
}
}
if (showComplexity) {
// Show complexity score from report if available
if (typeof task.complexity === 'number') {
row.push(getComplexityWithColor(task.complexity));
} else {
row.push(chalk.gray('N/A'));
}
}
table.push(row);
// Add subtasks if requested
if (showSubtasks && task.subtasks && task.subtasks.length > 0) {
task.subtasks.forEach((subtask) => {
const subRow: string[] = [
chalk.gray(` └─ ${subtask.id}`),
chalk.gray(truncate(subtask.title, colWidths[1] - 6)),
chalk.gray(getStatusWithColor(subtask.status, true)),
chalk.gray(subtask.priority || 'medium')
];
if (showDependencies) {
subRow.push(
chalk.gray(
subtask.dependencies && subtask.dependencies.length > 0
? subtask.dependencies.map((dep) => String(dep)).join(', ')
: 'None'
)
);
}
if (showComplexity) {
const complexityDisplay =
typeof subtask.complexity === 'number'
? getComplexityWithColor(subtask.complexity)
: '--';
subRow.push(chalk.gray(complexityDisplay));
}
table.push(subRow);
});
}
});
return table.toString();
}
// Re-export everything from the new organized UI structure
export * from '../ui/index.js';

1
package-lock.json generated
View File

@@ -28447,6 +28447,7 @@
"@tm/core": "*",
"boxen": "^8.0.1",
"chalk": "5.6.2",
"cli-table3": "0.6.5",
"ora": "^8.1.1"
},
"devDependencies": {

View File

@@ -19,7 +19,8 @@
"@tm/core": "*",
"chalk": "5.6.2",
"boxen": "^8.0.1",
"ora": "^8.1.1"
"ora": "^8.1.1",
"cli-table3": "^0.6.5"
},
"devDependencies": {
"@types/node": "^22.10.5",

View File

@@ -0,0 +1,99 @@
import { ui } from '@tm/cli';
import type { BaseBridgeParams } from './bridge-types.js';
import { checkStorageType } from './bridge-utils.js';
/**
* Parameters for the add-tag bridge function
*/
export interface AddTagBridgeParams extends BaseBridgeParams {
/** Tag name to create */
tagName: string;
}
/**
* Result returned when API storage redirects to web UI
*/
export interface RemoteAddTagResult {
success: boolean;
message: string;
redirectUrl: string;
}
/**
* Shared bridge function for add-tag command.
* Checks if using API storage and redirects to web UI if so.
*
* For API storage, tags are called "briefs" and must be created
* through the Hamster web interface.
*
* @param params - Bridge parameters
* @returns Result object if API storage handled it, null if should fall through to file storage
*/
export async function tryAddTagViaRemote(
params: AddTagBridgeParams
): Promise<RemoteAddTagResult | null> {
const {
tagName,
projectRoot,
isMCP = false,
outputFormat = 'text',
report
} = params;
// Check storage type using shared utility
const { isApiStorage, tmCore } = await checkStorageType(
projectRoot,
report,
'falling back to file-based tag creation'
);
if (!isApiStorage || !tmCore) {
// Not API storage - signal caller to fall through to file-based logic
return null;
}
// Get the brief creation URL from tmCore
const redirectUrl = tmCore.auth.getBriefCreationUrl();
if (!redirectUrl) {
report(
'error',
'Could not generate brief creation URL. Please ensure you have selected an organization using "tm context org"'
);
return {
success: false,
message:
'Failed to generate brief creation URL. Please ensure an organization is selected.',
redirectUrl: ''
};
}
// Show CLI output if not MCP
if (!isMCP && outputFormat === 'text') {
console.log(
ui.displayCardBox({
header: '# Create a Brief in Hamster Studio',
body: [
'Your tags are separate task lists. When connected to Hamster,\ntask lists are attached to briefs.',
'Create a new brief and its task list will automatically be\navailable when generated.'
],
callToAction: {
label: 'Visit:',
action: redirectUrl
},
footer:
'To access tasks for a specific brief, use:\n' +
' • tm briefs select <brief-name>\n' +
' • tm briefs select <brief-id>\n' +
' • tm briefs select (interactive)'
})
);
}
// Return success result with redirect URL
return {
success: true,
message: `API storage detected. Please create tag "${tagName}" at: ${redirectUrl}`,
redirectUrl
};
}

View File

@@ -0,0 +1,46 @@
/**
* Shared types and interfaces for bridge functions
*/
/**
* Log levels used by bridge report functions
*/
export type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success';
/**
* Report function signature used by all bridges
*/
export type ReportFunction = (level: LogLevel, ...args: unknown[]) => void;
/**
* Output format for bridge results
*/
export type OutputFormat = 'text' | 'json';
/**
* Common parameters shared by all bridge functions
*/
export interface BaseBridgeParams {
/** Project root directory */
projectRoot: string;
/** Whether called from MCP context (default: false) */
isMCP?: boolean;
/** Output format (default: 'text') */
outputFormat?: OutputFormat;
/** Logging function */
report: ReportFunction;
/** Optional tag for task organization */
tag?: string;
}
/**
* Result from checking if API storage should handle an operation
*/
export interface StorageCheckResult {
/** Whether API storage is being used */
isApiStorage: boolean;
/** TmCore instance if initialization succeeded */
tmCore?: import('@tm/core').TmCore;
/** Error message if initialization failed */
error?: string;
}

View File

@@ -0,0 +1,69 @@
/**
* Shared utility functions for bridge operations
*/
import { type TmCore, createTmCore } from '@tm/core';
import type { ReportFunction, StorageCheckResult } from './bridge-types.js';
/**
* Initialize TmCore and check if API storage is being used.
*
* This function encapsulates the common pattern used by all bridge functions:
* 1. Try to create TmCore instance
* 2. Check the storage type
* 3. Return results or handle errors gracefully
*
* @param projectRoot - Project root directory
* @param report - Logging function
* @param fallbackMessage - Message to log if TmCore initialization fails
* @returns Storage check result with TmCore instance if successful
*
* @example
* const { isApiStorage, tmCore } = await checkStorageType(
* projectRoot,
* report,
* 'falling back to file-based operation'
* );
*
* if (!isApiStorage) {
* // Continue with file-based logic
* return null;
* }
*/
export async function checkStorageType(
projectRoot: string,
report: ReportFunction,
fallbackMessage = 'falling back to file-based operation'
): Promise<StorageCheckResult> {
let tmCore: TmCore;
try {
tmCore = await createTmCore({
projectPath: projectRoot || process.cwd()
});
} catch (tmCoreError) {
const errorMessage =
tmCoreError instanceof Error ? tmCoreError.message : String(tmCoreError);
report('warn', `TmCore check failed, ${fallbackMessage}: ${errorMessage}`);
return {
isApiStorage: false,
error: errorMessage
};
}
// Check if we're using API storage (use resolved storage type, not config)
const storageType = tmCore.tasks.getStorageType();
if (storageType !== 'api') {
return {
isApiStorage: false,
tmCore
};
}
return {
isApiStorage: true,
tmCore
};
}

View File

@@ -1,12 +1,13 @@
import chalk from 'chalk';
import boxen from 'boxen';
import chalk from 'chalk';
import ora from 'ora';
import { createTmCore, type TmCore } from '@tm/core';
import type { BaseBridgeParams } from './bridge-types.js';
import { checkStorageType } from './bridge-utils.js';
/**
* Parameters for the expand bridge function
*/
export interface ExpandBridgeParams {
export interface ExpandBridgeParams extends BaseBridgeParams {
/** Task ID (can be numeric "1" or alphanumeric "TAS-49") */
taskId: string | number;
/** Number of subtasks to generate (optional) */
@@ -17,16 +18,6 @@ export interface ExpandBridgeParams {
additionalContext?: string;
/** Force regeneration even if subtasks exist */
force?: boolean;
/** Project root directory */
projectRoot: string;
/** Optional tag for task organization */
tag?: string;
/** Whether called from MCP context (default: false) */
isMCP?: boolean;
/** Output format (default: 'text') */
outputFormat?: 'text' | 'json';
/** Logging function */
report: (level: string, ...args: unknown[]) => void;
}
/**
@@ -63,32 +54,15 @@ export async function tryExpandViaRemote(
report
} = params;
let tmCore: TmCore;
try {
tmCore = await createTmCore({
projectPath: projectRoot || process.cwd()
});
} catch (tmCoreError) {
const errorMessage =
tmCoreError instanceof Error ? tmCoreError.message : String(tmCoreError);
report(
'warn',
`TmCore check failed, falling back to file-based expansion: ${errorMessage}`
// Check storage type using shared utility
const { isApiStorage, tmCore } = await checkStorageType(
projectRoot,
report,
'falling back to file-based expansion'
);
// Return null to signal fall-through to file storage logic
return null;
}
// Check if we're using API storage (use resolved storage type, not config)
const storageType = tmCore.tasks.getStorageType();
if (storageType !== 'api') {
if (!isApiStorage || !tmCore) {
// Not API storage - signal caller to fall through to file-based logic
report(
'debug',
`Using file storage - processing expansion locally for task ${taskId}`
);
return null;
}

View File

@@ -9,6 +9,18 @@
* DELETE THIS PACKAGE when legacy scripts are removed.
*/
// Shared types and utilities
export type {
LogLevel,
ReportFunction,
OutputFormat,
BaseBridgeParams,
StorageCheckResult
} from './bridge-types.js';
export { checkStorageType } from './bridge-utils.js';
// Bridge functions
export {
tryUpdateViaRemote,
type UpdateBridgeParams,
@@ -20,3 +32,22 @@ export {
type ExpandBridgeParams,
type RemoteExpandResult
} from './expand-bridge.js';
export {
tryListTagsViaRemote,
type TagsBridgeParams,
type RemoteTagsResult,
type TagInfo
} from './tags-bridge.js';
export {
tryUseTagViaRemote,
type UseTagBridgeParams,
type RemoteUseTagResult
} from './use-tag-bridge.js';
export {
tryAddTagViaRemote,
type AddTagBridgeParams,
type RemoteAddTagResult
} from './add-tag-bridge.js';

View File

@@ -0,0 +1,160 @@
import { ui } from '@tm/cli';
import boxen from 'boxen';
import chalk from 'chalk';
import Table from 'cli-table3';
import type { TagInfo } from '@tm/core';
import type { BaseBridgeParams } from './bridge-types.js';
import { checkStorageType } from './bridge-utils.js';
// Re-export for convenience
export type { TagInfo };
/**
* Parameters for the tags bridge function
*/
export interface TagsBridgeParams extends BaseBridgeParams {
/** Whether to show metadata (default: false) */
showMetadata?: boolean;
}
/**
* Result returned when API storage handles the tags listing
*/
export interface RemoteTagsResult {
success: boolean;
tags: TagInfo[];
currentTag: string | null;
totalTags: number;
message: string;
}
/**
* Shared bridge function for list-tags command.
* Checks if using API storage and delegates to remote service if so.
*
* For API storage, tags are called "briefs" and task counts are fetched
* from the remote database.
*
* @param params - Bridge parameters
* @returns Result object if API storage handled it, null if should fall through to file storage
*/
export async function tryListTagsViaRemote(
params: TagsBridgeParams
): Promise<RemoteTagsResult | null> {
const { projectRoot, isMCP = false, outputFormat = 'text', report } = params;
// Check storage type using shared utility
const { isApiStorage, tmCore } = await checkStorageType(
projectRoot,
report,
'falling back to file-based tags'
);
if (!isApiStorage || !tmCore) {
// Not API storage - signal caller to fall through to file-based logic
return null;
}
try {
// Get tags with statistics from tm-core
// Tags are already sorted by status and updatedAt from brief-service
const tagsResult = await tmCore.tasks.getTagsWithStats();
// Sort tags: current tag first, then preserve status/updatedAt ordering from service
tagsResult.tags.sort((a, b) => {
// Always keep current tag at the top
if (a.isCurrent) return -1;
if (b.isCurrent) return 1;
// For non-current tags, preserve the status/updatedAt ordering already applied
return 0;
});
if (outputFormat === 'text' && !isMCP) {
// Display results in a table format
if (tagsResult.tags.length === 0) {
console.log(
boxen(chalk.yellow('No tags found'), {
padding: 1,
borderColor: 'yellow',
borderStyle: 'round',
margin: { top: 1, bottom: 1 }
})
);
} else {
// Create table headers (with temporary Updated column)
const headers = [
chalk.cyan.bold('Tag Name'),
chalk.cyan.bold('Status'),
chalk.cyan.bold('Updated'),
chalk.cyan.bold('Tasks'),
chalk.cyan.bold('Completed')
];
// Calculate dynamic column widths based on terminal width
const terminalWidth = Math.max(
(process.stdout.columns as number) || 120,
80
);
const usableWidth = Math.floor(terminalWidth * 0.95);
// Column order: Tag Name, Status, Updated, Tasks, Completed
const widths = [0.35, 0.25, 0.2, 0.1, 0.1];
const colWidths = widths.map((w, i) =>
Math.max(Math.floor(usableWidth * w), i === 0 ? 20 : 8)
);
const table = new Table({
head: headers,
colWidths: colWidths,
wordWrap: true
});
// Add rows
tagsResult.tags.forEach((tag) => {
const row = [];
// Tag name with current indicator and short ID (last 8 chars)
const shortId = tag.briefId ? tag.briefId.slice(-8) : 'unknown';
const tagDisplay = tag.isCurrent
? `${chalk.green('●')} ${chalk.green.bold(tag.name)} ${chalk.gray(`(current - ${shortId})`)}`
: ` ${tag.name} ${chalk.gray(`(${shortId})`)}`;
row.push(tagDisplay);
row.push(ui.getBriefStatusWithColor(tag.status, true));
// Updated date (temporary for validation)
const updatedDate = tag.updatedAt
? new Date(tag.updatedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: chalk.gray('N/A');
row.push(chalk.gray(updatedDate));
// Task counts
row.push(chalk.white(tag.taskCount.toString()));
row.push(chalk.green(tag.completedTasks.toString()));
table.push(row);
});
console.log(table.toString());
}
}
// Return success result - signals that we handled it
return {
success: true,
tags: tagsResult.tags,
currentTag: tagsResult.currentTag,
totalTags: tagsResult.totalTags,
message: `Found ${tagsResult.totalTags} tag(s)`
};
} catch (error) {
// tm-core already formatted the error properly, just re-throw
throw error;
}
}

View File

@@ -1,28 +1,19 @@
import chalk from 'chalk';
import boxen from 'boxen';
import chalk from 'chalk';
import ora from 'ora';
import { createTmCore, type TmCore } from '@tm/core';
import type { BaseBridgeParams } from './bridge-types.js';
import { checkStorageType } from './bridge-utils.js';
/**
* Parameters for the update bridge function
*/
export interface UpdateBridgeParams {
export interface UpdateBridgeParams extends BaseBridgeParams {
/** Task ID (can be numeric "1", alphanumeric "TAS-49", or dotted "1.2" or "TAS-49.1") */
taskId: string | number;
/** Update prompt for AI */
prompt: string;
/** Project root directory */
projectRoot: string;
/** Optional tag for task organization */
tag?: string;
/** Whether to append or full update (default: false) */
appendMode?: boolean;
/** Whether called from MCP context (default: false) */
isMCP?: boolean;
/** Output format (default: 'text') */
outputFormat?: 'text' | 'json';
/** Logging function */
report: (level: string, ...args: unknown[]) => void;
}
/**
@@ -61,32 +52,15 @@ export async function tryUpdateViaRemote(
report
} = params;
let tmCore: TmCore;
try {
tmCore = await createTmCore({
projectPath: projectRoot || process.cwd()
});
} catch (tmCoreError) {
const errorMessage =
tmCoreError instanceof Error ? tmCoreError.message : String(tmCoreError);
report(
'warn',
`TmCore check failed, falling back to file-based update: ${errorMessage}`
// Check storage type using shared utility
const { isApiStorage, tmCore } = await checkStorageType(
projectRoot,
report,
'falling back to file-based update'
);
// Return null to signal fall-through to file storage logic
return null;
}
// Check if we're using API storage (use resolved storage type, not config)
const storageType = tmCore.tasks.getStorageType();
if (storageType !== 'api') {
if (!isApiStorage || !tmCore) {
// Not API storage - signal caller to fall through to file-based logic
report(
'info',
`Using file storage - processing update locally for task ${taskId}`
);
return null;
}

View File

@@ -0,0 +1,145 @@
import boxen from 'boxen';
import chalk from 'chalk';
import ora from 'ora';
import type { BaseBridgeParams } from './bridge-types.js';
import { checkStorageType } from './bridge-utils.js';
/**
* Parameters for the use-tag bridge function
*/
export interface UseTagBridgeParams extends BaseBridgeParams {
/** Tag name to switch to */
tagName: string;
}
/**
* Result returned when API storage handles the tag switch
*/
export interface RemoteUseTagResult {
success: boolean;
previousTag: string | null;
currentTag: string;
switched: boolean;
taskCount: number;
message: string;
}
/**
* Shared bridge function for use-tag command.
* Checks if using API storage and delegates to remote service if so.
*
* For API storage, tags are called "briefs" and switching tags means
* changing the current brief context.
*
* @param params - Bridge parameters
* @returns Result object if API storage handled it, null if should fall through to file storage
*/
export async function tryUseTagViaRemote(
params: UseTagBridgeParams
): Promise<RemoteUseTagResult | null> {
const {
tagName,
projectRoot,
isMCP = false,
outputFormat = 'text',
report
} = params;
// Check storage type using shared utility
const { isApiStorage, tmCore } = await checkStorageType(
projectRoot,
report,
'falling back to file-based tag switching'
);
if (!isApiStorage || !tmCore) {
// Not API storage - signal caller to fall through to file-based logic
return null;
}
// API STORAGE PATH: Switch brief in Hamster
report('info', `Switching to tag (brief) "${tagName}" in Hamster`);
// Show CLI output if not MCP
if (!isMCP && outputFormat === 'text') {
console.log(
boxen(chalk.blue.bold(`Switching Tag in Hamster`), {
padding: 1,
borderColor: 'blue',
borderStyle: 'round',
margin: { top: 1, bottom: 1 }
})
);
}
const spinner =
!isMCP && outputFormat === 'text'
? ora({ text: `Switching to tag "${tagName}"...`, color: 'cyan' }).start()
: null;
try {
// Get current context before switching
const previousContext = tmCore.auth.getContext();
const previousTag = previousContext?.briefName || null;
// Switch to the new tag/brief
// This will look up the brief by name and update the context
await tmCore.tasks.switchTag(tagName);
// Get updated context after switching
const newContext = tmCore.auth.getContext();
const currentTag = newContext?.briefName || tagName;
// Get task count for the new tag
const tasks = await tmCore.tasks.list();
const taskCount = tasks.tasks.length;
if (spinner) {
spinner.succeed(`Switched to tag "${currentTag}"`);
}
if (outputFormat === 'text' && !isMCP) {
// Display success message
const briefId = newContext?.briefId
? newContext.briefId.slice(-8)
: 'unknown';
console.log(
boxen(
chalk.green.bold('✓ Tag Switched Successfully') +
'\n\n' +
(previousTag
? chalk.white(`Previous Tag: ${chalk.cyan(previousTag)}\n`)
: '') +
chalk.white(`Current Tag: ${chalk.green.bold(currentTag)}`) +
'\n' +
chalk.gray(`Brief ID: ${briefId}`) +
'\n' +
chalk.white(`Available Tasks: ${chalk.yellow(taskCount)}`),
{
padding: 1,
borderColor: 'green',
borderStyle: 'round',
margin: { top: 1, bottom: 0 }
}
)
);
}
// Return success result - signals that we handled it
return {
success: true,
previousTag,
currentTag,
switched: true,
taskCount,
message: `Successfully switched to tag "${currentTag}"`
};
} catch (error) {
if (spinner) {
spinner.fail('Failed to switch tag');
}
// tm-core already formatted the error properly, just re-throw
throw error;
}
}

View File

@@ -4,11 +4,25 @@
*/
import type {
TaskStatus,
TaskComplexity,
TaskPriority,
TaskComplexity
TaskStatus
} from '../types/index.js';
// Import from root package.json (monorepo root) for version info
import packageJson from '../../../../../package.json' with { type: 'json' };
/**
* Task Master version from root package.json
* Centralized to avoid fragile relative paths throughout the codebase
*/
export const TASKMASTER_VERSION = packageJson.version || 'unknown';
/**
* Package name from root package.json
*/
export const PACKAGE_NAME = packageJson.name || 'task-master-ai';
/**
* Valid task status values
*/

View File

@@ -4,9 +4,9 @@
*/
import type {
StorageType,
TaskComplexity,
TaskPriority,
StorageType
TaskPriority
} from '../types/index.js';
/**

View File

@@ -3,8 +3,8 @@
* This file defines the contract for all storage implementations
*/
import type { Task, TaskMetadata, TaskStatus } from '../types/index.js';
import type { ExpandTaskResult } from '../../modules/integration/services/task-expansion.service.js';
import type { Task, TaskMetadata, TaskStatus } from '../types/index.js';
/**
* Options for loading tasks from storage
@@ -164,6 +164,19 @@ export interface IStorage {
*/
getAllTags(): Promise<string[]>;
/**
* Create a new tag
* @param tagName - Name of the tag to create
* @param options - Creation options
* @param options.copyFrom - Tag to copy tasks from (optional)
* @param options.description - Tag description (optional)
* @returns Promise that resolves when creation is complete
*/
createTag(
tagName: string,
options?: { copyFrom?: string; description?: string }
): Promise<void>;
/**
* Delete all tasks and metadata for a specific tag
* @param tag - Tag to delete
@@ -216,6 +229,57 @@ export interface IStorage {
* @returns The brief name if using API storage with a selected brief, null otherwise
*/
getCurrentBriefName(): string | null;
/**
* Get all tags with detailed statistics including task counts
* @returns Promise that resolves to tags with statistics
*/
getTagsWithStats(): Promise<TagsWithStatsResult>;
}
/**
* Tag information with detailed statistics
*/
export interface TagInfo {
/** Tag/Brief name */
name: string;
/** Whether this is the current/active tag */
isCurrent: boolean;
/** Total number of tasks in this tag */
taskCount: number;
/** Number of completed tasks */
completedTasks: number;
/** Breakdown of tasks by status */
statusBreakdown: Record<string, number>;
/** Subtask counts if available */
subtaskCounts?: {
totalSubtasks: number;
subtasksByStatus: Record<string, number>;
};
/** Tag creation date */
created?: string;
/** Tag last modified date */
updatedAt?: string;
/** Tag description */
description?: string;
/** Brief/Tag status (for API storage briefs) */
status?: string;
/** Brief ID/UUID (for API storage) */
briefId?: string;
}
/**
* Result returned by getTagsWithStats
*/
export interface TagsWithStatsResult {
/** List of tags with statistics */
tags: TagInfo[];
/** Current active tag name */
currentTag: string | null;
/** Total number of tags */
totalTags: number;
}
/**
@@ -303,6 +367,10 @@ export abstract class BaseStorage implements IStorage {
abstract loadMetadata(tag?: string): Promise<TaskMetadata | null>;
abstract saveMetadata(metadata: TaskMetadata, tag?: string): Promise<void>;
abstract getAllTags(): Promise<string[]>;
abstract createTag(
tagName: string,
options?: { copyFrom?: string; description?: string }
): Promise<void>;
abstract deleteTag(tag: string): Promise<void>;
abstract renameTag(oldTag: string, newTag: string): Promise<void>;
abstract copyTag(sourceTag: string, targetTag: string): Promise<void>;
@@ -311,6 +379,7 @@ export abstract class BaseStorage implements IStorage {
abstract getStats(): Promise<StorageStats>;
abstract getStorageType(): 'file' | 'api';
abstract getCurrentBriefName(): string | null;
abstract getTagsWithStats(): Promise<TagsWithStatsResult>;
/**
* Utility method to generate backup filename
* @param originalPath - Original file path

View File

@@ -2,8 +2,8 @@
* @fileoverview Tests for MCP logging integration
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Logger, LogLevel, type LogCallback } from './logger.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type LogCallback, LogLevel, Logger } from './logger.js';
describe('Logger - MCP Integration', () => {
// Store original environment

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { TaskMapper } from './TaskMapper.js';
import { describe, expect, it, vi } from 'vitest';
import type { Tables } from '../types/database.types.js';
import { TaskMapper } from './TaskMapper.js';
type TaskRow = Tables<'tasks'>;

View File

@@ -1,5 +1,5 @@
import { Task, Subtask } from '../types/index.js';
import { Database, Tables } from '../types/database.types.js';
import { Subtask, Task } from '../types/index.js';
type TaskRow = Tables<'tasks'>;

View File

@@ -105,6 +105,8 @@ export interface TaskMetadata {
projectName?: string;
description?: string;
tags?: string[];
created?: string;
updated?: string;
}
/**

View File

@@ -53,6 +53,9 @@ export {
normalizeProjectRoot
} from './project-root-finder.js';
// Export path construction utilities
export { getProjectPaths } from './path-helpers.js';
// Additional utility exports
/**

View File

@@ -0,0 +1,40 @@
/**
* @fileoverview Path construction utilities for Task Master
* Provides standardized paths for Task Master project structure
*/
import { join, resolve } from 'node:path';
import { TASKMASTER_TASKS_FILE } from '../constants/paths.js';
import { findProjectRoot } from './project-root-finder.js';
/**
* Get standard Task Master project paths
* Automatically detects project root using smart detection
*
* @param projectPath - Optional explicit project path (if not provided, auto-detects)
* @returns Object with projectRoot and tasksPath
*
* @example
* ```typescript
* // Auto-detect project root
* const { projectRoot, tasksPath } = getProjectPaths();
*
* // Or specify explicit path
* const { projectRoot, tasksPath } = getProjectPaths('./my-project');
* // projectRoot: '/absolute/path/to/my-project'
* // tasksPath: '/absolute/path/to/my-project/.taskmaster/tasks/tasks.json'
* ```
*/
export function getProjectPaths(projectPath?: string): {
projectRoot: string;
tasksPath: string;
} {
const projectRoot = projectPath
? resolve(process.cwd(), projectPath)
: findProjectRoot();
return {
projectRoot,
tasksPath: join(projectRoot, TASKMASTER_TASKS_FILE)
};
}

View File

@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
normalizeProjectPath,
denormalizeProjectPath,
isValidNormalizedPath
isValidNormalizedPath,
normalizeProjectPath
} from './path-normalizer.js';
describe('Path Normalizer (base64url encoding)', () => {

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
compareRunIds,
generateRunId,
isValidRunId,
parseRunId,
compareRunIds
parseRunId
} from './run-id-generator.js';
describe('Run ID Generator', () => {

View File

@@ -36,6 +36,12 @@ export type * from './common/types/index.js';
// Common interfaces
export type * from './common/interfaces/index.js';
// Storage interfaces - TagInfo and TagsWithStatsResult
export type {
TagInfo,
TagsWithStatsResult
} from './common/interfaces/storage.interface.js';
// Constants
export * from './common/constants/index.js';
@@ -75,6 +81,10 @@ export type {
} from './modules/auth/types.js';
export { AuthenticationError } from './modules/auth/types.js';
// Brief types
export type { Brief } from './modules/briefs/types.js';
export type { TagWithStats } from './modules/briefs/services/brief-service.js';
// Workflow types
export type {
StartWorkflowOptions,
@@ -112,6 +122,10 @@ export type {
// Auth - Advanced
export { AuthManager } from './modules/auth/managers/auth-manager.js';
// Briefs - Advanced
export { BriefsDomain } from './modules/briefs/briefs-domain.js';
export { BriefService } from './modules/briefs/services/brief-service.js';
// Workflow - Advanced
export { WorkflowOrchestrator } from './modules/workflow/orchestrators/workflow-orchestrator.js';
export { WorkflowStateManager } from './modules/workflow/managers/workflow-state-manager.js';

View File

@@ -8,12 +8,12 @@ import {
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import type {
AIModel,
AIOptions,
AIResponse,
IAIProvider,
ProviderUsageStats,
ProviderInfo,
AIModel
ProviderUsageStats
} from '../interfaces/ai-provider.interface.js';
// Constants for retry logic

View File

@@ -0,0 +1,111 @@
/**
* Auth Domain tests
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { AuthDomain } from './auth-domain.js';
describe('AuthDomain', () => {
let authDomain: AuthDomain;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
// Save original environment
originalEnv = { ...process.env };
authDomain = new AuthDomain();
});
afterEach(() => {
// Restore original environment
process.env = originalEnv;
vi.clearAllMocks();
});
describe('getBriefCreationUrl', () => {
it('should return null if no base domain is configured', () => {
// Clear environment variables
delete process.env.TM_BASE_DOMAIN;
delete process.env.TM_PUBLIC_BASE_DOMAIN;
// Create fresh instance with cleared env
const domain = new AuthDomain();
const url = domain.getBriefCreationUrl();
expect(url).toBeNull();
});
it('should return null if org slug is not available in context', () => {
// Set base domain but context will have no orgSlug
process.env.TM_BASE_DOMAIN = 'localhost:8080';
const domain = new AuthDomain();
// Mock getContext to return null (no context set)
vi.spyOn(domain, 'getContext').mockReturnValue(null);
const url = domain.getBriefCreationUrl();
expect(url).toBeNull();
});
it('should construct URL with http protocol for localhost', () => {
process.env.TM_BASE_DOMAIN = 'localhost:8080';
// Mock getContext to return a context with orgSlug
const domain = new AuthDomain();
vi.spyOn(domain, 'getContext').mockReturnValue({
orgSlug: 'test-org',
updatedAt: new Date().toISOString()
});
const url = domain.getBriefCreationUrl();
expect(url).toBe('http://localhost:8080/home/test-org/briefs/create');
});
it('should construct URL with https protocol for production domain', () => {
process.env.TM_BASE_DOMAIN = 'tryhamster.com';
const domain = new AuthDomain();
vi.spyOn(domain, 'getContext').mockReturnValue({
orgSlug: 'acme-corp',
updatedAt: new Date().toISOString()
});
const url = domain.getBriefCreationUrl();
expect(url).toBe('https://tryhamster.com/home/acme-corp/briefs/create');
});
it('should use existing protocol if base domain includes it', () => {
process.env.TM_BASE_DOMAIN = 'https://staging.hamster.dev';
const domain = new AuthDomain();
vi.spyOn(domain, 'getContext').mockReturnValue({
orgSlug: 'staging-org',
updatedAt: new Date().toISOString()
});
const url = domain.getBriefCreationUrl();
expect(url).toBe(
'https://staging.hamster.dev/home/staging-org/briefs/create'
);
});
it('should prefer TM_BASE_DOMAIN over TM_PUBLIC_BASE_DOMAIN', () => {
process.env.TM_BASE_DOMAIN = 'localhost:8080';
process.env.TM_PUBLIC_BASE_DOMAIN = 'tryhamster.com';
const domain = new AuthDomain();
vi.spyOn(domain, 'getContext').mockReturnValue({
orgSlug: 'my-org',
updatedAt: new Date().toISOString()
});
const url = domain.getBriefCreationUrl();
// Should use TM_BASE_DOMAIN (localhost), not TM_PUBLIC_BASE_DOMAIN
expect(url).toBe('http://localhost:8080/home/my-org/briefs/create');
});
});
});

View File

@@ -4,18 +4,18 @@
*/
import path from 'node:path';
import type { StorageType } from '../../common/types/index.js';
import type { Brief } from '../briefs/types.js';
import { AuthManager } from './managers/auth-manager.js';
import type {
Organization,
RemoteTask
} from './services/organization.service.js';
import type {
AuthCredentials,
OAuthFlowOptions,
UserContext
} from './types.js';
import type {
Organization,
Brief,
RemoteTask
} from './services/organization.service.js';
import type { StorageType } from '../../common/types/index.js';
/**
* Display information for storage context
@@ -210,6 +210,21 @@ export class AuthDomain {
};
}
/**
* Get the URL for creating a new brief in the web UI
* Returns null if not using API storage or if org slug is not available
*/
getBriefCreationUrl(): string | null {
const context = this.getContext();
const baseUrl = this.getWebAppUrl();
if (!baseUrl || !context?.orgSlug) {
return null;
}
return `${baseUrl}/home/${context.orgSlug}/briefs/create`;
}
/**
* Get web app base URL from environment configuration
* @private

View File

@@ -6,34 +6,54 @@ import os from 'os';
import path from 'path';
import { AuthConfig } from './types.js';
// Single base domain for all URLs
// Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsdown's env option
// Runtime: process.env.TM_BASE_DOMAIN can override for staging/development
// Default: https://tryhamster.com for production
const BASE_DOMAIN =
/**
* Get the base domain from environment variables
* Evaluated lazily to allow dotenv to load first
* Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
* Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsdown's env option
* Runtime: process.env.TM_BASE_DOMAIN can override for staging/development
* Default: https://tryhamster.com for production
*/
function getBaseDomain(): string {
return (
process.env.TM_BASE_DOMAIN || // Runtime override (for staging/tux)
process.env.TM_PUBLIC_BASE_DOMAIN; // Build-time (baked into compiled code)
process.env.TM_PUBLIC_BASE_DOMAIN || // Build-time (baked into compiled code)
'https://tryhamster.com' // Fallback default
);
}
/**
* Default authentication configuration
* Get default authentication configuration
* All URL configuration is derived from the single BASE_DOMAIN
* Evaluated lazily to allow dotenv to load environment variables first
*/
export const DEFAULT_AUTH_CONFIG: AuthConfig = {
function getDefaultAuthConfig(): AuthConfig {
return {
// Base domain for all services
baseUrl: BASE_DOMAIN!,
baseUrl: getBaseDomain(),
// Configuration directory and file paths
configDir: path.join(os.homedir(), '.taskmaster'),
configFile: path.join(os.homedir(), '.taskmaster', 'auth.json')
};
}
/**
* Get merged configuration with optional overrides
*/
export function getAuthConfig(overrides?: Partial<AuthConfig>): AuthConfig {
return {
...DEFAULT_AUTH_CONFIG,
...getDefaultAuthConfig(),
...overrides
};
}
/**
* Default authentication configuration (exported for backward compatibility)
* Note: This is now a getter property that evaluates lazily
*/
export const DEFAULT_AUTH_CONFIG: AuthConfig = new Proxy({} as AuthConfig, {
get(_target, prop) {
return getDefaultAuthConfig()[prop as keyof AuthConfig];
}
});

View File

@@ -9,7 +9,6 @@ export { OAuthService } from './services/oauth-service.js';
export { SupabaseSessionStorage } from './services/supabase-session-storage.js';
export type {
Organization,
Brief,
RemoteTask
} from './services/organization.service.js';

View File

@@ -2,7 +2,7 @@
* Tests for AuthManager singleton behavior
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the logger to verify warnings (must be hoisted before SUT import)
const mockLogger = {

View File

@@ -2,31 +2,31 @@
* Authentication manager for Task Master CLI
*/
import {
AuthCredentials,
OAuthFlowOptions,
AuthenticationError,
AuthConfig,
UserContext,
UserContextWithBrief
} from '../types.js';
import { ContextStore } from '../services/context-store.js';
import { OAuthService } from '../services/oauth-service.js';
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
import {
OrganizationService,
type Organization,
type Brief,
type RemoteTask
} from '../services/organization.service.js';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import { getLogger } from '../../../common/logger/index.js';
import fs from 'fs';
import path from 'path';
import os from 'os';
import type { Brief } from '../../briefs/types.js';
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
import { ContextStore } from '../services/context-store.js';
import { OAuthService } from '../services/oauth-service.js';
import {
type Organization,
OrganizationService,
type RemoteTask
} from '../services/organization.service.js';
import {
AuthConfig,
AuthCredentials,
AuthenticationError,
OAuthFlowOptions,
UserContext,
UserContextWithBrief
} from '../types.js';
/**
* Authentication manager class

View File

@@ -11,8 +11,8 @@
import fs from 'fs';
import path from 'path';
import { UserContext, AuthenticationError } from '../types.js';
import { getLogger } from '../../../common/logger/index.js';
import { AuthenticationError, UserContext } from '../types.js';
const DEFAULT_CONTEXT_FILE = path.join(
process.env.HOME || process.env.USERPROFILE || '~',

View File

@@ -2,23 +2,23 @@
* OAuth 2.0 Authorization Code Flow service
*/
import http from 'http';
import { URL } from 'url';
import crypto from 'crypto';
import http from 'http';
import os from 'os';
import {
AuthCredentials,
AuthenticationError,
OAuthFlowOptions,
AuthConfig,
CliData
} from '../types.js';
import { ContextStore } from '../services/context-store.js';
import { URL } from 'url';
import { Session } from '@supabase/supabase-js';
import { TASKMASTER_VERSION } from '../../../common/constants/index.js';
import { getLogger } from '../../../common/logger/index.js';
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
import { getAuthConfig } from '../config.js';
import { getLogger } from '../../../common/logger/index.js';
import packageJson from '../../../../../../package.json' with { type: 'json' };
import { Session } from '@supabase/supabase-js';
import { ContextStore } from '../services/context-store.js';
import {
AuthConfig,
AuthCredentials,
AuthenticationError,
CliData,
OAuthFlowOptions
} from '../types.js';
export class OAuthService {
private logger = getLogger('OAuthService');
@@ -417,10 +417,10 @@ export class OAuthService {
}
/**
* Get CLI version from package.json if available
* Get CLI version from centralized constants
*/
private getCliVersion(): string {
return packageJson.version || 'unknown';
return TASKMASTER_VERSION;
}
/**

View File

@@ -4,12 +4,13 @@
*/
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '../../../common/types/database.types.js';
import {
TaskMasterError,
ERROR_CODES
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import { getLogger } from '../../../common/logger/index.js';
import { Database } from '../../../common/types/database.types.js';
import type { Brief } from '../../briefs/types.js';
/**
* Organization data structure
@@ -20,24 +21,6 @@ export interface Organization {
slug: string;
}
/**
* Brief data structure
*/
export interface Brief {
id: string;
accountId: string;
documentId: string;
status: string;
createdAt: string;
updatedAt: string;
document?: {
id: string;
title: string;
document_name: string;
description?: string;
};
}
/**
* Task data structure from the remote database
*/
@@ -181,6 +164,7 @@ export class OrganizationService {
status,
created_at,
updated_at,
tasks(count),
document:document_id (
id,
document_name,
@@ -211,6 +195,9 @@ export class OrganizationService {
status: brief.status,
createdAt: brief.created_at,
updatedAt: brief.updated_at,
taskCount: Array.isArray(brief.tasks)
? (brief.tasks[0]?.count ?? 0)
: 0,
document: brief.document
? {
id: brief.document.id,

View File

@@ -14,9 +14,9 @@
* - Persistence to ~/.taskmaster/session.json
*/
import type { SupportedStorage } from '@supabase/supabase-js';
import fs from 'fs';
import path from 'path';
import type { SupportedStorage } from '@supabase/supabase-js';
import { getLogger } from '../../../common/logger/index.js';
const DEFAULT_SESSION_FILE = path.join(

View File

@@ -19,6 +19,8 @@ export interface UserContext {
orgSlug?: string;
briefId?: string;
briefName?: string;
briefStatus?: string;
briefUpdatedAt?: string;
updatedAt: string;
}

View File

@@ -0,0 +1,182 @@
/**
* @fileoverview Briefs Domain Facade
* Public API for brief-related operations
*/
import {
ERROR_CODES,
TaskMasterError
} from '../../common/errors/task-master-error.js';
import { AuthManager } from '../auth/managers/auth-manager.js';
import type { TaskRepository } from '../tasks/repositories/task-repository.interface.js';
import { BriefService, type TagWithStats } from './services/brief-service.js';
/**
* Briefs Domain - Unified API for brief operations
* Handles brief switching, matching, and statistics
*/
export class BriefsDomain {
private briefService: BriefService;
private authManager: AuthManager;
constructor() {
this.briefService = new BriefService();
this.authManager = AuthManager.getInstance();
}
/**
* Resolve a brief by name, ID, URL, or partial ID without updating context
* Returns the full brief object
*
* Supports:
* - Hamster URLs (e.g., https://app.tryhamster.com/home/hamster/briefs/abc123)
* - Full UUID
* - Last 8 characters of UUID
* - Brief name (exact or partial match)
*
* @param input - Raw input: URL, UUID, last 8 chars, or brief name
* @param orgId - Optional organization ID. If not provided, uses current context.
* @returns The resolved brief object
*/
async resolveBrief(input: string, orgId?: string): Promise<any> {
// Extract brief ID/name from input (could be URL, ID, or name)
const briefIdOrName = this.extractBriefIdentifier(input);
// Get org ID from parameter or context
const resolvedOrgId = orgId || this.authManager.getContext()?.orgId;
if (!resolvedOrgId) {
throw new TaskMasterError(
'No organization selected. Run "tm context org" first.',
ERROR_CODES.CONFIG_ERROR
);
}
// Fetch all briefs for the org
const briefs = await this.authManager.getBriefs(resolvedOrgId);
// Find matching brief using service
const matchingBrief = await this.briefService.findBrief(
briefs,
briefIdOrName
);
this.briefService.validateBriefFound(matchingBrief, briefIdOrName);
return matchingBrief;
}
/**
* Extract brief identifier from raw input
* Handles URLs, paths, and direct IDs
*
* @param input - Raw input string
* @returns Extracted brief identifier
*/
private extractBriefIdentifier(input: string): string {
const raw = input?.trim() ?? '';
if (!raw) {
throw new TaskMasterError(
'Brief identifier cannot be empty',
ERROR_CODES.VALIDATION_ERROR
);
}
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;
};
// 1) URL (absolute or schemeless)
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) {
return candidate;
}
}
// 2) Looks like a path without scheme
if (raw.includes('/')) {
const candidate = fromParts(raw);
if (candidate) {
return candidate;
}
}
// 3) Fallback: raw token (UUID, last 8 chars, or name)
return raw;
}
/**
* Switch to a different brief by name or ID
* Validates context, finds matching brief, and updates auth context
*/
async switchBrief(briefNameOrId: string): Promise<void> {
// Use resolveBrief to find the brief
const matchingBrief = await this.resolveBrief(briefNameOrId);
// Update context with the found brief
await this.authManager.updateContext({
briefId: matchingBrief.id,
briefName:
matchingBrief.document?.title || `Brief ${matchingBrief.id.slice(-8)}`,
briefStatus: matchingBrief.status,
briefUpdatedAt: matchingBrief.updatedAt
});
}
/**
* Get all briefs with detailed statistics including task counts
* Used for API storage to show brief statistics
*/
async getBriefsWithStats(
repository: TaskRepository,
projectId: string
): Promise<{
tags: TagWithStats[];
currentTag: string | null;
totalTags: number;
}> {
const context = this.authManager.getContext();
if (!context?.orgId) {
throw new TaskMasterError(
'No organization context available',
ERROR_CODES.MISSING_CONFIGURATION,
{
operation: 'getBriefsWithStats',
userMessage:
'No organization selected. Please authenticate first using: tm auth login'
}
);
}
// Get all briefs for the organization (through auth manager)
const briefs = await this.authManager.getBriefs(context.orgId);
// Use BriefService to calculate stats
return this.briefService.getTagsWithStats(
briefs,
context.briefId,
repository,
projectId
);
}
}

View File

@@ -0,0 +1,7 @@
/**
* Briefs module exports
*/
export { BriefsDomain } from './briefs-domain.js';
export { BriefService, type TagWithStats } from './services/brief-service.js';
export type { Brief } from './types.js';

View File

@@ -0,0 +1,225 @@
/**
* @fileoverview Brief Service
* Handles brief lookup, matching, and statistics
*/
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
import type { Brief } from '../types.js';
/**
* Tag statistics with detailed breakdown
*/
export interface TagWithStats {
name: string;
isCurrent: boolean;
taskCount: number;
completedTasks: number;
statusBreakdown: Record<string, number>;
subtaskCounts?: {
totalSubtasks: number;
subtasksByStatus: Record<string, number>;
};
created?: string;
description?: string;
status?: string;
briefId?: string;
updatedAt?: string;
}
/**
* Service for brief-related operations
*/
export class BriefService {
/**
* Find a brief by name or ID with flexible matching
*/
async findBrief(
briefs: Brief[],
nameOrId: string
): Promise<Brief | undefined> {
return briefs.find((brief) => this.matches(brief, nameOrId));
}
/**
* Match a brief against a query string
* Supports: exact name match, partial name match, full ID, last 8 chars of ID
*/
private matches(brief: Brief, query: string): boolean {
const briefName = brief.document?.title || '';
// Exact match (case-insensitive)
if (briefName.toLowerCase() === query.toLowerCase()) {
return true;
}
// Partial match
if (briefName.toLowerCase().includes(query.toLowerCase())) {
return true;
}
// Match by ID (full or last 8 chars)
if (
brief.id === query ||
brief.id.toLowerCase() === query.toLowerCase() ||
brief.id.slice(-8).toLowerCase() === query.toLowerCase()
) {
return true;
}
return false;
}
/**
* Get tags with detailed statistics for all briefs in an organization
* Used for API storage to show brief statistics
*/
async getTagsWithStats(
briefs: Brief[],
currentBriefId: string | undefined,
repository: TaskRepository,
_projectId?: string
): Promise<{
tags: TagWithStats[];
currentTag: string | null;
totalTags: number;
}> {
// For each brief, get task counts by querying tasks
const tagsWithStats = await Promise.all(
briefs.map(async (brief: Brief) => {
try {
// Get all tasks for this brief
const tasks = await repository.getTasks(brief.id, {});
// Calculate statistics
const statusBreakdown: Record<string, number> = {};
let completedTasks = 0;
const subtaskCounts = {
totalSubtasks: 0,
subtasksByStatus: {} as Record<string, number>
};
tasks.forEach((task) => {
// Count task status
const status = task.status || 'pending';
statusBreakdown[status] = (statusBreakdown[status] || 0) + 1;
if (status === 'done') {
completedTasks++;
}
// Count subtasks
if (task.subtasks && task.subtasks.length > 0) {
subtaskCounts.totalSubtasks += task.subtasks.length;
task.subtasks.forEach((subtask) => {
const subStatus = subtask.status || 'pending';
subtaskCounts.subtasksByStatus[subStatus] =
(subtaskCounts.subtasksByStatus[subStatus] || 0) + 1;
});
}
});
return {
name:
brief.document?.title ||
brief.document?.document_name ||
brief.id,
isCurrent: currentBriefId === brief.id,
taskCount: tasks.length,
completedTasks,
statusBreakdown,
subtaskCounts:
subtaskCounts.totalSubtasks > 0 ? subtaskCounts : undefined,
created: brief.createdAt,
description: brief.document?.description,
status: brief.status,
briefId: brief.id,
updatedAt: brief.updatedAt
};
} catch (error) {
// If we can't get tasks for a brief, return it with 0 tasks
console.warn(`Failed to get tasks for brief ${brief.id}:`, error);
return {
name:
brief.document?.title ||
brief.document?.document_name ||
brief.id,
isCurrent: currentBriefId === brief.id,
taskCount: 0,
completedTasks: 0,
statusBreakdown: {},
created: brief.createdAt,
description: brief.document?.description,
status: brief.status,
briefId: brief.id,
updatedAt: brief.updatedAt
};
}
})
);
// Define priority order for brief statuses
const statusPriority: Record<string, number> = {
delivering: 1,
aligned: 2,
refining: 3,
draft: 4,
delivered: 5,
done: 6,
archived: 7
};
// Sort tags: first by status priority, then by updatedAt (most recent first) within each status
const sortedTags = tagsWithStats.sort((a, b) => {
// Get status priorities (default to 999 for unknown statuses)
const statusA = (a.status || '').toLowerCase();
const statusB = (b.status || '').toLowerCase();
const priorityA = statusPriority[statusA] ?? 999;
const priorityB = statusPriority[statusB] ?? 999;
// Sort by status priority first
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
// Within same status, sort by updatedAt (most recent first)
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return dateB - dateA; // Descending order (most recent first)
});
// Find current brief name
const currentBrief = briefs.find((b) => b.id === currentBriefId);
const currentTag = currentBrief
? currentBrief.document?.title ||
currentBrief.document?.document_name ||
null
: null;
return {
tags: sortedTags,
currentTag,
totalTags: sortedTags.length
};
}
/**
* Validate that a brief was found, throw error if not
*/
validateBriefFound(
brief: Brief | undefined,
nameOrId: string
): asserts brief is Brief {
if (!brief) {
throw new TaskMasterError(
`Brief "${nameOrId}" not found in organization`,
ERROR_CODES.NOT_FOUND
);
}
}
}

View File

@@ -0,0 +1,23 @@
/**
* @fileoverview Briefs module types
*/
/**
* Brief data structure
* Represents a project brief containing tasks and requirements
*/
export interface Brief {
id: string;
accountId: string;
documentId: string;
status: string;
createdAt: string;
updatedAt: string;
taskCount?: number;
document?: {
id: string;
title: string;
document_name: string;
description?: string;
};
}

View File

@@ -3,11 +3,11 @@
* Public API for configuration management
*/
import type { ConfigManager } from './managers/config-manager.js';
import type {
PartialConfiguration,
RuntimeStorageConfig
} from '../../common/interfaces/configuration.interface.js';
import type { ConfigManager } from './managers/config-manager.js';
/**
* Config Domain - Unified API for configuration operations

View File

@@ -3,14 +3,14 @@
* Tests the orchestration of all configuration services
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { ConfigManager } from './config-manager.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
import { ConfigLoader } from '../services/config-loader.service.js';
import { ConfigMerger } from '../services/config-merger.service.js';
import { RuntimeStateManager } from '../services/runtime-state-manager.service.js';
import { ConfigPersistence } from '../services/config-persistence.service.js';
import { EnvironmentConfigProvider } from '../services/environment-config-provider.service.js';
import { RuntimeStateManager } from '../services/runtime-state-manager.service.js';
import { ConfigManager } from './config-manager.js';
// Mock all services
vi.mock('../services/config-loader.service.js');

View File

@@ -13,12 +13,12 @@ import type {
import { DEFAULT_CONFIG_VALUES as DEFAULTS } from '../../../common/interfaces/configuration.interface.js';
import { ConfigLoader } from '../services/config-loader.service.js';
import {
ConfigMerger,
CONFIG_PRECEDENCE
CONFIG_PRECEDENCE,
ConfigMerger
} from '../services/config-merger.service.js';
import { RuntimeStateManager } from '../services/runtime-state-manager.service.js';
import { ConfigPersistence } from '../services/config-persistence.service.js';
import { EnvironmentConfigProvider } from '../services/environment-config-provider.service.js';
import { RuntimeStateManager } from '../services/runtime-state-manager.service.js';
/**
* ConfigManager orchestrates all configuration services

View File

@@ -2,10 +2,10 @@
* @fileoverview Unit tests for ConfigLoader service
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import fs from 'node:fs/promises';
import { ConfigLoader } from './config-loader.service.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
import { ConfigLoader } from './config-loader.service.js';
vi.mock('node:fs', () => ({
promises: {

View File

@@ -5,12 +5,12 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
/**
* ConfigLoader handles loading configuration from files

View File

@@ -2,8 +2,8 @@
* @fileoverview Unit tests for ConfigMerger service
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { ConfigMerger, CONFIG_PRECEDENCE } from './config-merger.service.js';
import { beforeEach, describe, expect, it } from 'vitest';
import { CONFIG_PRECEDENCE, ConfigMerger } from './config-merger.service.js';
describe('ConfigMerger', () => {
let merger: ConfigMerger;

View File

@@ -2,10 +2,10 @@
* @fileoverview Unit tests for ConfigPersistence service
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import fs from 'node:fs/promises';
import { ConfigPersistence } from './config-persistence.service.js';
import type { PartialConfiguration } from '@tm/core/common/interfaces/configuration.interface.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigPersistence } from './config-persistence.service.js';
vi.mock('node:fs', () => ({
promises: {

View File

@@ -5,11 +5,11 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
import { getLogger } from '../../../common/logger/index.js';
/**

View File

@@ -2,7 +2,7 @@
* @fileoverview Unit tests for EnvironmentConfigProvider service
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EnvironmentConfigProvider } from './environment-config-provider.service.js';
describe('EnvironmentConfigProvider', () => {

Some files were not shown because too many files have changed in this diff Show More