Skip to main content
Glama
context.command.ts17.5 kB
/** * @fileoverview Context command for managing org/brief selection * Provides a clean interface for workspace context management */ import { AuthManager, type TmCore, type UserContext, 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 context command */ export interface ContextResult { success: boolean; action: 'show' | 'select-org' | 'select-brief' | 'clear' | 'set'; context?: UserContext; message?: string; } /** * ContextCommand extending Commander's Command class * Manages user's workspace context (org/brief selection) */ export class ContextCommand extends Command { private authManager: AuthManager; private tmCore?: TmCore; private lastResult?: ContextResult; constructor(name?: string) { super(name || 'context'); // Initialize auth manager this.authManager = AuthManager.getInstance(); // Configure the command this.description( 'Manage workspace context (organization and brief selection)' ); // Add subcommands this.addOrgCommand(); this.addBriefCommand(); this.addClearCommand(); this.addSetCommand(); // Accept optional positional argument for brief ID or Hamster URL this.argument('[briefOrUrl]', 'Brief ID or Hamster brief URL'); // Global option for this command and its subcommands this.option('--no-header', 'Suppress the header display'); // Default action: if an argument is provided, resolve and set context; else show this.action(async (briefOrUrl?: string, options?: { header?: boolean }) => { const showHeader = options?.header !== false; if (briefOrUrl && briefOrUrl.trim().length > 0) { await this.executeSetFromBriefInput(briefOrUrl.trim(), showHeader); return; } await this.executeShow(showHeader); }); } /** * Add org selection subcommand */ private addOrgCommand(): void { this.command('org') .description('Select an organization') .argument('[orgId]', 'Organization ID or slug to select directly') .option('--no-header', 'Suppress the header display') .action(async (orgId?: string) => { await this.executeSelectOrg(orgId); }); } /** * Add brief selection subcommand */ private addBriefCommand(): void { this.command('brief') .description('Select a brief within the current organization') .argument('[briefIdOrUrl]', 'Brief ID or Hamster URL to select directly') .option('--no-header', 'Suppress the header display') .action(async (briefIdOrUrl?: string) => { await this.executeSelectBrief(briefIdOrUrl); }); } /** * Add clear subcommand */ private addClearCommand(): void { this.command('clear') .description('Clear all context selections') .option('--no-header', 'Suppress the header display') .action(async () => { await this.executeClear(); }); } /** * Add set subcommand for direct context setting */ private addSetCommand(): void { this.command('set') .description('Set context directly') .option('--org <id>', 'Organization ID') .option('--org-name <name>', 'Organization name') .option('--brief <id>', 'Brief ID') .option('--brief-name <name>', 'Brief name') .option('--no-header', 'Suppress the header display') .action(async (options) => { await this.executeSet(options); }); } /** * Execute show current context */ private async executeShow(showHeader: boolean = true): Promise<void> { try { const result = await this.displayContext(showHeader); this.setLastResult(result); } catch (error: any) { ui.displayError(`Failed to show context: ${(error as Error).message}`); process.exit(1); } } /** * Display current context */ private async displayContext( showHeader: boolean = true ): Promise<ContextResult> { // Check authentication 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', message: 'Not authenticated' }; } const context = this.authManager.getContext(); if (showHeader) { console.log(chalk.cyan('\n🌍 Workspace Context\n')); } if (context && (context.orgId || context.briefId)) { if (context.orgName || context.orgId) { console.log(chalk.green('✓ Organization')); if (context.orgName) { console.log(chalk.white(` ${context.orgName}`)); } if (context.orgId) { console.log(chalk.gray(` ID: ${context.orgId}`)); } } if (context.briefName || context.briefId) { console.log(chalk.green('\n✓ Brief')); if (context.briefName && context.briefId) { const shortId = context.briefId.slice(-8); console.log( chalk.white(` ${context.briefName} `) + chalk.gray(`(${shortId})`) ); } else if (context.briefName) { console.log(chalk.white(` ${context.briefName}`)); } 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) { console.log( chalk.gray( `\n Last updated: ${new Date(context.updatedAt).toLocaleString()}` ) ); } return { success: true, action: 'show', context, message: 'Context loaded' }; } else { console.log(chalk.yellow('✗ No context selected')); console.log( chalk.gray('\n Run "tm context org" to select an organization') ); console.log(chalk.gray(' Run "tm context brief" to select a brief')); return { success: true, action: 'show', message: 'No context selected' }; } } /** * Execute org selection */ private async executeSelectOrg(orgId?: string): Promise<void> { try { // Check authentication if (!(await checkAuthentication(this.authManager))) { process.exit(1); } const result = await this.selectOrganization(orgId); this.setLastResult(result); if (!result.success) { process.exit(1); } } catch (error: any) { ui.displayError( `Failed to select organization: ${(error as Error).message}` ); process.exit(1); } } /** * Select an organization interactively or by ID/slug/name */ private async selectOrganization(orgId?: string): Promise<ContextResult> { const spinner = ora('Fetching organizations...').start(); try { // Fetch organizations from API const organizations = await this.authManager.getOrganizations(); spinner.stop(); if (organizations.length === 0) { ui.displayWarning('No organizations available'); return { success: false, action: 'select-org', message: 'No organizations available' }; } let selectedOrg; // If orgId provided, find matching org by ID, slug or name const trimmedOrgId = orgId?.trim(); if (trimmedOrgId) { const normalizedInput = trimmedOrgId.toLowerCase(); selectedOrg = organizations.find( (org) => org.id === trimmedOrgId || org.slug?.toLowerCase() === normalizedInput || org.name.toLowerCase() === normalizedInput ); if (!selectedOrg) { const totalCount = organizations.length; const displayLimit = 5; const orgList = organizations .slice(0, displayLimit) .map((o) => o.name) .join(', '); let errorMessage = `Organization not found: ${trimmedOrgId}\n`; if (totalCount <= displayLimit) { errorMessage += `Available organizations: ${orgList}`; } else { errorMessage += `Available organizations (showing ${displayLimit} of ${totalCount}): ${orgList}`; errorMessage += `\nRun "tm context org" to see all organizations and select interactively`; } ui.displayError(errorMessage); return { success: false, action: 'select-org', message: `Organization not found: ${trimmedOrgId}` }; } } else { // Interactive selection const response = await inquirer.prompt([ { type: 'list', name: 'selectedOrg', message: 'Select an organization:', choices: organizations.map((org) => ({ name: org.name, value: org })) } ]); selectedOrg = response.selectedOrg; } // Update context await this.authManager.updateContext({ orgId: selectedOrg.id, orgName: selectedOrg.name, orgSlug: selectedOrg.slug, // Clear brief when changing org briefId: undefined, briefName: undefined }); ui.displaySuccess(`Selected organization: ${selectedOrg.name}`); return { success: true, action: 'select-org', context: this.authManager.getContext() || undefined, message: `Selected organization: ${selectedOrg.name}` }; } catch (error) { spinner.fail('Failed to fetch organizations'); throw error; } } /** * Execute brief selection */ private async executeSelectBrief(briefIdOrUrl?: string): Promise<void> { try { // Check authentication if (!(await checkAuthentication(this.authManager))) { process.exit(1); } // If briefIdOrUrl provided, use direct selection if (briefIdOrUrl && briefIdOrUrl.trim().length > 0) { await this.selectBriefDirectly(briefIdOrUrl.trim(), 'select-brief'); return; } // Interactive selection const context = this.authManager.getContext(); if (!context?.orgId) { ui.displayError( '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-brief', context: this.authManager.getContext() || undefined, message: result.message }); if (!result.success) { process.exit(1); } } catch (error: any) { ui.displayError(`Failed to select brief: ${(error as Error).message}`); process.exit(1); } } /** * Execute clear context */ private async executeClear(): Promise<void> { try { // Check authentication if (!(await checkAuthentication(this.authManager))) { process.exit(1); } const result = await this.clearContext(); this.setLastResult(result); if (!result.success) { process.exit(1); } } catch (error: any) { ui.displayError(`Failed to clear context: ${(error as Error).message}`); process.exit(1); } } /** * Clear all context selections */ private async clearContext(): Promise<ContextResult> { try { await this.authManager.clearContext(); ui.displaySuccess('Context cleared'); return { success: true, action: 'clear', message: 'Context cleared' }; } catch (error) { ui.displayError(`Failed to clear context: ${(error as Error).message}`); return { success: false, action: 'clear', message: `Failed to clear context: ${(error as Error).message}` }; } } /** * Execute set context with options */ private async executeSet(options: any): Promise<void> { try { // Check authentication if (!(await checkAuthentication(this.authManager))) { process.exit(1); } const result = await this.setContext(options); this.setLastResult(result); if (!result.success) { process.exit(1); } } catch (error: any) { 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() }); } } /** * Helper method to select brief directly from input (URL or ID) * Used by both executeSelectBrief and executeSetFromBriefInput */ private async selectBriefDirectly( input: string, action: 'select-brief' | 'set' ): Promise<void> { await this.initTmCore(); const result = await selectBriefFromInput( this.authManager, input, this.tmCore ); this.setLastResult({ success: result.success, action, context: this.authManager.getContext() || undefined, message: result.message }); if (!result.success) { process.exit(1); } } /** * Execute setting context from a brief ID or Hamster URL * All parsing logic is in tm-core */ private async executeSetFromBriefInput( input: string, _showHeader: boolean = true ): Promise<void> { try { // Check authentication if (!(await checkAuthentication(this.authManager))) { process.exit(1); } await this.selectBriefDirectly(input, 'set'); } catch (error: any) { ui.displayError( `Failed to set context from brief: ${(error as Error).message}` ); process.exit(1); } } /** * Set context directly from options */ private async setContext(options: any): Promise<ContextResult> { try { const context: Partial<UserContext> = {}; if (options.org) { context.orgId = options.org; } if (options.orgName) { context.orgName = options.orgName; } if (options.brief) { context.briefId = options.brief; } if (options.briefName) { context.briefName = options.briefName; } if (Object.keys(context).length === 0) { ui.displayWarning('No context options provided'); return { success: false, action: 'set', message: 'No context options provided' }; } await this.authManager.updateContext(context); ui.displaySuccess('Context updated'); // Display what was set if (context.orgName || context.orgId) { console.log( chalk.gray(` Organization: ${context.orgName || context.orgId}`) ); } if (context.briefName || context.briefId) { console.log( chalk.gray(` Brief: ${context.briefName || context.briefId}`) ); } return { success: true, action: 'set', context: this.authManager.getContext() || undefined, message: 'Context updated' }; } catch (error) { ui.displayError(`Failed to set context: ${(error as Error).message}`); return { success: false, action: 'set', message: `Failed to set context: ${(error as Error).message}` }; } } /** * Set the last result for programmatic access */ private setLastResult(result: ContextResult): void { this.lastResult = result; } /** * Get the last result (for programmatic usage) */ getLastResult(): ContextResult | undefined { return this.lastResult; } /** * Get current context (for programmatic usage) */ getContext(): UserContext | null { return this.authManager.getContext(); } /** * Interactive context setup (for post-auth flow) * Organization selection is MANDATORY - you cannot proceed without an org. * Brief selection is optional. */ async setupContextInteractive(): Promise<{ success: boolean; orgSelected: boolean; briefSelected: boolean; }> { try { // Organization selection is REQUIRED - use the shared utility // It will auto-select if only one org, or prompt if multiple const orgResult = await ensureOrgSelected(this.authManager, { promptMessage: 'Select an organization:' }); if (!orgResult.success || !orgResult.orgId) { // This should rarely happen (only if user has no orgs) return { success: false, orgSelected: false, briefSelected: false }; } // Brief selection is optional - ask if they want to select one const { selectBrief } = await inquirer.prompt([ { type: 'confirm', name: 'selectBrief', message: 'Would you like to select a brief now?', default: true } ]); if (!selectBrief) { return { success: true, orgSelected: true, briefSelected: false }; } // Select brief using shared utility const briefResult = await selectBriefInteractive( this.authManager, orgResult.orgId ); return { success: true, orgSelected: true, briefSelected: briefResult.success }; } catch (error) { console.error( chalk.yellow( '\nContext setup encountered an error. You can set it up later with "tm context"' ) ); return { success: false, orgSelected: false, briefSelected: false }; } } /** * Clean up resources */ async cleanup(): Promise<void> { // No resources to clean up for context command } /** * Register this command on an existing program */ static register(program: Command, name?: string): ContextCommand { const contextCommand = new ContextCommand(name); program.addCommand(contextCommand); return contextCommand; } }

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