1import { existsSync, readdirSync, statSync } from "node:fs";
2import { join } from "node:path";
3import { Type } from "@sinclair/typebox";
4import type { AgentTool } from "@mariozechner/pi-agent-core";
5import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js";
6import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js";
7
8const DEFAULT_LIMIT = 500;
9
10const LsSchema = Type.Object({
11 path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })),
12 limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })),
13});
14
15export const createLsTool = (workspacePath: string): AgentTool => ({
16 name: "ls",
17 label: "List Directory",
18 description: `List directory contents. Returns up to ${DEFAULT_LIMIT} entries and ${DEFAULT_MAX_BYTES / 1024} KB of output.`,
19 parameters: LsSchema as any,
20 execute: async (_toolCallId: string, params: any) => {
21 const resolved = resolveToCwd(params.path || ".", workspacePath);
22 ensureWorkspacePath(workspacePath, resolved);
23
24 if (!existsSync(resolved)) {
25 throw new Error(`Path does not exist: ${params.path || "."}`);
26 }
27
28 const stats = statSync(resolved);
29 if (!stats.isDirectory()) {
30 throw new Error(`Not a directory: ${params.path || "."}`);
31 }
32
33 const entries = readdirSync(resolved);
34 entries.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
35
36 const effectiveLimit = params.limit ?? DEFAULT_LIMIT;
37 const limited = entries.slice(0, effectiveLimit);
38 const entryLimitReached = entries.length > effectiveLimit;
39
40 const lines = limited.map((entry) => {
41 const entryPath = join(resolved, entry);
42 try {
43 const entryStat = statSync(entryPath);
44 return entryStat.isDirectory() ? `${entry}/` : entry;
45 } catch {
46 return entry;
47 }
48 });
49
50 if (lines.length === 0) {
51 return {
52 content: [{ type: "text", text: "(empty directory)" }],
53 details: {},
54 };
55 }
56
57 const rawOutput = lines.join("\n");
58 const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
59
60 const notices: string[] = [];
61 if (entryLimitReached) {
62 notices.push(`Entry limit reached: showing ${effectiveLimit} of ${entries.length} entries.`);
63 }
64 if (truncation.truncated) {
65 notices.push(
66 `Output truncated: showing ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}.`,
67 );
68 }
69
70 let output = truncation.content;
71 if (notices.length > 0) {
72 output += `\n\n[${notices.join(" ")}]`;
73 }
74
75 return {
76 content: [{ type: "text", text: output }],
77 details: {
78 ...(truncation.truncated ? { truncation } : {}),
79 ...(entryLimitReached ? { entryLimitReached: true } : {}),
80 },
81 };
82 },
83});