From 096cd2635543759625abe77c3a4f9aacc257d8a4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 07:43:36 +0000 Subject: [PATCH 1/3] feat: implement ls_files tool for BUS-1467 - Add ls-files-tool with support for standard ls options (-l, -a, -R, -h) - Support bulk operations with proper error handling - Parse ls output into structured JSON format - Handle cross-platform concerns for Windows compatibility - Follow established Mastra tool patterns with sandbox execution - Include comprehensive unit tests for both implementation and tool Co-Authored-By: Dallin Bentley --- .../ls-files-tool/ls-files-impl.test.ts | 169 ++++++++++ .../file-tools/ls-files-tool/ls-files-impl.ts | 289 ++++++++++++++++++ .../ls-files-tool/ls-files-tool.test.ts | 175 +++++++++++ .../file-tools/ls-files-tool/ls-files-tool.ts | 197 ++++++++++++ packages/ai/src/tools/index.ts | 1 + 5 files changed, 831 insertions(+) create mode 100644 packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.test.ts create mode 100644 packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.ts create mode 100644 packages/ai/src/tools/file-tools/ls-files-tool/ls-files-tool.test.ts create mode 100644 packages/ai/src/tools/file-tools/ls-files-tool/ls-files-tool.ts diff --git a/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.test.ts b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.test.ts new file mode 100644 index 000000000..ed946d145 --- /dev/null +++ b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as child_process from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import { lsFilesSafely, generateLsCode, type LsOptions } from './ls-files-impl'; + +vi.mock('node:child_process'); +vi.mock('node:fs/promises'); + +const mockChildProcess = vi.mocked(child_process); +const mockFs = vi.mocked(fs); + +describe('ls-files-impl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('lsFilesSafely', () => { + it('should handle successful ls command execution', async () => { + mockFs.access.mockResolvedValue(undefined); + mockChildProcess.exec.mockImplementation((command, callback) => { + const cb = callback as (error: null, stdout: string, stderr: string) => void; + cb(null, 'file1.txt\nfile2.txt\n', ''); + return {} as any; + }); + + const result = await lsFilesSafely(['/test/path']); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + success: true, + path: '/test/path', + entries: [ + { name: 'file1.txt', type: 'file' }, + { name: 'file2.txt', type: 'file' }, + ], + }); + }); + + it('should handle detailed ls output parsing', async () => { + mockFs.access.mockResolvedValue(undefined); + mockChildProcess.exec.mockImplementation((command, callback) => { + const cb = callback as (error: null, stdout: string, stderr: string) => void; + const detailedOutput = `total 8 +-rw-r--r-- 1 user group 1024 Jan 15 10:30 file1.txt +drwxr-xr-x 2 user group 4096 Jan 15 10:31 directory1`; + cb(null, detailedOutput, ''); + return {} as any; + }); + + const options: LsOptions = { detailed: true }; + const result = await lsFilesSafely(['/test/path'], options); + + expect(result).toHaveLength(1); + expect(result[0].success).toBe(true); + expect(result[0].entries).toHaveLength(2); + expect(result[0].entries?.[0]).toEqual({ + name: 'file1.txt', + type: 'file', + size: '1024', + permissions: '-rw-r--r--', + modified: 'Jan 15 10:30', + owner: 'user', + group: 'group', + }); + expect(result[0].entries?.[1]).toEqual({ + name: 'directory1', + type: 'directory', + size: '4096', + permissions: 'drwxr-xr-x', + modified: 'Jan 15 10:31', + owner: 'user', + group: 'group', + }); + }); + + it('should handle path not found error', async () => { + mockFs.access.mockRejectedValue(new Error('ENOENT')); + + const result = await lsFilesSafely(['/nonexistent/path']); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + success: false, + path: '/nonexistent/path', + error: 'Path not found', + }); + }); + + it('should handle Windows platform', async () => { + mockFs.access.mockResolvedValue(undefined); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + const result = await lsFilesSafely(['/test/path']); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + success: false, + path: '/test/path', + error: 'ls command not available on Windows platform', + }); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should handle command execution error', async () => { + mockFs.access.mockResolvedValue(undefined); + mockChildProcess.exec.mockImplementation((command, callback) => { + const cb = callback as (error: Error, stdout: string, stderr: string) => void; + cb(new Error('Permission denied'), '', 'ls: cannot access'); + return {} as any; + }); + + const result = await lsFilesSafely(['/test/path']); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + success: false, + path: '/test/path', + error: 'Command failed: ls: cannot access', + }); + }); + + it('should handle multiple paths independently', async () => { + mockFs.access.mockImplementation((path) => { + if (path.toString().includes('good')) { + return Promise.resolve(undefined); + } + return Promise.reject(new Error('ENOENT')); + }); + + mockChildProcess.exec.mockImplementation((command, callback) => { + const cb = callback as (error: null, stdout: string, stderr: string) => void; + cb(null, 'file.txt\n', ''); + return {} as any; + }); + + const result = await lsFilesSafely(['/good/path', '/bad/path']); + + expect(result).toHaveLength(2); + expect(result[0].success).toBe(true); + expect(result[1].success).toBe(false); + expect(result[1].error).toBe('Path not found'); + }); + }); + + describe('generateLsCode', () => { + it('should generate valid TypeScript code for sandbox execution', () => { + const paths = ['/test/path1', '/test/path2']; + const options: LsOptions = { detailed: true, all: true }; + + const code = generateLsCode(paths, options); + + expect(code).toContain('const paths = ["/test/path1","/test/path2"]'); + expect(code).toContain('const options = {"detailed":true,"all":true}'); + expect(code).toContain('function buildLsCommand'); + expect(code).toContain('function parseDetailedLsOutput'); + expect(code).toContain('function parseSimpleLsOutput'); + expect(code).toContain('console.log(JSON.stringify(results))'); + }); + + it('should generate code that handles empty options', () => { + const paths = ['/test/path']; + const code = generateLsCode(paths); + + expect(code).toContain('const options = {}'); + }); + }); +}); diff --git a/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.ts b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.ts new file mode 100644 index 000000000..1f92c85d2 --- /dev/null +++ b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.ts @@ -0,0 +1,289 @@ +import * as child_process from 'node:child_process'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; + +export interface LsOptions { + detailed?: boolean; + all?: boolean; + recursive?: boolean; + humanReadable?: boolean; +} + +export interface LsEntry { + name: string; + type: 'file' | 'directory' | 'symlink' | 'other'; + size?: string; + permissions?: string; + modified?: string; + owner?: string; + group?: string; +} + +export interface LsResult { + success: boolean; + path: string; + entries?: LsEntry[]; + error?: string; +} + +function buildLsCommand(targetPath: string, options?: LsOptions): string { + const flags: string[] = []; + + if (options?.detailed) flags.push('-l'); + if (options?.all) flags.push('-a'); + if (options?.recursive) flags.push('-R'); + if (options?.humanReadable) flags.push('-h'); + + const flagString = flags.length > 0 ? ` ${flags.join('')}` : ''; + return `ls${flagString} "${targetPath}"`; +} + +function parseDetailedLsOutput(output: string): LsEntry[] { + const lines = output.trim().split('\n').filter(line => line.trim() !== ''); + const entries: LsEntry[] = []; + + for (const line of lines) { + if (line.startsWith('total ') || line.trim() === '') continue; + + const parts = line.trim().split(/\s+/); + if (parts.length < 9) continue; + + const permissions = parts[0]; + const owner = parts[2]; + const group = parts[3]; + const size = parts[4]; + const month = parts[5]; + const day = parts[6]; + const timeOrYear = parts[7]; + const name = parts.slice(8).join(' '); + + if (!permissions) continue; + + let type: LsEntry['type'] = 'file'; + if (permissions.startsWith('d')) type = 'directory'; + else if (permissions.startsWith('l')) type = 'symlink'; + else if (!permissions.startsWith('-')) type = 'other'; + + const modified = `${month} ${day} ${timeOrYear}`; + + const entry: LsEntry = { + name, + type, + }; + + if (size) entry.size = size; + if (permissions) entry.permissions = permissions; + if (modified) entry.modified = modified; + if (owner) entry.owner = owner; + if (group) entry.group = group; + + entries.push(entry); + } + + return entries; +} + +function parseSimpleLsOutput(output: string): LsEntry[] { + const lines = output.trim().split('\n').filter(line => line.trim() !== ''); + return lines.map(name => ({ + name: name.trim(), + type: 'file' as const, + })); +} + +async function lsSinglePath(targetPath: string, options?: LsOptions): Promise { + try { + const resolvedPath = path.isAbsolute(targetPath) ? targetPath : path.join(process.cwd(), targetPath); + + try { + await fs.access(resolvedPath); + } catch { + return { + success: false, + path: targetPath, + error: 'Path not found', + }; + } + + if (process.platform === 'win32') { + return { + success: false, + path: targetPath, + error: 'ls command not available on Windows platform', + }; + } + + const command = buildLsCommand(resolvedPath, options); + + return new Promise((resolve) => { + child_process.exec(command, (error, stdout, stderr) => { + if (error) { + resolve({ + success: false, + path: targetPath, + error: `Command failed: ${stderr || error.message}`, + }); + return; + } + + try { + const entries = options?.detailed + ? parseDetailedLsOutput(stdout) + : parseSimpleLsOutput(stdout); + + resolve({ + success: true, + path: targetPath, + entries, + }); + } catch (parseError) { + resolve({ + success: false, + path: targetPath, + error: `Failed to parse ls output: ${parseError instanceof Error ? parseError.message : 'Unknown parse error'}`, + }); + } + }); + }); + } catch (error) { + return { + success: false, + path: targetPath, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } +} + +export async function lsFilesSafely(paths: string[], options?: LsOptions): Promise { + const lsPromises = paths.map((targetPath) => lsSinglePath(targetPath, options)); + return Promise.all(lsPromises); +} + +export function generateLsCode(paths: string[], options?: LsOptions): string { + return ` +const child_process = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +function buildLsCommand(targetPath: string, options?: any): string { + const flags: string[] = []; + + if (options?.detailed) flags.push('-l'); + if (options?.all) flags.push('-a'); + if (options?.recursive) flags.push('-R'); + if (options?.humanReadable) flags.push('-h'); + + const flagString = flags.length > 0 ? \` \${flags.join('')}\` : ''; + return \`ls\${flagString} "\${targetPath}"\`; +} + +function parseDetailedLsOutput(output: string) { + const lines = output.trim().split('\\n').filter((line: string) => line.trim() !== ''); + const entries: any[] = []; + + for (const line of lines) { + if (line.startsWith('total ') || line.trim() === '') continue; + + const parts = line.trim().split(/\\s+/); + if (parts.length < 9) continue; + + const permissions = parts[0]; + const owner = parts[2]; + const group = parts[3]; + const size = parts[4]; + const month = parts[5]; + const day = parts[6]; + const timeOrYear = parts[7]; + const name = parts.slice(8).join(' '); + + let type = 'file'; + if (permissions.startsWith('d')) type = 'directory'; + else if (permissions.startsWith('l')) type = 'symlink'; + else if (!permissions.startsWith('-')) type = 'other'; + + const modified = \`\${month} \${day} \${timeOrYear}\`; + + entries.push({ + name, + type, + size, + permissions, + modified, + owner, + group, + }); + } + + return entries; +} + +function parseSimpleLsOutput(output: string) { + const lines = output.trim().split('\\n').filter((line: string) => line.trim() !== ''); + return lines.map((name: string) => ({ + name: name.trim(), + type: 'file', + })); +} + +function lsSinglePath(targetPath: string, options?: any) { + try { + const resolvedPath = path.isAbsolute(targetPath) ? targetPath : path.join(process.cwd(), targetPath); + + try { + fs.accessSync(resolvedPath); + } catch { + return { + success: false, + path: targetPath, + error: 'Path not found', + }; + } + + if (process.platform === 'win32') { + return { + success: false, + path: targetPath, + error: 'ls command not available on Windows platform', + }; + } + + const command = buildLsCommand(resolvedPath, options); + + try { + const stdout = child_process.execSync(command, { encoding: 'utf8' }); + + const entries = options?.detailed + ? parseDetailedLsOutput(stdout) + : parseSimpleLsOutput(stdout); + + return { + success: true, + path: targetPath, + entries, + }; + } catch (error: any) { + return { + success: false, + path: targetPath, + error: \`Command failed: \${error.message}\`, + }; + } + } catch (error: any) { + return { + success: false, + path: targetPath, + error: error.message || 'Unknown error occurred', + }; + } +} + +function lsFilesConcurrently(paths: string[], options?: any) { + return paths.map((targetPath: string) => lsSinglePath(targetPath, options)); +} + +const paths = ${JSON.stringify(paths)}; +const options = ${JSON.stringify(options || {})}; +const results = lsFilesConcurrently(paths, options); +console.log(JSON.stringify(results)); + `.trim(); +} diff --git a/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-tool.test.ts b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-tool.test.ts new file mode 100644 index 000000000..09f9955b9 --- /dev/null +++ b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-tool.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RuntimeContext } from '@mastra/core/runtime-context'; +import { lsFiles } from './ls-files-tool'; +import { SandboxContextKey } from '../../../context/sandbox-context'; + +const mockRunTypescript = vi.fn(); +const mockLsFilesSafely = vi.fn(); +const mockGenerateLsCode = vi.fn(); + +vi.mock('@buster/sandbox', () => ({ + runTypescript: mockRunTypescript, +})); + +vi.mock('./ls-files-impl', () => ({ + lsFilesSafely: mockLsFilesSafely, + generateLsCode: mockGenerateLsCode, +})); + +describe('ls-files-tool', () => { + let runtimeContext: RuntimeContext; + + beforeEach(() => { + vi.clearAllMocks(); + runtimeContext = new RuntimeContext(); + }); + + describe('lsFiles tool', () => { + it('should have correct tool definition', () => { + expect(lsFiles.id).toBe('ls-files'); + expect(lsFiles.description).toContain('Lists files and directories'); + expect(lsFiles.inputSchema).toBeDefined(); + expect(lsFiles.outputSchema).toBeDefined(); + }); + + it('should validate input schema correctly', () => { + const validInput = { + paths: ['/test/path'], + options: { detailed: true, all: false }, + }; + + const result = lsFiles.inputSchema.safeParse(validInput); + expect(result.success).toBe(true); + }); + + it('should validate output schema correctly', () => { + const validOutput = { + results: [ + { + status: 'success' as const, + path: '/test/path', + entries: [ + { + name: 'file.txt', + type: 'file' as const, + size: '1024', + permissions: '-rw-r--r--', + modified: 'Jan 15 10:30', + owner: 'user', + group: 'group', + }, + ], + }, + ], + }; + + const result = lsFiles.outputSchema.safeParse(validOutput); + expect(result.success).toBe(true); + }); + + it('should execute with sandbox when available', async () => { + const mockSandbox = { process: { codeRun: vi.fn() } }; + runtimeContext.set(SandboxContextKey.Sandbox, mockSandbox); + + mockGenerateLsCode.mockReturnValue('generated code'); + mockRunTypescript.mockResolvedValue({ + result: JSON.stringify([ + { + success: true, + path: '/test/path', + entries: [{ name: 'file.txt', type: 'file' }], + }, + ]), + exitCode: 0, + }); + + const result = await lsFiles.execute({ + context: { paths: ['/test/path'] }, + runtimeContext, + }); + + expect(mockGenerateLsCode).toHaveBeenCalledWith(['/test/path'], undefined); + expect(mockRunTypescript).toHaveBeenCalledWith(mockSandbox, 'generated code'); + expect(result.results).toHaveLength(1); + expect(result.results[0]?.status).toBe('success'); + }); + + it('should execute locally when sandbox not available', async () => { + mockLsFilesSafely.mockResolvedValue([ + { + success: true, + path: '/test/path', + entries: [{ name: 'file.txt', type: 'file' }], + }, + ]); + + const result = await lsFiles.execute({ + context: { paths: ['/test/path'] }, + runtimeContext, + }); + + expect(mockLsFilesSafely).toHaveBeenCalledWith(['/test/path'], undefined); + expect(result.results).toHaveLength(1); + expect(result.results[0]?.status).toBe('success'); + }); + + it('should handle sandbox execution failure', async () => { + const mockSandbox = { process: { codeRun: vi.fn() } }; + runtimeContext.set(SandboxContextKey.Sandbox, mockSandbox); + + mockGenerateLsCode.mockReturnValue('generated code'); + mockRunTypescript.mockResolvedValue({ + result: '', + exitCode: 1, + stderr: 'Command failed', + }); + + const result = await lsFiles.execute({ + context: { paths: ['/test/path'] }, + runtimeContext, + }); + + expect(result.results).toHaveLength(1); + expect(result.results[0]?.status).toBe('error'); + if (result.results[0]?.status === 'error') { + expect(result.results[0].error_message).toContain('Execution error'); + } + }); + + it('should handle empty paths array', async () => { + const result = await lsFiles.execute({ + context: { paths: [] }, + runtimeContext, + }); + + expect(result.results).toHaveLength(0); + }); + + it('should handle mixed success and error results', async () => { + mockLsFilesSafely.mockResolvedValue([ + { + success: true, + path: '/good/path', + entries: [{ name: 'file.txt', type: 'file' }], + }, + { + success: false, + path: '/bad/path', + error: 'Path not found', + }, + ]); + + const result = await lsFiles.execute({ + context: { paths: ['/good/path', '/bad/path'] }, + runtimeContext, + }); + + expect(result.results).toHaveLength(2); + expect(result.results[0]?.status).toBe('success'); + expect(result.results[1]?.status).toBe('error'); + if (result.results[1]?.status === 'error') { + expect(result.results[1].error_message).toBe('Path not found'); + } + }); + }); +}); diff --git a/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-tool.ts b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-tool.ts new file mode 100644 index 000000000..ec47ee930 --- /dev/null +++ b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-tool.ts @@ -0,0 +1,197 @@ +import { runTypescript } from '@buster/sandbox'; +import type { RuntimeContext } from '@mastra/core/runtime-context'; +import { createTool } from '@mastra/core/tools'; +import { wrapTraced } from 'braintrust'; +import { z } from 'zod'; +import { type SandboxContext, SandboxContextKey } from '../../../context/sandbox-context'; +import type { LsOptions } from './ls-files-impl'; + +const lsOptionsSchema = z.object({ + detailed: z + .boolean() + .optional() + .describe('Use -l flag for detailed listing with permissions, owner, size, and modification date'), + all: z + .boolean() + .optional() + .describe('Use -a flag to include hidden files and directories (those starting with .)'), + recursive: z + .boolean() + .optional() + .describe('Use -R flag for recursive listing of subdirectories'), + humanReadable: z + .boolean() + .optional() + .describe('Use -h flag for human-readable file sizes (e.g., 1K, 234M, 2G)'), +}); + +const lsFilesInputSchema = z.object({ + paths: z + .array(z.string()) + .describe( + 'Array of paths to list. Can be absolute paths (e.g., /path/to/directory) or relative paths (e.g., ./relative/path). Directories will be listed with their contents.' + ), + options: lsOptionsSchema + .optional() + .describe('Options for ls command execution'), +}); + +const lsFilesOutputSchema = z.object({ + results: z.array( + z.discriminatedUnion('status', [ + z.object({ + status: z.literal('success'), + path: z.string(), + entries: z.array( + z.object({ + name: z.string(), + type: z.enum(['file', 'directory', 'symlink', 'other']), + size: z.string().optional(), + permissions: z.string().optional(), + modified: z.string().optional(), + owner: z.string().optional(), + group: z.string().optional(), + }) + ), + }), + z.object({ + status: z.literal('error'), + path: z.string(), + error_message: z.string(), + }), + ]) + ), +}); + +const lsFilesExecution = wrapTraced( + async ( + params: z.infer, + runtimeContext: RuntimeContext + ): Promise> => { + const { paths, options } = params; + + if (!paths || paths.length === 0) { + return { results: [] }; + } + + try { + const sandbox = runtimeContext.get(SandboxContextKey.Sandbox); + + if (sandbox) { + const { generateLsCode } = await import('./ls-files-impl'); + const code = generateLsCode(paths, options as LsOptions); + const result = await runTypescript(sandbox, code); + + if (result.exitCode !== 0) { + console.error('Sandbox execution failed. Exit code:', result.exitCode); + console.error('Stderr:', result.stderr); + console.error('Stdout:', result.result); + throw new Error(`Sandbox execution failed: ${result.stderr || 'Unknown error'}`); + } + + let lsResults: Array<{ + success: boolean; + path: string; + entries?: Array<{ + name: string; + type: string; + size?: string; + permissions?: string; + modified?: string; + owner?: string; + group?: string; + }>; + error?: string; + }>; + try { + lsResults = JSON.parse(result.result.trim()); + } catch (parseError) { + console.error('Failed to parse sandbox output:', result.result); + throw new Error( + `Failed to parse sandbox output: ${parseError instanceof Error ? parseError.message : 'Unknown parse error'}` + ); + } + + return { + results: lsResults.map((lsResult) => { + if (lsResult.success) { + return { + status: 'success' as const, + path: lsResult.path, + entries: (lsResult.entries || []).map((entry) => ({ + name: entry.name, + type: entry.type as 'file' | 'directory' | 'symlink' | 'other', + size: entry.size, + permissions: entry.permissions, + modified: entry.modified, + owner: entry.owner, + group: entry.group, + })), + }; + } + return { + status: 'error' as const, + path: lsResult.path, + error_message: lsResult.error || 'Unknown error', + }; + }), + }; + } + + const { lsFilesSafely } = await import('./ls-files-impl'); + const lsResults = await lsFilesSafely(paths, options as LsOptions); + + return { + results: lsResults.map((lsResult) => { + if (lsResult.success) { + return { + status: 'success' as const, + path: lsResult.path, + entries: (lsResult.entries || []).map((entry) => ({ + name: entry.name, + type: entry.type, + size: entry.size, + permissions: entry.permissions, + modified: entry.modified, + owner: entry.owner, + group: entry.group, + })), + }; + } + return { + status: 'error' as const, + path: lsResult.path, + error_message: lsResult.error || 'Unknown error', + }; + }), + }; + } catch (error) { + return { + results: paths.map((path) => ({ + status: 'error' as const, + path, + error_message: `Execution error: ${error instanceof Error ? error.message : 'Unknown error'}`, + })), + }; + } + }, + { name: 'ls-files' } +); + +export const lsFiles = createTool({ + id: 'ls-files', + description: `Lists files and directories with structured metadata output using the ls command. Supports standard ls options like -l (detailed listing), -a (include hidden files), -R (recursive), and -h (human-readable sizes). Accepts both absolute and relative paths and can handle bulk operations through an array of paths. Returns structured JSON with file metadata including name, type, size, permissions, and modification dates. Handles errors gracefully by continuing to process other paths even if some fail.`, + inputSchema: lsFilesInputSchema, + outputSchema: lsFilesOutputSchema, + execute: async ({ + context, + runtimeContext, + }: { + context: z.infer; + runtimeContext: RuntimeContext; + }) => { + return await lsFilesExecution(context, runtimeContext); + }, +}); + +export default lsFiles; diff --git a/packages/ai/src/tools/index.ts b/packages/ai/src/tools/index.ts index 472bc048d..7e08cae9b 100644 --- a/packages/ai/src/tools/index.ts +++ b/packages/ai/src/tools/index.ts @@ -12,3 +12,4 @@ export { createTodoList } from './planning-thinking-tools/create-todo-item-tool' export { editFiles } from './file-tools/edit-files-tool/edit-files-tool'; export { readFiles } from './file-tools/read-files-tool/read-files-tool'; export { createFiles } from './file-tools/create-files-tool/create-file-tool'; +export { lsFiles } from './file-tools/ls-files-tool/ls-files-tool'; From fe77b4e3a87b7096f4a3de37f086eeb821b0a9cf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Jul 2025 14:52:58 +0000 Subject: [PATCH 2/3] fix: add optional chaining to ls_files tests for TypeScript strict null checks - Follow patterns from read-files and edit-files tests - Use optional chaining (?.) for array access to handle 'Object is possibly undefined' errors - Fixes CI TypeScript compilation errors on lines 54, 55, 56, 65, 141, 142, 143 Co-Authored-By: Dallin Bentley --- .../file-tools/ls-files-tool/ls-files-impl.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.test.ts b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.test.ts index ed946d145..a4e2266ad 100644 --- a/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.test.ts +++ b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.test.ts @@ -51,9 +51,9 @@ drwxr-xr-x 2 user group 4096 Jan 15 10:31 directory1`; const result = await lsFilesSafely(['/test/path'], options); expect(result).toHaveLength(1); - expect(result[0].success).toBe(true); - expect(result[0].entries).toHaveLength(2); - expect(result[0].entries?.[0]).toEqual({ + expect(result[0]?.success).toBe(true); + expect(result[0]?.entries).toHaveLength(2); + expect(result[0]?.entries?.[0]).toEqual({ name: 'file1.txt', type: 'file', size: '1024', @@ -62,7 +62,7 @@ drwxr-xr-x 2 user group 4096 Jan 15 10:31 directory1`; owner: 'user', group: 'group', }); - expect(result[0].entries?.[1]).toEqual({ + expect(result[0]?.entries?.[1]).toEqual({ name: 'directory1', type: 'directory', size: '4096', @@ -138,9 +138,9 @@ drwxr-xr-x 2 user group 4096 Jan 15 10:31 directory1`; const result = await lsFilesSafely(['/good/path', '/bad/path']); expect(result).toHaveLength(2); - expect(result[0].success).toBe(true); - expect(result[1].success).toBe(false); - expect(result[1].error).toBe('Path not found'); + expect(result[0]?.success).toBe(true); + expect(result[1]?.success).toBe(false); + expect(result[1]?.error).toBe('Path not found'); }); }); From 464d3265d456acf304ff163e31930535085ba0cb Mon Sep 17 00:00:00 2001 From: dal Date: Tue, 22 Jul 2025 09:23:11 -0600 Subject: [PATCH 3/3] ls file working now --- .../ls-files-tool/ls-files-impl.test.ts | 4 +- .../file-tools/ls-files-tool/ls-files-impl.ts | 56 +++++++++++-------- .../ls-files-tool/ls-files-tool.test.ts | 11 ++-- .../file-tools/ls-files-tool/ls-files-tool.ts | 13 ++--- 4 files changed, 45 insertions(+), 39 deletions(-) diff --git a/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.test.ts b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.test.ts index a4e2266ad..f502c41aa 100644 --- a/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.test.ts +++ b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as child_process from 'node:child_process'; import * as fs from 'node:fs/promises'; -import { lsFilesSafely, generateLsCode, type LsOptions } from './ls-files-impl'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type LsOptions, generateLsCode, lsFilesSafely } from './ls-files-impl'; vi.mock('node:child_process'); vi.mock('node:fs/promises'); diff --git a/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.ts b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.ts index 1f92c85d2..facce2822 100644 --- a/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.ts +++ b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-impl.ts @@ -1,6 +1,6 @@ import * as child_process from 'node:child_process'; -import * as path from 'node:path'; import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; export interface LsOptions { detailed?: boolean; @@ -28,26 +28,29 @@ export interface LsResult { function buildLsCommand(targetPath: string, options?: LsOptions): string { const flags: string[] = []; - + if (options?.detailed) flags.push('-l'); if (options?.all) flags.push('-a'); if (options?.recursive) flags.push('-R'); if (options?.humanReadable) flags.push('-h'); - + const flagString = flags.length > 0 ? ` ${flags.join('')}` : ''; return `ls${flagString} "${targetPath}"`; } function parseDetailedLsOutput(output: string): LsEntry[] { - const lines = output.trim().split('\n').filter(line => line.trim() !== ''); + const lines = output + .trim() + .split('\n') + .filter((line) => line.trim() !== ''); const entries: LsEntry[] = []; - + for (const line of lines) { if (line.startsWith('total ') || line.trim() === '') continue; - + const parts = line.trim().split(/\s+/); if (parts.length < 9) continue; - + const permissions = parts[0]; const owner = parts[2]; const group = parts[3]; @@ -56,36 +59,39 @@ function parseDetailedLsOutput(output: string): LsEntry[] { const day = parts[6]; const timeOrYear = parts[7]; const name = parts.slice(8).join(' '); - + if (!permissions) continue; - + let type: LsEntry['type'] = 'file'; if (permissions.startsWith('d')) type = 'directory'; else if (permissions.startsWith('l')) type = 'symlink'; else if (!permissions.startsWith('-')) type = 'other'; - + const modified = `${month} ${day} ${timeOrYear}`; - + const entry: LsEntry = { name, type, }; - + if (size) entry.size = size; if (permissions) entry.permissions = permissions; if (modified) entry.modified = modified; if (owner) entry.owner = owner; if (group) entry.group = group; - + entries.push(entry); } - + return entries; } function parseSimpleLsOutput(output: string): LsEntry[] { - const lines = output.trim().split('\n').filter(line => line.trim() !== ''); - return lines.map(name => ({ + const lines = output + .trim() + .split('\n') + .filter((line) => line.trim() !== ''); + return lines.map((name) => ({ name: name.trim(), type: 'file' as const, })); @@ -93,8 +99,10 @@ function parseSimpleLsOutput(output: string): LsEntry[] { async function lsSinglePath(targetPath: string, options?: LsOptions): Promise { try { - const resolvedPath = path.isAbsolute(targetPath) ? targetPath : path.join(process.cwd(), targetPath); - + const resolvedPath = path.isAbsolute(targetPath) + ? targetPath + : path.join(process.cwd(), targetPath); + try { await fs.access(resolvedPath); } catch { @@ -104,7 +112,7 @@ async function lsSinglePath(targetPath: string, options?: LsOptions): Promise { child_process.exec(command, (error, stdout, stderr) => { if (error) { @@ -125,12 +133,12 @@ async function lsSinglePath(targetPath: string, options?: LsOptions): Promise ({ - runTypescript: mockRunTypescript, + runTypescript: (...args: any[]) => mockRunTypescript(...args), })); vi.mock('./ls-files-impl', () => ({ - lsFilesSafely: mockLsFilesSafely, - generateLsCode: mockGenerateLsCode, + lsFilesSafely: (...args: any[]) => mockLsFilesSafely(...args), + generateLsCode: (...args: any[]) => mockGenerateLsCode(...args), })); +import { lsFiles } from './ls-files-tool'; + describe('ls-files-tool', () => { let runtimeContext: RuntimeContext; diff --git a/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-tool.ts b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-tool.ts index ec47ee930..f9f5dd62b 100644 --- a/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-tool.ts +++ b/packages/ai/src/tools/file-tools/ls-files-tool/ls-files-tool.ts @@ -10,15 +10,14 @@ const lsOptionsSchema = z.object({ detailed: z .boolean() .optional() - .describe('Use -l flag for detailed listing with permissions, owner, size, and modification date'), + .describe( + 'Use -l flag for detailed listing with permissions, owner, size, and modification date' + ), all: z .boolean() .optional() .describe('Use -a flag to include hidden files and directories (those starting with .)'), - recursive: z - .boolean() - .optional() - .describe('Use -R flag for recursive listing of subdirectories'), + recursive: z.boolean().optional().describe('Use -R flag for recursive listing of subdirectories'), humanReadable: z .boolean() .optional() @@ -31,9 +30,7 @@ const lsFilesInputSchema = z.object({ .describe( 'Array of paths to list. Can be absolute paths (e.g., /path/to/directory) or relative paths (e.g., ./relative/path). Directories will be listed with their contents.' ), - options: lsOptionsSchema - .optional() - .describe('Options for ls command execution'), + options: lsOptionsSchema.optional().describe('Options for ls command execution'), }); const lsFilesOutputSchema = z.object({