258 lines
7.7 KiB
TypeScript
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;
|
|
}
|