Skip to main content
Glama
base-profile.ts11.2 kB
/** * @fileoverview Base Slash Command Profile * Abstract base class for all slash command profiles. * Follows the same pattern as ai-providers/base-provider.js */ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { SlashCommand, FormattedSlashCommand } from '../types.js'; import { filterCommandsByMode } from '../commands/index.js'; /** Default namespace for TaskMaster commands */ export const TM_NAMESPACE = 'tm'; /** * Result of adding or removing slash commands */ export interface SlashCommandResult { /** Whether the operation was successful */ success: boolean; /** Number of commands affected */ count: number; /** Directory where commands were written/removed */ directory: string; /** List of filenames affected */ files: string[]; /** Error message if operation failed */ error?: string; } /** * Options for adding slash commands */ export interface AddSlashCommandsOptions { /** * Operating mode to filter commands. * - 'solo': Solo + common commands (for local file storage) * - 'team': Team-only commands (exclusive, for Hamster cloud) * - undefined: All commands (no filtering) */ mode?: 'solo' | 'team'; } /** * Abstract base class for slash command profiles. * * Each profile encapsulates its own formatting logic, directory structure, * and any profile-specific transformations. This follows SOLID principles: * - Single Responsibility: Each profile handles only its own formatting * - Open/Closed: Add new profiles without modifying existing code * - Liskov Substitution: All profiles are interchangeable via base class * - Interface Segregation: Base class defines minimal interface * - Dependency Inversion: Consumers depend on abstraction, not concrete profiles * * @example * ```ts * import { CursorProfile } from '@tm/profiles'; * import { allCommands } from '@tm/profiles'; * * const cursor = new CursorProfile(); * cursor.addSlashCommands('/path/to/project', allCommands); * ``` */ export abstract class BaseSlashCommandProfile { /** Profile identifier (lowercase, e.g., 'claude', 'cursor') */ abstract readonly name: string; /** Display name for UI/logging (e.g., 'Claude Code', 'Cursor') */ abstract readonly displayName: string; /** Commands directory relative to project root (e.g., '.claude/commands') */ abstract readonly commandsDir: string; /** File extension for command files (e.g., '.md') */ abstract readonly extension: string; /** * Whether this profile supports nested command directories. * - true: Commands go in a subdirectory (e.g., `.claude/commands/tm/help.md`) * - false: Commands use a prefix (e.g., `.opencode/command/tm-help.md`) * * Override in profiles that don't support nested directories. */ readonly supportsNestedCommands: boolean = true; /** * Check if this profile supports slash commands. * Profiles with empty commandsDir do not support commands. */ get supportsCommands(): boolean { return this.commandsDir !== ''; } /** * Format a single command for this profile. * Each profile implements its own formatting logic. * * @param command - The slash command to format * @returns Formatted command ready to write to file */ abstract format(command: SlashCommand): FormattedSlashCommand; /** * Format all commands for this profile. * * @param commands - Array of slash commands to format * @returns Array of formatted commands */ formatAll(commands: SlashCommand[]): FormattedSlashCommand[] { return commands.map((cmd) => this.format(cmd)); } /** * Get the full filename for a command. * - Nested profiles: `commandName.md` (goes in tm/ subdirectory) * - Flat profiles: `tm-commandName.md` (uses prefix) * * @param commandName - The command name (without extension) * @returns Full filename with extension */ getFilename(commandName: string): string { if (this.supportsNestedCommands) { return `${commandName}${this.extension}`; } return `${TM_NAMESPACE}-${commandName}${this.extension}`; } /** * Transform the argument placeholder if needed. * Override in profiles that use different placeholder syntax. * * @param content - The command content * @returns Content with transformed placeholders */ transformArgumentPlaceholder(content: string): string { return content; // Default: no transformation ($ARGUMENTS stays as-is) } /** * Hook for additional post-processing after formatting. * Override for profile-specific transformations. * * @param content - The formatted content * @returns Post-processed content */ postProcess(content: string): string { return content; } /** * Get the absolute path to the commands directory for a project. * - Nested profiles: Returns `projectRoot/commandsDir/tm/` * - Flat profiles: Returns `projectRoot/commandsDir/` * * @param projectRoot - Absolute path to the project root * @returns Absolute path to the commands directory */ getCommandsPath(projectRoot: string): string { if (this.supportsNestedCommands) { return path.join(projectRoot, this.commandsDir, TM_NAMESPACE); } return path.join(projectRoot, this.commandsDir); } /** * Add slash commands to a project. * * Formats and writes all provided commands to the profile's commands directory. * Creates the directory if it doesn't exist. * * @param projectRoot - Absolute path to the project root * @param commands - Array of slash commands to add * @param options - Options including mode filtering * @returns Result of the operation * * @example * ```ts * const cursor = new CursorProfile(); * // Add all commands * const result = cursor.addSlashCommands('/path/to/project', allCommands); * * // Add only solo mode commands * const soloResult = cursor.addSlashCommands('/path/to/project', allCommands, { mode: 'solo' }); * * // Add only team mode commands (exclusive) * const teamResult = cursor.addSlashCommands('/path/to/project', allCommands, { mode: 'team' }); * ``` */ addSlashCommands( projectRoot: string, commands: SlashCommand[], options?: AddSlashCommandsOptions ): SlashCommandResult { const commandsPath = this.getCommandsPath(projectRoot); const files: string[] = []; if (!this.supportsCommands) { return { success: false, count: 0, directory: commandsPath, files: [], error: `Profile "${this.name}" does not support slash commands` }; } try { // When mode is specified, first remove ALL existing TaskMaster commands // to ensure clean slate (prevents orphaned commands when switching modes) if (options?.mode) { this.removeSlashCommands(projectRoot, commands, false); } // Filter commands by mode if specified const filteredCommands = options?.mode ? filterCommandsByMode(commands, options.mode) : commands; // Ensure directory exists if (!fs.existsSync(commandsPath)) { fs.mkdirSync(commandsPath, { recursive: true }); } // Format and write each command const formatted = this.formatAll(filteredCommands); for (const output of formatted) { const filePath = path.join(commandsPath, output.filename); fs.writeFileSync(filePath, output.content); files.push(output.filename); } return { success: true, count: files.length, directory: commandsPath, files }; } catch (err) { return { success: false, count: 0, directory: commandsPath, files: [], error: err instanceof Error ? err.message : String(err) }; } } /** * Remove slash commands from a project. * * Removes only the commands that match the provided command names. * Preserves user's custom commands that are not in the list. * Optionally removes the directory if empty after removal. * * @param projectRoot - Absolute path to the project root * @param commands - Array of slash commands to remove (matches by name) * @param removeEmptyDir - Whether to remove the directory if empty (default: true) * @returns Result of the operation * * @example * ```ts * const cursor = new CursorProfile(); * const result = cursor.removeSlashCommands('/path/to/project', allCommands); * console.log(`Removed ${result.count} commands`); * ``` */ removeSlashCommands( projectRoot: string, commands: SlashCommand[], removeEmptyDir: boolean = true ): SlashCommandResult { const commandsPath = this.getCommandsPath(projectRoot); const files: string[] = []; if (!this.supportsCommands) { return { success: false, count: 0, directory: commandsPath, files: [], error: `Profile "${this.name}" does not support slash commands` }; } if (!fs.existsSync(commandsPath)) { return { success: true, count: 0, directory: commandsPath, files: [] }; } try { // Get command names to remove (with appropriate prefix for flat profiles) const commandNames = new Set( commands.map((cmd) => { const name = cmd.metadata.name.toLowerCase(); // For flat profiles, filenames have tm- prefix return this.supportsNestedCommands ? name : `${TM_NAMESPACE}-${name}`; }) ); // Get all files in directory const existingFiles = fs.readdirSync(commandsPath); for (const file of existingFiles) { const baseName = path.basename(file, path.extname(file)).toLowerCase(); // Only remove files that match our command names if (commandNames.has(baseName)) { const filePath = path.join(commandsPath, file); fs.rmSync(filePath, { force: true }); files.push(file); } } // Remove directory if empty and requested if (removeEmptyDir) { const remainingFiles = fs.readdirSync(commandsPath); if (remainingFiles.length === 0) { fs.rmSync(commandsPath, { recursive: true, force: true }); } } return { success: true, count: files.length, directory: commandsPath, files }; } catch (err) { return { success: false, count: files.length, directory: commandsPath, files, error: err instanceof Error ? err.message : String(err) }; } } /** * Replace slash commands for a new operating mode. * * Removes all existing TaskMaster commands and adds commands for the new mode. * This is useful when switching between solo and team modes. * * @param projectRoot - Absolute path to the project root * @param commands - Array of all slash commands (will be filtered by mode) * @param newMode - The new operating mode to switch to * @returns Result of the operation * * @example * ```ts * const cursor = new CursorProfile(); * // Switch from solo to team mode * const result = cursor.replaceSlashCommands('/path/to/project', allCommands, 'team'); * ``` */ replaceSlashCommands( projectRoot: string, commands: SlashCommand[], newMode: 'solo' | 'team' ): SlashCommandResult { // Remove all existing TaskMaster commands const removeResult = this.removeSlashCommands(projectRoot, commands); if (!removeResult.success) { return removeResult; } // Add commands for the new mode return this.addSlashCommands(projectRoot, commands, { mode: newMode }); } }

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