feat: add custom slash commands support (#384)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
259
src/cli/commands/custom.ts
Normal file
259
src/cli/commands/custom.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user