feat: source aliases in bash mode (#604)

This commit is contained in:
jnjpng
2026-01-20 17:29:39 -08:00
committed by GitHub
parent d34a65323c
commit dc8c0e8321
3 changed files with 243 additions and 4 deletions

View File

@@ -3433,7 +3433,7 @@ export default function App({
);
// Handle bash mode command submission
// Uses the same shell runner as the Bash tool for consistency
// Expands aliases from shell config files, then runs with spawnCommand
const handleBashSubmit = useCallback(
async (command: string) => {
const cmdId = uid("bash");
@@ -3458,11 +3458,20 @@ export default function App({
refreshDerived();
try {
// Use the same spawnCommand as the Bash tool for consistent behavior
// Expand aliases before running
const { expandAliases } = await import("./helpers/shellAliases");
const expanded = expandAliases(command);
// If command uses a shell function, prepend the function definition
const finalCommand = expanded.functionDef
? `${expanded.functionDef}\n${expanded.command}`
: expanded.command;
// Use spawnCommand for actual execution
const { spawnCommand } = await import("../tools/impl/Bash.js");
const { getShellEnv } = await import("../tools/impl/shellEnv.js");
const result = await spawnCommand(command, {
const result = await spawnCommand(finalCommand, {
cwd: process.cwd(),
env: getShellEnv(),
timeout: 30000, // 30 second timeout

View File

@@ -591,7 +591,7 @@ export function Input({
setTemporaryInput("");
setValue(""); // Clear immediately for responsiveness
setIsBashMode(false); // Exit bash mode after submitting
// Stay in bash mode - user exits with backspace on empty input
if (onBashSubmit) {
await onBashSubmit(previousValue);
}

View File

@@ -0,0 +1,230 @@
/**
* Shell alias expansion for bash mode.
* Reads aliases from common shell config files and expands them in commands.
*/
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
// Cache of parsed aliases
let aliasCache: Map<string, string> | null = null;
/**
* Common shell config files that may contain aliases
*/
const ALIAS_FILES = [
".zshrc",
".bashrc",
".bash_aliases",
".zsh_aliases",
".aliases",
".shell_aliases",
];
/**
* Parse alias and function definitions from a shell config file.
* Handles formats like:
* alias gco='git checkout'
* alias gco="git checkout"
* function_name() { ... }
*/
function parseAliasesFromFile(filePath: string): Map<string, string> {
const aliases = new Map<string, string>();
if (!existsSync(filePath)) {
return aliases;
}
try {
const content = readFileSync(filePath, "utf-8");
const lines = content.split("\n");
let inFunction = false;
let functionName = "";
let functionBody = "";
let braceDepth = 0;
for (const line of lines) {
const trimmed = line.trim();
// Track function body parsing
if (inFunction) {
functionBody += `${line}\n`;
braceDepth += (line.match(/{/g) || []).length;
braceDepth -= (line.match(/}/g) || []).length;
if (braceDepth === 0) {
// Function complete - store it
// Functions are stored with a special marker so we know to source them
aliases.set(functionName, `__LETTA_FUNC__${functionBody}`);
inFunction = false;
functionName = "";
functionBody = "";
}
continue;
}
// Skip comments and empty lines
if (trimmed.startsWith("#") || !trimmed) {
continue;
}
// Match function definitions: name() { or function name {
const funcMatch =
trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(\)\s*\{?/) ||
trimmed.match(/^function\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\{?/);
if (funcMatch && funcMatch[1]) {
functionName = funcMatch[1];
functionBody = `${line}\n`;
braceDepth =
(line.match(/{/g) || []).length - (line.match(/}/g) || []).length;
if (braceDepth > 0) {
inFunction = true;
} else if (
braceDepth === 0 &&
line.includes("{") &&
line.includes("}")
) {
// One-liner function
aliases.set(functionName, `__LETTA_FUNC__${functionBody}`);
functionName = "";
functionBody = "";
}
continue;
}
// Match alias definitions: alias name='value' or alias name="value" or alias name=value
const aliasMatch = trimmed.match(/^alias\s+([a-zA-Z0-9_-]+)=(.+)$/);
if (aliasMatch) {
const [, name, rawValue] = aliasMatch;
if (!name || !rawValue) continue;
let value = rawValue.trim();
// Remove surrounding quotes if present
if (
(value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))
) {
value = value.slice(1, -1);
}
// Unescape basic escapes
value = value.replace(/\\'/g, "'").replace(/\\"/g, '"');
if (name && value) {
aliases.set(name, value);
}
}
}
} catch (_error) {
// Silently ignore read errors
}
return aliases;
}
/**
* Load all aliases from common shell config files.
* Results are cached for performance.
*/
export function loadAliases(forceReload = false): Map<string, string> {
if (aliasCache && !forceReload) {
return aliasCache;
}
const home = homedir();
const allAliases = new Map<string, string>();
for (const file of ALIAS_FILES) {
const filePath = join(home, file);
const fileAliases = parseAliasesFromFile(filePath);
// Later files override earlier ones
for (const [name, value] of fileAliases) {
allAliases.set(name, value);
}
}
aliasCache = allAliases;
return allAliases;
}
/**
* Result of alias expansion
*/
export interface ExpandedCommand {
/** The expanded command to run */
command: string;
/** If the command uses a function, this contains the function definition to prepend */
functionDef?: string;
}
/**
* Expand aliases in a command.
* Only expands the first word if it's an alias.
* Handles recursive alias expansion (up to a limit).
* For functions, returns the function definition to prepend to the command.
*/
export function expandAliases(command: string, maxDepth = 10): ExpandedCommand {
const aliases = loadAliases();
if (aliases.size === 0) {
return { command };
}
const trimmed = command.trim();
const firstSpaceIdx = trimmed.indexOf(" ");
const firstWord =
firstSpaceIdx === -1 ? trimmed : trimmed.slice(0, firstSpaceIdx);
const rest = firstSpaceIdx === -1 ? "" : trimmed.slice(firstSpaceIdx);
const aliasValue = aliases.get(firstWord);
// Check if it's a function
if (aliasValue?.startsWith("__LETTA_FUNC__")) {
const functionDef = aliasValue.slice("__LETTA_FUNC__".length);
// Return the original command but with function def to prepend
return { command, functionDef };
}
// Regular alias expansion
if (!aliasValue) {
return { command };
}
let expanded = aliasValue + rest;
let depth = 1;
// Continue expanding if the result starts with another alias
while (depth < maxDepth) {
const expandedTrimmed = expanded.trim();
const expandedFirstSpace = expandedTrimmed.indexOf(" ");
const expandedFirstWord =
expandedFirstSpace === -1
? expandedTrimmed
: expandedTrimmed.slice(0, expandedFirstSpace);
const expandedRest =
expandedFirstSpace === -1
? ""
: expandedTrimmed.slice(expandedFirstSpace);
const nextAlias = aliases.get(expandedFirstWord);
if (!nextAlias || nextAlias.startsWith("__LETTA_FUNC__")) {
break;
}
expanded = nextAlias + expandedRest;
depth++;
}
return { command: expanded };
}
/**
* Clear the alias cache (useful for testing or when config files change)
*/
export function clearAliasCache(): void {
aliasCache = null;
}