Skip to main content
Glama
briefs.command.ts19 kB
/** * @fileoverview Briefs Command - Friendly alias for tag management in API storage * Provides brief-specific commands that only work with API storage */ import readline from 'readline'; import { type LogLevel, type TagInfo, tryAddTagViaRemote } from '@tm/bridge'; import type { Brief, TmCore } from '@tm/core'; import { AuthManager, createTmCore } from '@tm/core'; import chalk from 'chalk'; import { Command } from 'commander'; import inquirer from 'inquirer'; import ora from 'ora'; import { getBriefStatusWithColor } from '../ui/formatters/status-formatters.js'; import { checkAuthentication } from '../utils/auth-helpers.js'; import { selectBriefFromInput, selectBriefInteractive } from '../utils/brief-selection.js'; import { ensureOrgSelected } from '../utils/org-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); } // Ensure org is selected - prompt if not const orgId = await this.ensureOrgSelectedLocal(); if (!orgId) { process.exit(1); } // Fetch briefs directly from AuthManager (bypasses storage layer issues) const spinner = ora('Fetching briefs...').start(); const briefs = await this.authManager.getBriefs(orgId); spinner.stop(); // Get current context to determine current brief const context = this.authManager.getContext(); const currentBriefId = context?.briefId; // Convert to TagInfo format for display const tags: TagInfo[] = briefs.map((brief: Brief) => ({ name: brief.document?.title || `Brief ${brief.id.slice(-8)}`, isCurrent: brief.id === currentBriefId, taskCount: brief.taskCount || 0, completedTasks: 0, // Not available from getBriefs statusBreakdown: {}, created: brief.createdAt, description: brief.document?.description, status: brief.status, briefId: brief.id, updatedAt: brief.updatedAt })); // Sort: current first, then by updatedAt tags.sort((a, b) => { if (a.isCurrent) return -1; if (b.isCurrent) return 1; return 0; }); this.setLastResult({ success: true, action: 'list', briefs: tags, currentBrief: currentBriefId || null, message: `Found ${tags.length} brief(s)` }); // Determine if we should skip table display (when interactive selection follows) const isInteractive = process.stdout.isTTY; // If interactive mode and briefs available, show integrated table selection if (isInteractive && tags.length > 0) { await this.promptBriefSelection(tags); } else if (tags.length === 0) { ui.displayWarning('No briefs found in this organization'); } else { // Non-interactive: display table this.displayBriefsTable(tags, options?.showMetadata); } } 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); } } /** * Ensure an organization is selected, prompting if necessary * Uses the shared org-selection utility */ private async ensureOrgSelectedLocal(): Promise<string | null> { const result = await ensureOrgSelected(this.authManager); return result.success ? result.orgId || null : null; } /** * Display briefs in a table format (for non-interactive mode) */ private displayBriefsTable(tags: TagInfo[], _showMetadata?: boolean): void { const Table = require('cli-table3'); const terminalWidth = Math.max(process.stdout.columns || 120, 80); const usableWidth = Math.floor(terminalWidth * 0.95); 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: [ chalk.cyan.bold('Brief Name'), chalk.cyan.bold('Status'), chalk.cyan.bold('Updated'), chalk.cyan.bold('Tasks'), chalk.cyan.bold('Completed') ], colWidths: colWidths, wordWrap: true }); tags.forEach((tag) => { 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})`)}`; const updatedDate = tag.updatedAt ? new Date(tag.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : chalk.gray('N/A'); table.push([ tagDisplay, getBriefStatusWithColor(tag.status, true), chalk.gray(updatedDate), chalk.white(String(tag.taskCount || 0)), chalk.green(String(tag.completedTasks || 0)) ]); }); console.log(table.toString()); } /** * Create table-formatted choice for integrated selection */ private formatBriefAsTableRow( brief: TagInfo, colWidths: { name: number; status: number; updated: number; tasks: number; done: number; } ): string { const shortId = brief.briefId ? brief.briefId.slice(-8) : 'unknown'; const isCurrent = brief.isCurrent; // Current indicator const currentMarker = isCurrent ? chalk.green('●') : ' '; // Calculate max name length (leave room for marker, spaces, and ID) const idSuffix = isCurrent ? '(current)' : `(${shortId})`; const maxNameLen = colWidths.name - 4 - idSuffix.length; // 4 = "● " + " " before id // Truncate name if too long let displayName = brief.name; if (displayName.length > maxNameLen) { displayName = displayName.substring(0, maxNameLen - 1) + '…'; } const nameText = isCurrent ? chalk.green.bold(displayName) : displayName; const idText = isCurrent ? chalk.gray(`(current)`) : chalk.gray(`(${shortId})`); // Calculate visual length and pad const nameVisualLength = 2 + displayName.length + 1 + idSuffix.length; const namePadding = Math.max(0, colWidths.name - nameVisualLength); const nameCol = `${currentMarker} ${nameText} ${idText}${' '.repeat(namePadding)}`; // Status column - fixed width with padding const statusDisplay = getBriefStatusWithColor(brief.status, true); const statusVisual = (brief.status || 'unknown').length + 2; // icon + space + status const statusPadding = Math.max(0, colWidths.status - statusVisual); const statusCol = `${statusDisplay}${' '.repeat(statusPadding)}`; // Updated column const updatedDate = brief.updatedAt ? new Date(brief.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : 'N/A'; const updatedCol = chalk.gray(updatedDate.padEnd(colWidths.updated)); // Tasks column const tasksCol = chalk.white( String(brief.taskCount || 0).padStart(colWidths.tasks) ); // Done column const doneCol = chalk.green( String(brief.completedTasks || 0).padStart(colWidths.done) ); return `${nameCol} ${statusCol} ${updatedCol} ${tasksCol} ${doneCol}`; } /** * Prompt user to select a brief using integrated table selection */ private async promptBriefSelection(briefs: TagInfo[]): Promise<void> { try { // Check if org is selected (required for context updates) const context = this.authManager.getContext(); if (!context?.orgId) { // Don't prompt if no org selected - user needs to set org first return; } // Calculate column widths based on terminal const terminalWidth = Math.max(process.stdout.columns || 120, 80); const usableWidth = Math.floor(terminalWidth * 0.95); const colWidths = { name: Math.floor(usableWidth * 0.42), // More room for long names status: Math.floor(usableWidth * 0.14), updated: Math.floor(usableWidth * 0.16), tasks: 6, done: 6 }; // Create table header const headerLine = chalk.cyan.bold('Brief Name'.padEnd(colWidths.name)) + chalk.cyan.bold('Status'.padEnd(colWidths.status)) + chalk.cyan.bold('Updated'.padEnd(colWidths.updated)) + chalk.cyan.bold('Tasks'.padStart(colWidths.tasks + 2)) + chalk.cyan.bold('Done'.padStart(colWidths.done + 2)); const separator = chalk.gray('─'.repeat(usableWidth)); // Build choices as table rows const choices: any[] = [ new inquirer.Separator(headerLine), new inquirer.Separator(separator) ]; briefs.forEach((brief) => { choices.push({ name: this.formatBriefAsTableRow(brief, colWidths), value: brief.briefId || brief.name, short: brief.name // Show just name after selection }); }); // Add separator and cancel option choices.push(new inquirer.Separator(separator)); choices.push({ name: chalk.dim(' (Cancel - keep current selection)'), value: null, short: 'Cancelled' }); // Set up ESC key handler to cancel let cancelled = false; const handleKeypress = (_char: string, key: readline.Key) => { if (key && key.name === 'escape') { cancelled = true; // Send Ctrl+C to cancel the prompt process.stdin.emit('keypress', '', { name: 'c', ctrl: true }); } }; // Enable keypress events if (process.stdin.isTTY) { readline.emitKeypressEvents(process.stdin); process.stdin.on('keypress', handleKeypress); } let answer: { selectedBrief: string | null }; try { answer = await inquirer.prompt([ { type: 'list', name: 'selectedBrief', message: 'Select a brief:', choices: choices, pageSize: Math.min(briefs.length + 5, 20), // Show all briefs if possible loop: false } ]); } finally { // Clean up keypress listener if (process.stdin.isTTY) { process.stdin.removeListener('keypress', handleKeypress); } } // If ESC was pressed, treat as cancel if (cancelled) { return; } if (answer.selectedBrief && answer.selectedBrief !== null) { // Find the selected brief const selectedBrief = briefs.find( (b) => b.briefId === answer.selectedBrief || b.name === answer.selectedBrief ); if (selectedBrief) { // Update context with selected brief await this.authManager.updateContext({ briefId: selectedBrief.briefId || undefined, briefName: selectedBrief.name, briefStatus: selectedBrief.status || undefined, briefUpdatedAt: selectedBrief.updatedAt || undefined }); ui.displaySuccess(`Selected brief: ${selectedBrief.name}`); this.setLastResult({ success: true, action: 'select', currentBrief: selectedBrief.briefId || selectedBrief.name, message: `Selected brief: ${selectedBrief.name}` }); } } } catch (error) { // If user cancels (Ctrl+C), inquirer throws - handle gracefully if ((error as any).isTtyError) { // Not a TTY, skip interactive prompt return; } // Other errors - log but don't fail the command console.error( chalk.yellow( `\nNote: Could not prompt for brief selection: ${(error as Error).message}` ) ); } } /** * 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; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/eyaltoledano/claude-task-master'

If you have feedback or need assistance with the MCP directory API, please join our Discord server