@oh-my-pi/pi-coding-agent 8.4.2 → 8.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools/find.ts CHANGED
@@ -1,9 +1,9 @@
1
- import path from "node:path";
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
2
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
- import { StringEnum } from "@oh-my-pi/pi-ai";
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
- import { ptree, untilAborted } from "@oh-my-pi/pi-utils";
6
+ import { isEnoent, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import type { Static } from "@sinclair/typebox";
8
8
  import { Type } from "@sinclair/typebox";
9
9
  import { renderPromptTemplate } from "../config/prompt-templates";
@@ -13,28 +13,24 @@ import findDescription from "../prompts/tools/find.md" with { type: "text" };
13
13
  import { renderFileList, renderStatusLine, renderTreeList } from "../tui";
14
14
  import { ensureTool } from "../utils/tools-manager";
15
15
  import type { ToolSession } from ".";
16
+ import { runRg } from "./grep";
16
17
  import { applyListLimit } from "./list-limit";
17
18
  import type { OutputMeta } from "./output-meta";
18
19
  import { resolveToCwd } from "./path-utils";
19
20
  import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
20
- import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
21
+ import { ToolError, throwIfAborted } from "./tool-errors";
21
22
  import { toolResult } from "./tool-result";
22
23
  import { type TruncationResult, truncateHead } from "./truncate";
23
24
 
24
25
  const findSchema = Type.Object({
25
26
  pattern: Type.String({ description: "Glob pattern, e.g. '*.ts', '**/*.json'" }),
26
27
  path: Type.Optional(Type.String({ description: "Directory to search (default: cwd)" })),
28
+ hidden: Type.Optional(Type.Boolean({ description: "Include hidden files and directories (default: true)" })),
27
29
  limit: Type.Optional(Type.Number({ description: "Max results (default: 1000)" })),
28
- hidden: Type.Optional(Type.Boolean({ description: "Include hidden files (default: true)" })),
29
- type: Type.Optional(
30
- StringEnum(["file", "dir", "all"], {
31
- description: "Filter: file, dir, or all (default: all)",
32
- }),
33
- ),
34
30
  });
35
31
 
36
32
  const DEFAULT_LIMIT = 1000;
37
- const FD_TIMEOUT_MS = 5000;
33
+ const RG_TIMEOUT_MS = 5000;
38
34
 
39
35
  export interface FindToolDetails {
40
36
  truncation?: TruncationResult;
@@ -60,53 +56,10 @@ export interface FindOperations {
60
56
  }
61
57
 
62
58
  export interface FindToolOptions {
63
- /** Custom operations for find. Default: local filesystem + fd */
59
+ /** Custom operations for find. Default: local filesystem + rg */
64
60
  operations?: FindOperations;
65
61
  }
66
62
 
67
- export interface FdResult {
68
- stdout: string;
69
- stderr: string;
70
- exitCode: number | null;
71
- }
72
-
73
- /**
74
- * Run fd command and capture output.
75
- *
76
- * @throws ToolAbortError if signal is aborted
77
- */
78
- export async function runFd(fdPath: string, args: string[], signal?: AbortSignal): Promise<FdResult> {
79
- const child = ptree.cspawn([fdPath, ...args], { signal });
80
-
81
- let stdout: string;
82
- try {
83
- stdout = await child.nothrow().text();
84
- } catch (err) {
85
- if (err instanceof ptree.Exception && err.aborted) {
86
- throw new ToolAbortError();
87
- }
88
- throw err;
89
- }
90
-
91
- let exitError: unknown;
92
- try {
93
- await child.exited;
94
- } catch (err) {
95
- exitError = err;
96
- if (err instanceof ptree.Exception && err.aborted) {
97
- throw new ToolAbortError();
98
- }
99
- }
100
-
101
- const exitCode = child.exitCode ?? (exitError instanceof ptree.Exception ? exitError.exitCode : null);
102
-
103
- return {
104
- stdout,
105
- stderr: child.peekStderr(),
106
- exitCode,
107
- };
108
- }
109
-
110
63
  export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
111
64
  public readonly name = "find";
112
65
  public readonly label = "Find";
@@ -129,7 +82,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
129
82
  _onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
130
83
  context?: AgentToolContext,
131
84
  ): Promise<AgentToolResult<FindToolDetails>> {
132
- const { pattern, path: searchDir, limit, hidden, type } = params;
85
+ const { pattern, path: searchDir, limit, hidden } = params;
133
86
 
134
87
  return untilAborted(signal, async () => {
135
88
  const searchPath = resolveToCwd(searchDir || ".", this.session.cwd);
@@ -142,9 +95,19 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
142
95
  const relative = path.relative(this.session.cwd, searchPath).replace(/\\/g, "/");
143
96
  return relative.length === 0 ? "." : relative;
144
97
  })();
145
- const effectiveLimit = limit ?? DEFAULT_LIMIT;
146
- const effectiveType = type ?? "all";
98
+ const normalizedPattern = pattern.trim();
99
+ if (!normalizedPattern) {
100
+ throw new ToolError("Pattern must not be empty");
101
+ }
102
+
103
+ const rawLimit = limit ?? DEFAULT_LIMIT;
104
+ const effectiveLimit = Number.isFinite(rawLimit) ? Math.floor(rawLimit) : Number.NaN;
105
+ if (!Number.isFinite(effectiveLimit) || effectiveLimit <= 0) {
106
+ throw new ToolError("Limit must be a positive number");
107
+ }
147
108
  const includeHidden = hidden ?? true;
109
+ const globPattern = normalizedPattern.replace(/\\/g, "/");
110
+ const globMatcher = new Bun.Glob(globPattern);
148
111
 
149
112
  // If custom operations provided with glob, use that instead of fd
150
113
  if (this.customOps?.glob) {
@@ -152,7 +115,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
152
115
  throw new ToolError(`Path not found: ${searchPath}`);
153
116
  }
154
117
 
155
- const results = await this.customOps.glob(pattern, searchPath, {
118
+ const results = await this.customOps.glob(normalizedPattern, searchPath, {
156
119
  ignore: ["**/node_modules/**", "**/.git/**"],
157
120
  limit: effectiveLimit,
158
121
  });
@@ -195,99 +158,51 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
195
158
  return resultBuilder.done();
196
159
  }
197
160
 
198
- // Default: use fd
199
- const fdPath = await ensureTool("fd", {
200
- silent: true,
201
- notify: message => context?.ui?.notify(message, "info"),
202
- });
203
- if (!fdPath) {
204
- throw new ToolError("fd is not available and could not be downloaded");
205
- }
206
-
207
- // Build fd arguments
208
- // When pattern contains path separators (e.g. "reports/**"), use --full-path
209
- // so fd matches against the full path, not just the filename.
210
- // Also prepend **/ to anchor the pattern at any depth in the search path.
211
- // Note: "**/foo.rs" is a glob construct (filename at any depth), not a path.
212
- // Only patterns with real path components like "foo/bar" or "foo/**/bar" need --full-path.
213
- const patternWithoutLeadingStarStar = pattern.replace(/^\*\*\//, "");
214
- const hasPathSeparator =
215
- patternWithoutLeadingStarStar.includes("/") || patternWithoutLeadingStarStar.includes("\\");
216
- const effectivePattern = hasPathSeparator && !pattern.startsWith("**/") ? `**/${pattern}` : pattern;
217
- const args: string[] = [
218
- "--glob", // Use glob pattern
219
- ...(hasPathSeparator ? ["--full-path"] : []),
220
- "--color=never", // No ANSI colors
221
- "--max-results",
222
- String(effectiveLimit),
223
- ];
224
-
225
- if (includeHidden) {
226
- args.push("--hidden");
227
- }
228
-
229
- // Add type filter
230
- if (effectiveType === "file") {
231
- args.push("--type", "f");
232
- } else if (effectiveType === "dir") {
233
- args.push("--type", "d");
234
- }
235
-
236
- // Include .gitignore files (root + nested) so fd respects them even outside git repos
237
- const gitignoreFiles = new Set<string>();
238
- const rootGitignore = path.join(searchPath, ".gitignore");
239
- if (await Bun.file(rootGitignore).exists()) {
240
- gitignoreFiles.add(rootGitignore);
241
- }
242
-
161
+ let searchStat: Awaited<ReturnType<typeof fs.stat>>;
243
162
  try {
244
- const gitignoreArgs = [
245
- "--hidden",
246
- "--no-ignore",
247
- "--type",
248
- "f",
249
- "--glob",
250
- ".gitignore",
251
- "--exclude",
252
- ".git",
253
- "--exclude",
254
- "node_modules",
255
- "--absolute-path",
256
- searchPath,
257
- ];
258
- const timeoutSignal = AbortSignal.timeout(FD_TIMEOUT_MS);
259
- const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
260
- const { stdout: gitignoreStdout } = await runFd(fdPath, gitignoreArgs, combinedSignal);
261
- for (const rawLine of gitignoreStdout.split("\n")) {
262
- const file = rawLine.trim();
263
- if (!file) continue;
264
- gitignoreFiles.add(file);
265
- }
163
+ searchStat = await fs.stat(searchPath);
266
164
  } catch (err) {
267
- if (err instanceof ToolAbortError) {
268
- throw err;
165
+ if (isEnoent(err)) {
166
+ throw new ToolError(`Path not found: ${searchPath}`);
269
167
  }
270
- // Ignore other lookup errors
168
+ throw err;
169
+ }
170
+ if (!searchStat.isDirectory()) {
171
+ throw new ToolError(`Path is not a directory: ${searchPath}`);
271
172
  }
272
173
 
273
- for (const gitignorePath of gitignoreFiles) {
274
- args.push("--ignore-file", gitignorePath);
174
+ // Default: use rg
175
+ const rgPath = await ensureTool("rg", {
176
+ silent: true,
177
+ notify: message => context?.ui?.notify(message, "info"),
178
+ });
179
+ if (!rgPath) {
180
+ throw new ToolError("rg is not available and could not be downloaded");
275
181
  }
276
182
 
277
- // Pattern and path
278
- args.push(effectivePattern, searchPath);
183
+ const args = [
184
+ "--files",
185
+ ...(includeHidden ? ["--hidden"] : []),
186
+ "--no-require-git",
187
+ "--color=never",
188
+ "--glob",
189
+ "!**/.git/**",
190
+ "--glob",
191
+ "!**/node_modules/**",
192
+ searchPath,
193
+ ];
279
194
 
280
- // Run fd with timeout
281
- const mainTimeoutSignal = AbortSignal.timeout(FD_TIMEOUT_MS);
195
+ // Run rg with timeout
196
+ const mainTimeoutSignal = AbortSignal.timeout(RG_TIMEOUT_MS);
282
197
  const mainCombinedSignal = signal ? AbortSignal.any([signal, mainTimeoutSignal]) : mainTimeoutSignal;
283
- const { stdout, stderr, exitCode } = await runFd(fdPath, args, mainCombinedSignal);
198
+ const { stdout, stderr, exitCode } = await runRg(rgPath, args, mainCombinedSignal);
284
199
  const output = stdout.trim();
285
200
 
286
- // fd exit codes: 0 = found files, 1 = no matches, other = error
201
+ // rg exit codes: 0 = found files, 1 = no matches, other = error
287
202
  // Treat exit code 1 with no output as "no files found"
288
203
  if (!output) {
289
204
  if (exitCode !== 0 && exitCode !== 1) {
290
- throw new ToolError(stderr.trim() || `fd failed (exit ${exitCode})`);
205
+ throw new ToolError(stderr.trim() || `rg failed (exit ${exitCode})`);
291
206
  }
292
207
  const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
293
208
  return toolResult(details).text("No files found matching pattern").done();
@@ -311,21 +226,34 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
311
226
  } else {
312
227
  relativePath = path.relative(searchPath, line);
313
228
  }
314
-
315
- if (hadTrailingSlash && !relativePath.endsWith("/")) {
316
- relativePath += "/";
229
+ const matchPath = relativePath.replace(/\\/g, "/");
230
+ if (!globMatcher.match(matchPath)) {
231
+ continue;
317
232
  }
318
233
 
234
+ let mtimeMs = 0;
235
+ let isDirectory = false;
319
236
  // Get mtime for sorting (files that fail to stat get mtime 0)
320
237
  try {
321
238
  const fullPath = path.join(searchPath, relativePath);
322
- const stat = await Bun.file(fullPath).stat();
323
- relativized.push(relativePath);
324
- mtimes.push(stat.mtimeMs);
239
+ const stat = await fs.stat(fullPath);
240
+ mtimeMs = stat.mtimeMs;
241
+ isDirectory = stat.isDirectory();
325
242
  } catch {
326
- relativized.push(relativePath);
327
- mtimes.push(0);
243
+ mtimeMs = 0;
244
+ }
245
+
246
+ if ((isDirectory || hadTrailingSlash) && !relativePath.endsWith("/")) {
247
+ relativePath += "/";
328
248
  }
249
+
250
+ relativized.push(relativePath);
251
+ mtimes.push(mtimeMs);
252
+ }
253
+
254
+ if (relativized.length === 0) {
255
+ const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
256
+ return toolResult(details).text("No files found matching pattern").done();
329
257
  }
330
258
 
331
259
  // Sort by mtime (most recent first)
@@ -373,8 +301,6 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
373
301
  interface FindRenderArgs {
374
302
  pattern: string;
375
303
  path?: string;
376
- type?: string;
377
- hidden?: boolean;
378
304
  sortByMtime?: boolean;
379
305
  limit?: number;
380
306
  }
@@ -386,8 +312,6 @@ export const findToolRenderer = {
386
312
  renderCall(args: FindRenderArgs, uiTheme: Theme): Component {
387
313
  const meta: string[] = [];
388
314
  if (args.path) meta.push(`in ${args.path}`);
389
- if (args.type && args.type !== "all") meta.push(`type:${args.type}`);
390
- if (args.hidden) meta.push("hidden");
391
315
  if (args.sortByMtime) meta.push("sort:mtime");
392
316
  if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
393
317