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,