feat: add custom slash commands support (#384)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-24 14:56:42 -08:00
committed by GitHub
parent 1f35f403c0
commit 552a006e97
4 changed files with 405 additions and 34 deletions

View File

@@ -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: `<system-reminder>\n${prompt}\n</system-reminder>`,
},
]);
} 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, {

259
src/cli/commands/custom.ts Normal file
View File

@@ -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<CustomCommand[]> {
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<CustomCommand[]> {
const commandsById = new Map<string, CustomCommand[]>(); // 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<CustomCommand[]> {
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<void> {
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<CustomCommand | null> {
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<string> {
// 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<CustomCommand | undefined> {
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;
}

View File

@@ -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<HelpTab>("commands");
const [currentPage, setCurrentPage] = useState(0);
const [selectedIndex, setSelectedIndex] = useState(0);
const [customCommands, setCustomCommands] = useState<CommandItem[]>([]);
// 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<CommandItem[]>(() => {
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<ShortcutItem[]>(() => {

View File

@@ -51,37 +51,60 @@ export function SlashCommandAutocomplete({
workingDirectory = process.cwd(),
}: AutocompleteProps) {
const [matches, setMatches] = useState<CommandMatch[]>([]);
const [customCommands, setCustomCommands] = useState<CommandMatch[]>([]);
// 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,