Files
letta-code/src/tools/impl/shellEnv.ts

258 lines
7.7 KiB
TypeScript

/**
* Shell environment utilities
* Provides enhanced environment variables for shell execution,
* including bundled tools like ripgrep in PATH and Letta context for skill scripts.
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { getServerUrl } from "../../agent/client";
import { getCurrentAgentId } from "../../agent/context";
import { getMemoryFilesystemRoot } from "../../agent/memoryFilesystem";
import { settingsManager } from "../../settings-manager";
/**
* Get the directory containing the bundled ripgrep binary.
* Returns undefined if @vscode/ripgrep is not installed.
*/
function getRipgrepBinDir(): string | undefined {
try {
const __filename = fileURLToPath(import.meta.url);
const require = createRequire(__filename);
const rgPackage = require("@vscode/ripgrep");
// rgPath is the full path to the binary, we want the directory
return path.dirname(rgPackage.rgPath);
} catch (_error) {
return undefined;
}
}
/**
* Get the node_modules directory containing this package's dependencies.
* Skill scripts use createRequire with NODE_PATH to resolve dependencies.
*/
function getPackageNodeModulesDir(): string | undefined {
try {
const __filename = fileURLToPath(import.meta.url);
const require = createRequire(__filename);
// Find where letta-client is installed
const clientPath = require.resolve("@letta-ai/letta-client");
// Extract node_modules path: /a/b/node_modules/@letta-ai/letta-client/... -> /a/b/node_modules
const match = clientPath.match(/^(.+[/\\]node_modules)[/\\]/);
return match ? match[1] : undefined;
} catch {
return undefined;
}
}
interface LettaInvocation {
command: string;
args: string[];
}
const LETTA_BIN_ARGS_ENV = "LETTA_CODE_BIN_ARGS_JSON";
function normalizeInvocationCommand(raw: string | undefined): string | null {
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
const wrappedInDoubleQuotes =
trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"');
const wrappedInSingleQuotes =
trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'");
const normalized =
wrappedInDoubleQuotes || wrappedInSingleQuotes
? trimmed.slice(1, -1).trim()
: trimmed;
return normalized || null;
}
function parseInvocationArgs(raw: string | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (
Array.isArray(parsed) &&
parsed.every((item) => typeof item === "string")
) {
return parsed;
}
} catch {
// Ignore malformed JSON and fall back to empty args.
}
return [];
}
function isDevLettaEntryScript(scriptPath: string): boolean {
const normalized = scriptPath.replaceAll("\\", "/");
return normalized.endsWith("/src/index.ts");
}
export function resolveLettaInvocation(
env: NodeJS.ProcessEnv = process.env,
argv: string[] = process.argv,
execPath: string = process.execPath,
): LettaInvocation | null {
const explicitBin = normalizeInvocationCommand(env.LETTA_CODE_BIN);
if (explicitBin) {
return {
command: explicitBin,
args: parseInvocationArgs(env[LETTA_BIN_ARGS_ENV]),
};
}
const scriptPath = argv[1] || "";
if (scriptPath && isDevLettaEntryScript(scriptPath)) {
return { command: execPath, args: [scriptPath] };
}
return null;
}
function shellEscape(arg: string): string {
return `'${arg.replaceAll("'", `'"'"'`)}'`;
}
export function ensureLettaShimDir(invocation: LettaInvocation): string | null {
if (!invocation.command) return null;
const shimDir = path.join(tmpdir(), "letta-code-shell-shim");
mkdirSync(shimDir, { recursive: true });
if (process.platform === "win32") {
const cmdPath = path.join(shimDir, "letta.cmd");
const quotedCommand = `"${invocation.command.replaceAll('"', '""')}"`;
const quotedArgs = invocation.args
.map((arg) => `"${arg.replaceAll('"', '""')}"`)
.join(" ");
writeFileSync(
cmdPath,
`@echo off\r\n${quotedCommand} ${quotedArgs} %*\r\n`,
);
return shimDir;
}
const shimPath = path.join(shimDir, "letta");
const commandWithArgs = [invocation.command, ...invocation.args]
.map(shellEscape)
.join(" ");
writeFileSync(shimPath, `#!/bin/sh\nexec ${commandWithArgs} "$@"\n`, {
mode: 0o755,
});
return shimDir;
}
/**
* Get enhanced environment variables for shell execution.
* Includes bundled tools (like ripgrep) in PATH and Letta context for skill scripts.
*/
export function getShellEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
const pathKey =
Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH";
const pathPrefixes: string[] = [];
const lettaInvocation = resolveLettaInvocation(env);
if (lettaInvocation) {
env.LETTA_CODE_BIN = lettaInvocation.command;
env[LETTA_BIN_ARGS_ENV] = JSON.stringify(lettaInvocation.args);
const shimDir = ensureLettaShimDir(lettaInvocation);
if (shimDir) {
pathPrefixes.push(shimDir);
}
}
// Add ripgrep bin directory to PATH if available
const rgBinDir = getRipgrepBinDir();
if (rgBinDir) {
pathPrefixes.push(rgBinDir);
}
if (pathPrefixes.length > 0) {
const existingPath = env[pathKey] || "";
env[pathKey] = existingPath
? `${pathPrefixes.join(path.delimiter)}${path.delimiter}${existingPath}`
: pathPrefixes.join(path.delimiter);
}
// Add Letta context for skill scripts.
// Prefer explicit agent context, but fall back to inherited env values.
let agentId: string | undefined;
try {
const resolvedAgentId = getCurrentAgentId();
if (typeof resolvedAgentId === "string" && resolvedAgentId.trim()) {
agentId = resolvedAgentId.trim();
}
} catch {
// Context not set yet (e.g., during startup), try env fallback below.
}
if (!agentId) {
const fallbackAgentId = env.AGENT_ID || env.LETTA_AGENT_ID;
if (typeof fallbackAgentId === "string" && fallbackAgentId.trim()) {
agentId = fallbackAgentId.trim();
}
}
if (agentId) {
env.LETTA_AGENT_ID = agentId;
env.AGENT_ID = agentId;
try {
if (settingsManager.isMemfsEnabled(agentId)) {
const memoryDir = getMemoryFilesystemRoot(agentId);
env.LETTA_MEMORY_DIR = memoryDir;
env.MEMORY_DIR = memoryDir;
} else {
// Clear inherited/stale memory-dir vars for non-memfs agents.
delete env.LETTA_MEMORY_DIR;
delete env.MEMORY_DIR;
}
} catch {
// Settings may not be initialized in tests/startup; preserve inherited values.
}
}
// Inject API key and base URL from settings if not already in env
if (!env.LETTA_API_KEY || !env.LETTA_BASE_URL) {
try {
const settings = settingsManager.getSettings();
if (!env.LETTA_API_KEY && settings.env?.LETTA_API_KEY) {
env.LETTA_API_KEY = settings.env.LETTA_API_KEY;
}
if (!env.LETTA_BASE_URL) {
env.LETTA_BASE_URL = getServerUrl();
}
} catch {
// Settings not initialized yet, skip
}
}
// Add NODE_PATH for skill scripts to resolve @letta-ai/letta-client
// ES modules don't respect NODE_PATH, but createRequire does
const nodeModulesDir = getPackageNodeModulesDir();
if (nodeModulesDir) {
const currentNodePath = env.NODE_PATH || "";
env.NODE_PATH = currentNodePath
? `${nodeModulesDir}${path.delimiter}${currentNodePath}`
: nodeModulesDir;
}
// Disable interactive pagers (fixes git log, man, etc. hanging)
env.PAGER = "cat";
env.GIT_PAGER = "cat";
env.MANPAGER = "cat";
// Ensure TERM is set for proper color support
if (!env.TERM) {
env.TERM = "xterm-256color";
}
return env;
}