From 552a006e97e8d1d68d9b929ea5c56a00bc60c985 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Wed, 24 Dec 2025 14:56:42 -0800 Subject: [PATCH] feat: add custom slash commands support (#384) Co-authored-by: Letta --- src/cli/App.tsx | 73 +++++ src/cli/commands/custom.ts | 259 ++++++++++++++++++ src/cli/components/HelpDialog.tsx | 28 +- .../components/SlashCommandAutocomplete.tsx | 79 ++++-- 4 files changed, 405 insertions(+), 34 deletions(-) create mode 100644 src/cli/commands/custom.ts diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 7ce533f..e314801 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -3311,6 +3311,79 @@ ${gitContext} return { submitted: true }; } + // === Custom command handling === + // Check BEFORE falling through to executeCommand() + const { findCustomCommand, substituteArguments, expandBashCommands } = + await import("./commands/custom.js"); + const commandName = trimmed.split(/\s+/)[0]?.slice(1) || ""; // e.g., "review" from "/review arg" + const matchedCustom = await findCustomCommand(commandName); + + if (matchedCustom) { + const cmdId = uid("cmd"); + + // Extract arguments (everything after command name) + const args = trimmed.slice(`/${matchedCustom.id}`.length).trim(); + + // Build prompt: 1) substitute args, 2) expand bash commands + let prompt = substituteArguments(matchedCustom.content, args); + prompt = await expandBashCommands(prompt); + + // Show command in transcript (running phase for visual feedback) + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: trimmed, + output: `Running /${matchedCustom.id}...`, + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + + setCommandRunning(true); + + try { + // Mark command as finished BEFORE sending to agent + // (matches /remember pattern - command succeeded in triggering agent) + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: trimmed, + output: `Running custom command...`, + phase: "finished", + success: true, + }); + refreshDerived(); + + // Send prompt to agent + // NOTE: Unlike /remember, we DON'T append args separately because + // they're already substituted into the prompt via $ARGUMENTS + await processConversation([ + { + type: "message", + role: "user", + content: `\n${prompt}\n`, + }, + ]); + } catch (error) { + // Only catch errors from processConversation setup, not agent execution + const errorDetails = formatErrorDetails(error, agentId); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: trimmed, + output: `Failed to run command: ${errorDetails}`, + phase: "finished", + success: false, + }); + refreshDerived(); + } finally { + setCommandRunning(false); + } + + return { submitted: true }; + } + // === END custom command handling === + // Immediately add command to transcript with "running" phase const cmdId = uid("cmd"); buffersRef.current.byId.set(cmdId, { diff --git a/src/cli/commands/custom.ts b/src/cli/commands/custom.ts new file mode 100644 index 0000000..7a33325 --- /dev/null +++ b/src/cli/commands/custom.ts @@ -0,0 +1,259 @@ +/** + * Custom slash commands - user-defined commands from .commands/ and ~/.letta/commands/ + */ + +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { getStringField, parseFrontmatter } from "../../utils/frontmatter.js"; + +export const COMMANDS_DIR = ".commands"; +export const GLOBAL_COMMANDS_DIR = join( + process.env.HOME || process.env.USERPROFILE || "~", + ".letta/commands", +); + +export interface CustomCommand { + id: string; // Command name without slash (e.g., "review") + description: string; // For autocomplete display + argumentHint?: string; // e.g., "[message]" shown after command in autocomplete + namespace?: string; // Subdirectory name for disambiguation + source: "project" | "user"; + path: string; // Full path to .md file + content: string; // Prompt body (after frontmatter) + // Future fields (parsed but not used in MVP): + // allowedTools?: string[]; + // model?: string; + // disableModelInvocation?: boolean; +} + +// Cached commands (lazy initialized) +let cachedCommands: CustomCommand[] | null = null; + +/** + * Get custom commands (cached after first call) + */ +export async function getCustomCommands(): Promise { + if (cachedCommands !== null) { + return cachedCommands; + } + cachedCommands = await discoverCustomCommands(); + return cachedCommands; +} + +/** + * Force refresh of cached commands + */ +export function refreshCustomCommands(): void { + cachedCommands = null; +} + +/** + * Discover custom commands from project and user directories + */ +export async function discoverCustomCommands( + projectPath: string = join(process.cwd(), COMMANDS_DIR), +): Promise { + const commandsById = new Map(); // Group by id for collision handling + + // 1. Discover user commands first (lower priority) + const userCommands = await discoverFromDirectory(GLOBAL_COMMANDS_DIR, "user"); + for (const cmd of userCommands) { + const existing = commandsById.get(cmd.id) || []; + existing.push(cmd); + commandsById.set(cmd.id, existing); + } + + // 2. Discover project commands (higher priority - may override user) + const projectCommands = await discoverFromDirectory(projectPath, "project"); + for (const cmd of projectCommands) { + const existing = commandsById.get(cmd.id) || []; + // Insert project commands at front (higher priority) + existing.unshift(cmd); + commandsById.set(cmd.id, existing); + } + + // Flatten to array - keep all commands (for namespace disambiguation) + // Note: When executing, we pick the first match (project > user) + const result: CustomCommand[] = []; + for (const [_id, cmds] of commandsById) { + result.push(...cmds); + } + + return result; +} + +/** + * Discover commands from a single directory + */ +async function discoverFromDirectory( + dirPath: string, + source: "project" | "user", +): Promise { + if (!existsSync(dirPath)) { + return []; + } + + const commands: CustomCommand[] = []; + await findCommandFiles(dirPath, dirPath, commands, source); + return commands; +} + +/** + * Recursively find .md files in directory + */ +async function findCommandFiles( + currentPath: string, + rootPath: string, + commands: CustomCommand[], + source: "project" | "user", +): Promise { + try { + const entries = await readdir(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(currentPath, entry.name); + + if (entry.isDirectory()) { + await findCommandFiles(fullPath, rootPath, commands, source); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + try { + const cmd = await parseCommandFile(fullPath, rootPath, source); + if (cmd) { + commands.push(cmd); + } + } catch (_error) { + // Silently skip malformed command files + // In future: could track errors in a separate array for debugging + } + } + } + } catch (_error) { + // Directory read failed - silently continue + // This is expected if directory doesn't exist or lacks permissions + } +} + +/** + * Parse a command markdown file + */ +async function parseCommandFile( + filePath: string, + rootPath: string, + source: "project" | "user", +): Promise { + const content = await readFile(filePath, "utf-8"); + const { frontmatter, body } = parseFrontmatter(content); + + // Derive command ID from filename (without .md extension) + const id = basename(filePath, ".md"); + + // Derive namespace from subdirectory path + const relativePath = dirname(filePath).slice(rootPath.length); + const namespace = relativePath.replace(/^[/\\]/, "") || undefined; + + // Get description from frontmatter or first line of body + let description = getStringField(frontmatter, "description"); + if (!description) { + const firstLine = body.split("\n")[0]?.trim(); + description = firstLine?.replace(/^#\s*/, "") || `Custom command: ${id}`; + } + + const argumentHint = getStringField(frontmatter, "argument-hint"); + + return { + id, + description, + argumentHint, + namespace, + source, + path: filePath, + content: body, + }; +} + +/** + * Substitute arguments in command content + */ +export function substituteArguments(content: string, args: string): string { + let result = content; + + // Replace $ARGUMENTS with all arguments + result = result.replace(/\$ARGUMENTS/g, args); + + // Replace $1, $2, ... $9 with positional arguments + const argParts = args.split(/\s+/).filter(Boolean); + for (let i = 0; i < 9; i++) { + result = result.replace(new RegExp(`\\$${i + 1}`, "g"), argParts[i] || ""); + } + + return result; +} + +/** + * Expand bash commands in content + * Replaces !`command` patterns with command output + * Uses existing spawnCommand from Bash tool for consistency + */ +export async function expandBashCommands(content: string): Promise { + // Match !`command` pattern (backticks required) + const bashPattern = /!`([^`]+)`/g; + const matches = [...content.matchAll(bashPattern)]; + + if (matches.length === 0) { + return content; + } + + // Import spawnCommand from Bash tool (same as bash mode uses) + const { spawnCommand } = await import("../../tools/impl/Bash.js"); + const { getShellEnv } = await import("../../tools/impl/shellEnv.js"); + + let result = content; + + // Execute each bash command and replace with output + for (const match of matches) { + const fullMatch = match[0]; // e.g., !`git status` + const command = match[1]; // e.g., git status + + if (!command) continue; // Skip if no capture group match + + try { + const cmdResult = await spawnCommand(command, { + cwd: process.cwd(), + env: getShellEnv(), + timeout: 10000, // 10 second timeout for inline commands + }); + + const output = (cmdResult.stdout + cmdResult.stderr).trim(); + result = result.replace(fullMatch, output); + } catch (error) { + // On error, replace with error message + const errMsg = error instanceof Error ? error.message : String(error); + result = result.replace( + fullMatch, + `[Error executing ${command}: ${errMsg}]`, + ); + } + } + + return result; +} + +/** + * Find a custom command by name (handles namespace disambiguation) + * Returns the highest priority match (project > user, then first namespace) + */ +export async function findCustomCommand( + commandName: string, // e.g., "review" or "frontend/test" +): Promise { + const commands = await getCustomCommands(); + + // First try exact id match + const exactMatches = commands.filter((cmd) => cmd.id === commandName); + if (exactMatches.length > 0) { + // Return project command if available, else user + return exactMatches.find((c) => c.source === "project") || exactMatches[0]; + } + + return undefined; +} diff --git a/src/cli/components/HelpDialog.tsx b/src/cli/components/HelpDialog.tsx index 72fe865..689a625 100644 --- a/src/cli/components/HelpDialog.tsx +++ b/src/cli/components/HelpDialog.tsx @@ -1,5 +1,5 @@ import { Box, Text, useInput } from "ink"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { getVersion } from "../../version"; import { commands } from "../commands/registry"; import { colors } from "./colors"; @@ -28,18 +28,34 @@ export function HelpDialog({ onClose }: HelpDialogProps) { const [activeTab, setActiveTab] = useState("commands"); const [currentPage, setCurrentPage] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0); + const [customCommands, setCustomCommands] = useState([]); - // Get all non-hidden commands, sorted by order + // Load custom commands once on mount + useEffect(() => { + import("../commands/custom.js").then(({ getCustomCommands }) => { + getCustomCommands().then((customs) => { + setCustomCommands( + customs.map((cmd) => ({ + name: `/${cmd.id}`, + description: `${cmd.description} (${cmd.source}${cmd.namespace ? `:${cmd.namespace}` : ""})`, + order: 200 + (cmd.source === "project" ? 0 : 100), + })), + ); + }); + }); + }, []); + + // Get all non-hidden commands, sorted by order (includes custom commands) const allCommands = useMemo(() => { - return Object.entries(commands) + const builtins = Object.entries(commands) .filter(([_, cmd]) => !cmd.hidden) .map(([name, cmd]) => ({ name, description: cmd.desc, order: cmd.order ?? 100, - })) - .sort((a, b) => a.order - b.order); - }, []); + })); + return [...builtins, ...customCommands].sort((a, b) => a.order - b.order); + }, [customCommands]); // Keyboard shortcuts const shortcuts = useMemo(() => { diff --git a/src/cli/components/SlashCommandAutocomplete.tsx b/src/cli/components/SlashCommandAutocomplete.tsx index d127d4f..efff385 100644 --- a/src/cli/components/SlashCommandAutocomplete.tsx +++ b/src/cli/components/SlashCommandAutocomplete.tsx @@ -51,37 +51,60 @@ export function SlashCommandAutocomplete({ workingDirectory = process.cwd(), }: AutocompleteProps) { const [matches, setMatches] = useState([]); + const [customCommands, setCustomCommands] = useState([]); - // Check pin status to conditionally show/hide pin/unpin commands - const allCommands = useMemo(() => { - if (!agentId) return _allCommands; - - try { - const globalPinned = settingsManager.getGlobalPinnedAgents(); - const localPinned = - settingsManager.getLocalPinnedAgents(workingDirectory); - - const isPinnedGlobally = globalPinned.includes(agentId); - const isPinnedLocally = localPinned.includes(agentId); - const isPinnedAnywhere = isPinnedGlobally || isPinnedLocally; - const isPinnedBoth = isPinnedGlobally && isPinnedLocally; - - return _allCommands.filter((cmd) => { - // Hide /pin if agent is pinned both locally AND globally - if (cmd.cmd === "/pin" && isPinnedBoth) { - return false; - } - // Hide /unpin if agent is not pinned anywhere - if (cmd.cmd === "/unpin" && !isPinnedAnywhere) { - return false; - } - return true; + // Load custom commands once on mount + useEffect(() => { + import("../commands/custom.js").then(({ getCustomCommands }) => { + getCustomCommands().then((customs) => { + const matches: CommandMatch[] = customs.map((cmd) => ({ + cmd: `/${cmd.id}`, + // Include source/namespace in description for disambiguation + desc: `${cmd.description} (${cmd.source}${cmd.namespace ? `:${cmd.namespace}` : ""})`, + order: 200 + (cmd.source === "project" ? 0 : 100), + })); + setCustomCommands(matches); }); - } catch (_error) { - // If settings aren't loaded, just show all commands - return _allCommands; + }); + }, []); + + // Check pin status to conditionally show/hide pin/unpin commands, merge with custom commands + const allCommands = useMemo(() => { + let builtins = _allCommands; + + if (agentId) { + try { + const globalPinned = settingsManager.getGlobalPinnedAgents(); + const localPinned = + settingsManager.getLocalPinnedAgents(workingDirectory); + + const isPinnedGlobally = globalPinned.includes(agentId); + const isPinnedLocally = localPinned.includes(agentId); + const isPinnedAnywhere = isPinnedGlobally || isPinnedLocally; + const isPinnedBoth = isPinnedGlobally && isPinnedLocally; + + builtins = _allCommands.filter((cmd) => { + // Hide /pin if agent is pinned both locally AND globally + if (cmd.cmd === "/pin" && isPinnedBoth) { + return false; + } + // Hide /unpin if agent is not pinned anywhere + if (cmd.cmd === "/unpin" && !isPinnedAnywhere) { + return false; + } + return true; + }); + } catch (_error) { + // If settings aren't loaded, just use all builtins + builtins = _allCommands; + } } - }, [agentId, workingDirectory]); + + // Merge with custom commands and sort by order + return [...builtins, ...customCommands].sort( + (a, b) => (a.order ?? 100) - (b.order ?? 100), + ); + }, [agentId, workingDirectory, customCommands]); const { selectedIndex } = useAutocompleteNavigation({ matches,