feat: source aliases in bash mode (#604)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
230
src/cli/helpers/shellAliases.ts
Normal file
230
src/cli/helpers/shellAliases.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user