π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes File, pathExists, MAX_OUTPUT_SIZE, readFileSafe, and getFileModificationTime (and more) β mainly functions, hooks, or classes. Dependencies touch Node filesystem, Node OS/process metadata, Node path helpers, and src. It composes internal code from services, utils, debug, errors, and fileRead (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import { chmodSync, writeFileSync as fsWriteFileSync } from 'fs' import { realpath, stat } from 'fs/promises' import { homedir } from 'os' import { basename,
π€ Exports (heuristic)
FilepathExistsMAX_OUTPUT_SIZEreadFileSafegetFileModificationTimegetFileModificationTimeAsyncwriteTextContentdetectFileEncodingdetectLineEndingsconvertLeadingTabsToSpacesgetAbsoluteAndRelativePathsgetDisplayPathfindSimilarFileFILE_NOT_FOUND_CWD_NOTEsuggestPathUnderCwdisCompactLinePrefixEnabledaddLineNumbersstripLineNumberPrefixisDirEmptyreadFileSyncCachedwriteFileSyncAndFlush_DEPRECATEDgetDesktopPathisFileWithinReadSizeLimitnormalizePathForComparisonpathsEqual
π External import roots
Package roots from from "β¦" (relative paths omitted).
fsospathsrc
π₯οΈ Source preview
import { chmodSync, writeFileSync as fsWriteFileSync } from 'fs'
import { realpath, stat } from 'fs/promises'
import { homedir } from 'os'
import {
basename,
dirname,
extname,
isAbsolute,
join,
normalize,
relative,
resolve,
sep,
} from 'path'
import { logEvent } from 'src/services/analytics/index.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
import { getCwd } from '../utils/cwd.js'
import { logForDebugging } from './debug.js'
import { isENOENT, isFsInaccessible } from './errors.js'
import {
detectEncodingForResolvedPath,
detectLineEndingsForString,
type LineEndingType,
} from './fileRead.js'
import { fileReadCache } from './fileReadCache.js'
import { getFsImplementation, safeResolvePath } from './fsOperations.js'
import { logError } from './log.js'
import { expandPath } from './path.js'
import { getPlatform } from './platform.js'
export type File = {
filename: string
content: string
}
/**
* Check if a path exists asynchronously.
*/
export async function pathExists(path: string): Promise<boolean> {
try {
await stat(path)
return true
} catch {
return false
}
}
export const MAX_OUTPUT_SIZE = 0.25 * 1024 * 1024 // 0.25MB in bytes
export function readFileSafe(filepath: string): string | null {
try {
const fs = getFsImplementation()
return fs.readFileSync(filepath, { encoding: 'utf8' })
} catch (error) {
logError(error)
return null
}
}
/**
* Get the normalized modification time of a file in milliseconds.
* Uses Math.floor to ensure consistent timestamp comparisons across file operations,
* reducing false positives from sub-millisecond precision changes (e.g., from IDE
* file watchers that touch files without changing content).
*/
export function getFileModificationTime(filePath: string): number {
const fs = getFsImplementation()
return Math.floor(fs.statSync(filePath).mtimeMs)
}
/**
* Async variant of getFileModificationTime. Same floor semantics.
* Use this in async paths (getChangedFiles runs every turn on every readFileState
* entry β sync statSync there triggers the slow-operation indicator on network/
* slow disks).
*/
export async function getFileModificationTimeAsync(
filePath: string,
): Promise<number> {
const s = await getFsImplementation().stat(filePath)
return Math.floor(s.mtimeMs)
}
export function writeTextContent(
filePath: string,
content: string,
encoding: BufferEncoding,
endings: LineEndingType,
): void {
let toWrite = content
if (endings === 'CRLF') {
// Normalize any existing CRLF to LF first so a new_string that already
// contains \r\n (raw model output) doesn't become \r\r\n after the join.
toWrite = content.replaceAll('\r\n', '\n').split('\n').join('\r\n')
}
writeFileSyncAndFlush_DEPRECATED(filePath, toWrite, { encoding })
}
export function detectFileEncoding(filePath: string): BufferEncoding {
try {
const fs = getFsImplementation()
const { resolvedPath } = safeResolvePath(fs, filePath)
return detectEncodingForResolvedPath(resolvedPath)
} catch (error) {
if (isFsInaccessible(error)) {
logForDebugging(
`detectFileEncoding failed for expected reason: ${error.code}`,
{
level: 'debug',
},
)
} else {
logError(error)
}
return 'utf8'
}
}
export function detectLineEndings(
filePath: string,
encoding: BufferEncoding = 'utf8',
): LineEndingType {
try {
const fs = getFsImplementation()
const { resolvedPath } = safeResolvePath(fs, filePath)
const { buffer, bytesRead } = fs.readSync(resolvedPath, { length: 4096 })
const content = buffer.toString(encoding, 0, bytesRead)
return detectLineEndingsForString(content)
} catch (error) {
logError(error)
return 'LF'
}
}
export function convertLeadingTabsToSpaces(content: string): string {
// The /gm regex scans every line even on no-match; skip it entirely
// for the common tab-free case.
if (!content.includes('\t')) return content
return content.replace(/^\t+/gm, _ => ' '.repeat(_.length))
}
export function getAbsoluteAndRelativePaths(path: string | undefined): {
absolutePath: string | undefined
relativePath: string | undefined
} {
const absolutePath = path ? expandPath(path) : undefined
const relativePath = absolutePath
? relative(getCwd(), absolutePath)
: undefined
return { absolutePath, relativePath }
}
export function getDisplayPath(filePath: string): string {
// Use relative path if file is in the current working directory
const { relativePath } = getAbsoluteAndRelativePaths(filePath)
if (relativePath && !relativePath.startsWith('..')) {
return relativePath
}
// Use tilde notation for files in home directory
const homeDir = homedir()
if (filePath.startsWith(homeDir + sep)) {
return '~' + filePath.slice(homeDir.length)
}
// Otherwise return the absolute path
return filePath
}
/**
* Find files with the same name but different extensions in the same directory
* @param filePath The path to the file that doesn't exist
* @returns The found file with a different extension, or undefined if none found
*/
export function findSimilarFile(filePath: string): string | undefined {
const fs = getFsImplementation()
try {
const dir = dirname(filePath)
const fileBaseName = basename(filePath, extname(filePath))
// Get all files in the directory
const files = fs.readdirSync(dir)
// Find files with the same base name but different extension
const similarFiles = files.filter(
file =>
basename(file.name, extname(file.name)) === fileBaseName &&
join(dir, file.name) !== filePath,
)
// Return just the filename of the first match if found
const firstMatch = similarFiles[0]
if (firstMatch) {
return firstMatch.name
}
return undefined
} catch (error) {
// Missing dir (ENOENT) is expected; for other errors log and return undefined
if (!isENOENT(error)) {
logError(error)
}
return undefined
}
}
/**
* Marker included in file-not-found error messages that contain a cwd note.
* UI renderers check for this to show a short "File not found" message.
*/
export const FILE_NOT_FOUND_CWD_NOTE = 'Note: your current working directory is'
/**
* Suggests a corrected path under the current working directory when a file/directory
* is not found. Detects the "dropped repo folder" pattern where the model constructs
* an absolute path missing the repo directory component.
*
* Example:
* cwd = /Users/zeeg/src/currentRepo
* requestedPath = /Users/zeeg/src/foobar (doesn't exist)
* returns /Users/zeeg/src/currentRepo/foobar (if it exists)
*
* @param requestedPath - The absolute path that was not found
* @returns The corrected path if found under cwd, undefined otherwise
*/
export async function suggestPathUnderCwd(
requestedPath: string,
): Promise<string | undefined> {
const cwd = getCwd()
const cwdParent = dirname(cwd)
// Resolve symlinks in the requested path's parent directory (e.g., /tmp -> /private/tmp on macOS)
// so the prefix comparison works correctly against the cwd (which is already realpath-resolved).
let resolvedPath = requestedPath
try {
const resolvedDir = await realpath(dirname(requestedPath))
resolvedPath = join(resolvedDir, basename(requestedPath))
} catch {
// Parent directory doesn't exist, use the original path
}
// Only check if the requested path is under cwd's parent but not under cwd itself.
// When cwdParent is the root directory (e.g., '/'), use it directly as the prefix
// to avoid a double-separator '//' that would never match.
const cwdParentPrefix = cwdParent === sep ? sep : cwdParent + sep
if (
!resolvedPath.startsWith(cwdParentPrefix) ||
resolvedPath.startsWith(cwd + sep) ||
resolvedPath === cwd
) {
return undefined
}
// Get the relative path from the parent directory
const relFromParent = relative(cwdParent, resolvedPath)
// Check if the same relative path exists under cwd
const correctedPath = join(cwd, relFromParent)
try {
await stat(correctedPath)
return correctedPath
} catch {
return undefined
}
}
/**
* Whether to use the compact line-number prefix format (`N\t` instead of
* ` Nβ`). The padded-arrow format costs 9 bytes/line overhead; at
* 1.35B Read calls Γ 132 lines avg this is 2.18% of fleet uncached input
* (bq-queries/read_line_prefix_overhead_verify.sql).
*
* Ant soak validated no Edit error regression (6.29% vs 6.86% baseline).
* Killswitch pattern: GB can disable if issues surface externally.
*/
export function isCompactLinePrefixEnabled(): boolean {
// 3P default: killswitch off = compact format enabled. Client-side only β
// no server support needed, safe for Bedrock/Vertex/Foundry.
return !getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_compact_line_prefix_killswitch',
false,
)
}
/**
* Adds cat -n style line numbers to the content.
*/
export function addLineNumbers({
content,
// 1-indexed
startLine,
}: {
content: string
startLine: number
}): string {
if (!content) {
return ''
}
const lines = content.split(/\r?\n/)
if (isCompactLinePrefixEnabled()) {
return lines
.map((line, index) => `${index + startLine}\t${line}`)
.join('\n')
}
return lines
.map((line, index) => {
const numStr = String(index + startLine)
if (numStr.length >= 6) {
return `${numStr}β${line}`
}
return `${numStr.padStart(6, ' ')}β${line}`
})
.join('\n')
}
/**
* Inverse of addLineNumbers β strips the `Nβ` or `N\t` prefix from a single
* line. Co-located so format changes here and in addLineNumbers stay in sync.
*/
export function stripLineNumberPrefix(line: string): string {
const match = line.match(/^\s*\d+[\u2192\t](.*)$/)
return match?.[1] ?? line
}
/**
* Checks if a directory is empty.
* @param dirPath The path to the directory to check
* @returns true if the directory is empty or does not exist, false otherwise
*/
export function isDirEmpty(dirPath: string): boolean {
try {
return getFsImplementation().isDirEmptySync(dirPath)
} catch (e) {
// ENOENT: directory doesn't exist, consider it empty
// Other errors (EPERM on macOS protected folders, etc.): assume not empty
return isENOENT(e)
}
}
/**
* Reads a file with caching to avoid redundant I/O operations.
* This is the preferred method for FileEditTool operations.
*/
export function readFileSyncCached(filePath: string): string {
const { content } = fileReadCache.readFile(filePath)
return content
}
/**
* Writes to a file and flushes the file to disk
* @param filePath The path to the file to write to
* @param content The content to write to the file
* @param options Options for writing the file, including encoding and mode
* @deprecated Use `fs.promises.writeFile` with flush option instead for non-blocking writes.
* Sync file writes block the event loop and cause performance issues.
*/
export function writeFileSyncAndFlush_DEPRECATED(
filePath: string,
content: string,
options: { encoding: BufferEncoding; mode?: number } = { encoding: 'utf-8' },
): void {
const fs = getFsImplementation()
// Check if the target file is a symlink to preserve it for all users
// Note: We don't use safeResolvePath here because we need to manually handle
// symlinks to ensure we write to the target while preserving the symlink itself
let targetPath = filePath
try {
// Try to read the symlink - if successful, it's a symlink
const linkTarget = fs.readlinkSync(filePath)
// Resolve to absolute path
targetPath = isAbsolute(linkTarget)
? linkTarget
: resolve(dirname(filePath), linkTarget)
logForDebugging(`Writing through symlink: ${filePath} -> ${targetPath}`)
} catch {
// ENOENT (doesn't exist) or EINVAL (not a symlink) β keep targetPath = filePath
}
// Try atomic write first
const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}`
// Check if target file exists and get its permissions (single stat, reused in both atomic and fallback paths)
let targetMode: number | undefined
let targetExists = false
try {
targetMode = fs.statSync(targetPath).mode
targetExists = true
logForDebugging(`Preserving file permissions: ${targetMode.toString(8)}`)
} catch (e) {
if (!isENOENT(e)) throw e
if (options.mode !== undefined) {
// Use provided mode for new files
targetMode = options.mode
logForDebugging(
`Setting permissions for new file: ${targetMode.toString(8)}`,
)
}
}
try {
logForDebugging(`Writing to temp file: ${tempPath}`)
// Write to temp file with flush and mode (if specified for new file)
const writeOptions: {
encoding: BufferEncoding
flush: boolean
mode?: number
} = {
encoding: options.encoding,
flush: true,
}
// Only set mode in writeFileSync for new files to ensure atomic permission setting
if (!targetExists && options.mode !== undefined) {
writeOptions.mode = options.mode
}
fsWriteFileSync(tempPath, content, writeOptions)
logForDebugging(
`Temp file written successfully, size: ${content.length} bytes`,
)
// For existing files or if mode was not set atomically, apply permissions
if (targetExists && targetMode !== undefined) {
chmodSync(tempPath, targetMode)
logForDebugging(`Applied original permissions to temp file`)
}
// Atomic rename (on POSIX systems, this is atomic)
// On Windows, this will overwrite the destination if it exists
logForDebugging(`Renaming ${tempPath} to ${targetPath}`)
fs.renameSync(tempPath, targetPath)
logForDebugging(`File ${targetPath} written atomically`)
} catch (atomicError) {
logForDebugging(`Failed to write file atomically: ${atomicError}`, {
level: 'error',
})
logEvent('tengu_atomic_write_error', {})
// Clean up temp file on error
try {
logForDebugging(`Cleaning up temp file: ${tempPath}`)
fs.unlinkSync(tempPath)
} catch (cleanupError) {
logForDebugging(`Failed to clean up temp file: ${cleanupError}`)
}
// Fallback to non-atomic write
logForDebugging(`Falling back to non-atomic write for ${targetPath}`)
try {
const fallbackOptions: {
encoding: BufferEncoding
flush: boolean
mode?: number
} = {
encoding: options.encoding,
flush: true,
}
// Only set mode for new files
if (!targetExists && options.mode !== undefined) {
fallbackOptions.mode = options.mode
}
fsWriteFileSync(targetPath, content, fallbackOptions)
logForDebugging(
`File ${targetPath} written successfully with non-atomic fallback`,
)
} catch (fallbackError) {
logForDebugging(`Non-atomic write also failed: ${fallbackError}`)
throw fallbackError
}
}
}
export function getDesktopPath(): string {
const platform = getPlatform()
const homeDir = homedir()
if (platform === 'macos') {
return join(homeDir, 'Desktop')
}
if (platform === 'windows') {
// For WSL, try to access Windows desktop
const windowsHome = process.env.USERPROFILE
? process.env.USERPROFILE.replace(/\\/g, '/')
: null
if (windowsHome) {
const wslPath = windowsHome.replace(/^[A-Z]:/, '')
const desktopPath = `/mnt/c${wslPath}/Desktop`
if (getFsImplementation().existsSync(desktopPath)) {
return desktopPath
}
}
// Fallback: try to find desktop in typical Windows user location
try {
const usersDir = '/mnt/c/Users'
const userDirs = getFsImplementation().readdirSync(usersDir)
for (const user of userDirs) {
if (
user.name === 'Public' ||
user.name === 'Default' ||
user.name === 'Default User' ||
user.name === 'All Users'
) {
continue
}
const potentialDesktopPath = join(usersDir, user.name, 'Desktop')
if (getFsImplementation().existsSync(potentialDesktopPath)) {
return potentialDesktopPath
}
}
} catch (error) {
logError(error)
}
}
// Linux/unknown platform fallback
const desktopPath = join(homeDir, 'Desktop')
if (getFsImplementation().existsSync(desktopPath)) {
return desktopPath
}
// If Desktop folder doesn't exist, fallback to home directory
return homeDir
}
/**
* Validates that a file size is within the specified limit.
* Returns true if the file is within the limit, false otherwise.
*
* @param filePath The path to the file to validate
* @param maxSizeBytes The maximum allowed file size in bytes
* @returns true if file size is within limit, false otherwise
*/
export function isFileWithinReadSizeLimit(
filePath: string,
maxSizeBytes: number = MAX_OUTPUT_SIZE,
): boolean {
try {
const stats = getFsImplementation().statSync(filePath)
return stats.size <= maxSizeBytes
} catch {
// If we can't stat the file, return false to indicate validation failure
return false
}
}
/**
* Normalize a file path for comparison, handling platform differences.
* On Windows, normalizes path separators and converts to lowercase for
* case-insensitive comparison.
*/
export function normalizePathForComparison(filePath: string): string {
// Use path.normalize() to clean up redundant separators and resolve . and ..
let normalized = normalize(filePath)
// On Windows, normalize for case-insensitive comparison:
// - Convert forward slashes to backslashes (path.normalize only does this on actual Windows)
// - Convert to lowercase (Windows paths are case-insensitive)
if (getPlatform() === 'windows') {
normalized = normalized.replace(/\//g, '\\').toLowerCase()
}
return normalized
}
/**
* Compare two file paths for equality, handling Windows case-insensitivity.
*/
export function pathsEqual(path1: string, path2: string): boolean {
return normalizePathForComparison(path1) === normalizePathForComparison(path2)
}