// src/system/list.ts // Copyright (C) 2026 Rob Colbert // Licensed under the Apache License, Version 2.0 import fs from "node:fs/promises"; import path from "node:path"; import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; import { GadgetTool } from "../tool.ts"; import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts"; const MAX_ENTRIES = 1000; export class ListTool extends GadgetTool { get name(): string { return "list"; } get category(): string { return "system"; } public definition: IToolDefinition = { type: "function", function: { name: this.name, description: "List project directory contents with optional filtering. Shows file types, sizes, and modification dates. Supports filtering by glob pattern.", parameters: { type: "object", properties: { path: { type: "string", description: "Directory path to list, relative to the project root. Defaults to project root.", }, pattern: { type: "string", description: "Optional glob pattern to filter results (e.g., '*.ts', 'src/**/*').", }, recursive: { type: "boolean", description: "List subdirectories recursively (default: false).", }, showHidden: { type: "boolean", description: "Show hidden files (files starting with dot) (default: false).", }, maxDepth: { type: "number", description: "Maximum directory depth for recursive listing (default: 3).", }, }, required: [], }, }, }; public async execute(args: IToolArguments, logger: IAiLogger): Promise { const projectRoot = getProjectRoot(this.toolbox); if (!projectRoot) { return toolError({ code: "OPERATION_NOT_ALLOWED", message: "No active project workspace is configured for file tools.", recoveryHint: "Run this tool during an active Agent work order.", }); } let targetPath: string; if (args.path !== undefined && typeof args.path === "string") { const resolved = resolveProjectPath(this.toolbox, args.path); if (typeof resolved === "string") return resolved; targetPath = resolved.absolutePath; } else { targetPath = path.resolve(projectRoot); } const patternStr = typeof args.pattern === "string" ? args.pattern : undefined; let pattern: RegExp | undefined; if (patternStr) { try { const globPattern = patternStr .replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/\*/g, ".*") .replace(/\?/g, "."); pattern = new RegExp("^" + globPattern + "$"); } catch { // invalid glob pattern — skip filtering } } const recursive = args.recursive === true; const showHidden = args.showHidden === true; const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : 3; const displayPath = path.relative(projectRoot, targetPath) || "."; try { const stat = await fs.stat(targetPath); if (!stat.isDirectory()) { return toolError({ code: "INVALID_PARAMETER", message: `"${displayPath}" is not a directory.`, parameter: "path", recoveryHint: "Provide a directory path to list.", }); } const entries = await this.listDirectory(targetPath, pattern, recursive, showHidden, 0, maxDepth, projectRoot); const limited = entries.slice(0, MAX_ENTRIES); if (entries.length === 0) { return `No entries found in "${displayPath}"`; } const truncated = entries.length > MAX_ENTRIES; const lines: string[] = [ `Contents of "${displayPath}" (${truncated ? `showing first ${MAX_ENTRIES} of ` : ""}${entries.length} entries):`, "", ]; for (const entry of limited) { const typeIndicator = entry.isDirectory ? "d" : entry.isSymlink ? "l" : "-"; const size = entry.isDirectory ? "-" : entry.size.toString(); const modified = entry.modified ? new Date(entry.modified).toISOString().split("T")[0] : "-"; lines.push(`${typeIndicator} ${size.padStart(10)} ${modified} ${entry.name}`); } if (truncated) { lines.push("", `... and ${entries.length - MAX_ENTRIES} more entries`); } return lines.join("\n"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("failed to list directory", { path: displayPath, error: errorMessage }); return toolError({ code: "OPERATION_FAILED", message: `Failed to list directory: ${errorMessage}`, }); } } private async listDirectory( dir: string, pattern: RegExp | undefined, recursive: boolean, showHidden: boolean, depth: number, maxDepth: number, projectRoot: string, ): Promise> { const results: Array<{ name: string; isDirectory: boolean; isSymlink: boolean; size: number; modified: Date | null }> = []; let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return results; } for (const entry of entries) { if (!showHidden && entry.name.startsWith(".")) continue; if (entry.name === "node_modules" || entry.name === ".git") continue; const fullPath = path.join(dir, entry.name); const relativePath = path.relative(projectRoot, fullPath); if (pattern && !pattern.test(relativePath) && !pattern.test(entry.name)) continue; let stat; try { stat = await fs.stat(fullPath); } catch { continue; } results.push({ name: relativePath, isDirectory: entry.isDirectory(), isSymlink: entry.isSymbolicLink(), size: stat.size, modified: stat.mtime, }); if (recursive && entry.isDirectory() && depth < maxDepth) { const subResults = await this.listDirectory(fullPath, pattern, recursive, showHidden, depth + 1, maxDepth, projectRoot); results.push(...subResults); } } return results; } }