mirror of https://github.com/buster-so/buster.git
Merge pull request #579 from buster-so/devin/BUS-1467-1753083441
feat: implement ls_files tool for BUS-1467
This commit is contained in:
commit
99b6fc7822
|
@ -0,0 +1,169 @@
|
|||
import * as child_process from 'node:child_process';
|
||||
import * as fs from 'node:fs/promises';
|
||||
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');
|
||||
|
||||
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 = {}');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,297 @@
|
|||
import * as child_process from 'node:child_process';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
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<LsResult> {
|
||||
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<LsResult[]> {
|
||||
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();
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
import { RuntimeContext } from '@mastra/core/runtime-context';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SandboxContextKey } from '../../../context/sandbox-context';
|
||||
|
||||
const mockRunTypescript = vi.fn();
|
||||
const mockLsFilesSafely = vi.fn();
|
||||
const mockGenerateLsCode = vi.fn();
|
||||
|
||||
vi.mock('@buster/sandbox', () => ({
|
||||
runTypescript: (...args: any[]) => mockRunTypescript(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./ls-files-impl', () => ({
|
||||
lsFilesSafely: (...args: any[]) => mockLsFilesSafely(...args),
|
||||
generateLsCode: (...args: any[]) => mockGenerateLsCode(...args),
|
||||
}));
|
||||
|
||||
import { lsFiles } from './ls-files-tool';
|
||||
|
||||
describe('ls-files-tool', () => {
|
||||
let runtimeContext: RuntimeContext<any>;
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,194 @@
|
|||
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<typeof lsFilesInputSchema>,
|
||||
runtimeContext: RuntimeContext<SandboxContext>
|
||||
): Promise<z.infer<typeof lsFilesOutputSchema>> => {
|
||||
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<typeof lsFilesInputSchema>;
|
||||
runtimeContext: RuntimeContext<SandboxContext>;
|
||||
}) => {
|
||||
return await lsFilesExecution(context, runtimeContext);
|
||||
},
|
||||
});
|
||||
|
||||
export default lsFiles;
|
|
@ -13,6 +13,7 @@ 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';
|
||||
export { grepSearch } from './file-tools/grep-search-tool/grep-search-tool';
|
||||
export { bashExecute } from './file-tools';
|
||||
export { deleteFiles } from './file-tools/delete-files-tool/delete-files-tool';
|
||||
|
|
Loading…
Reference in New Issue