mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-29 22:02:04 +00:00
feat: add tm tags command to remote (#1386)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
@@ -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"
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -23,5 +23,9 @@
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
}
|
||||
}
|
||||
|
||||
10
CLAUDE.md
10
CLAUDE.md
@@ -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/`
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
382
apps/cli/src/commands/briefs.command.ts
Normal file
382
apps/cli/src/commands/briefs.command.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,150 +412,61 @@ 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);
|
||||
ui.displayError(
|
||||
`Failed to set context from brief: ${(error as Error).message}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 scheme‑less)
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set context directly from options
|
||||
*/
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
545
apps/cli/src/commands/tags.command.ts
Normal file
545
apps/cli/src/commands/tags.command.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
55
apps/cli/src/types/tag-management.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
60
apps/cli/src/ui/components/cardBox.component.ts
Normal file
60
apps/cli/src/ui/components/cardBox.component.ts
Normal 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 }
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
102
apps/cli/src/ui/display/messages.ts
Normal file
102
apps/cli/src/ui/display/messages.ts
Normal 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
|
||||
})
|
||||
);
|
||||
}
|
||||
145
apps/cli/src/ui/display/tables.ts
Normal file
145
apps/cli/src/ui/display/tables.ts
Normal 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();
|
||||
}
|
||||
49
apps/cli/src/ui/formatters/complexity-formatters.ts
Normal file
49
apps/cli/src/ui/formatters/complexity-formatters.ts
Normal 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})`);
|
||||
}
|
||||
39
apps/cli/src/ui/formatters/dependency-formatters.ts
Normal file
39
apps/cli/src/ui/formatters/dependency-formatters.ts
Normal 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(', ');
|
||||
}
|
||||
25
apps/cli/src/ui/formatters/priority-formatters.ts
Normal file
25
apps/cli/src/ui/formatters/priority-formatters.ts
Normal 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);
|
||||
}
|
||||
139
apps/cli/src/ui/formatters/status-formatters.spec.ts
Normal file
139
apps/cli/src/ui/formatters/status-formatters.spec.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
179
apps/cli/src/ui/formatters/status-formatters.ts
Normal file
179
apps/cli/src/ui/formatters/status-formatters.ts
Normal 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}`);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
158
apps/cli/src/ui/layout/helpers.spec.ts
Normal file
158
apps/cli/src/ui/layout/helpers.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
51
apps/cli/src/ui/layout/helpers.ts
Normal file
51
apps/cli/src/ui/layout/helpers.ts
Normal 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})`;
|
||||
}
|
||||
68
apps/cli/src/utils/auth-helpers.ts
Normal file
68
apps/cli/src/utils/auth-helpers.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
284
apps/cli/src/utils/brief-selection.ts
Normal file
284
apps/cli/src/utils/brief-selection.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
28
apps/cli/src/utils/index.ts
Normal file
28
apps/cli/src/utils/index.ts
Normal 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';
|
||||
@@ -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('✓');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
1
package-lock.json
generated
@@ -28447,6 +28447,7 @@
|
||||
"@tm/core": "*",
|
||||
"boxen": "^8.0.1",
|
||||
"chalk": "5.6.2",
|
||||
"cli-table3": "0.6.5",
|
||||
"ora": "^8.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
99
packages/tm-bridge/src/add-tag-bridge.ts
Normal file
99
packages/tm-bridge/src/add-tag-bridge.ts
Normal 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
|
||||
};
|
||||
}
|
||||
46
packages/tm-bridge/src/bridge-types.ts
Normal file
46
packages/tm-bridge/src/bridge-types.ts
Normal 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;
|
||||
}
|
||||
69
packages/tm-bridge/src/bridge-utils.ts
Normal file
69
packages/tm-bridge/src/bridge-utils.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
// Check storage type using shared utility
|
||||
const { isApiStorage, tmCore } = await checkStorageType(
|
||||
projectRoot,
|
||||
report,
|
||||
'falling back to file-based expansion'
|
||||
);
|
||||
|
||||
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}`
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
160
packages/tm-bridge/src/tags-bridge.ts
Normal file
160
packages/tm-bridge/src/tags-bridge.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
// Check storage type using shared utility
|
||||
const { isApiStorage, tmCore } = await checkStorageType(
|
||||
projectRoot,
|
||||
report,
|
||||
'falling back to file-based update'
|
||||
);
|
||||
|
||||
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}`
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
145
packages/tm-bridge/src/use-tag-bridge.ts
Normal file
145
packages/tm-bridge/src/use-tag-bridge.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
StorageType,
|
||||
TaskComplexity,
|
||||
TaskPriority,
|
||||
StorageType
|
||||
TaskPriority
|
||||
} from '../types/index.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface TaskMetadata {
|
||||
projectName?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,6 +53,9 @@ export {
|
||||
normalizeProjectRoot
|
||||
} from './project-root-finder.js';
|
||||
|
||||
// Export path construction utilities
|
||||
export { getProjectPaths } from './path-helpers.js';
|
||||
|
||||
// Additional utility exports
|
||||
|
||||
/**
|
||||
|
||||
40
packages/tm-core/src/common/utils/path-helpers.ts
Normal file
40
packages/tm-core/src/common/utils/path-helpers.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
111
packages/tm-core/src/modules/auth/auth-domain.spec.ts
Normal file
111
packages/tm-core/src/modules/auth/auth-domain.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
process.env.TM_BASE_DOMAIN || // Runtime override (for staging/tux)
|
||||
process.env.TM_PUBLIC_BASE_DOMAIN; // Build-time (baked into compiled code)
|
||||
/**
|
||||
* 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)
|
||||
'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 = {
|
||||
// Base domain for all services
|
||||
baseUrl: BASE_DOMAIN!,
|
||||
function getDefaultAuthConfig(): AuthConfig {
|
||||
return {
|
||||
// Base domain for all services
|
||||
baseUrl: getBaseDomain(),
|
||||
|
||||
// Configuration directory and file paths
|
||||
configDir: path.join(os.homedir(), '.taskmaster'),
|
||||
configFile: path.join(os.homedir(), '.taskmaster', 'auth.json')
|
||||
};
|
||||
// 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];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || '~',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface UserContext {
|
||||
orgSlug?: string;
|
||||
briefId?: string;
|
||||
briefName?: string;
|
||||
briefStatus?: string;
|
||||
briefUpdatedAt?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
182
packages/tm-core/src/modules/briefs/briefs-domain.ts
Normal file
182
packages/tm-core/src/modules/briefs/briefs-domain.ts
Normal 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 scheme‑less)
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
7
packages/tm-core/src/modules/briefs/index.ts
Normal file
7
packages/tm-core/src/modules/briefs/index.ts
Normal 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';
|
||||
225
packages/tm-core/src/modules/briefs/services/brief-service.ts
Normal file
225
packages/tm-core/src/modules/briefs/services/brief-service.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
packages/tm-core/src/modules/briefs/types.ts
Normal file
23
packages/tm-core/src/modules/briefs/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user