diff --git a/package.json b/package.json index 271670b..f526594 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "extensions": [ "./packages/pi-ext/extensions/custom-footer/custom-footer.ts", "./packages/pi-ext/extensions/startup-screen/index.ts", - "./packages/pi-ext/extensions/file-search/index.ts", "./packages/pi-ext/extensions/review/review.ts", "./packages/pi-ext/extensions/leader-key/index.ts", "./packages/pi-ext/extensions/chat-to-md/index.ts", diff --git a/packages/pi-ext/README.md b/packages/pi-ext/README.md index 9ace871..b422b33 100644 --- a/packages/pi-ext/README.md +++ b/packages/pi-ext/README.md @@ -42,7 +42,6 @@ Omit a resource type to load all of it. Use an empty array to load none. | [Pi Telescope](extensions/pi-telescope) | Native fuzzy finder for sessions, files, commands, and other providers. | | [Custom Footer](extensions/custom-footer) | Core session line plus one bounded prioritized extension-status line. | | [Startup Screen](extensions/startup-screen) | Centred PI wordmark coloured by the active theme, with a responsive compact fallback. | -| [File Search](extensions/file-search) | First-class typed `fd` file discovery and `rg` content search. | | [Tool Presentation](extensions/tool-presentation) | Tidy compact built-in cards with lazy highlighted edit/write details. | | [Permissions](extensions/permissions) | Switchable `yolo`, `safe`, and `read-only` command policies. | | [Session Query](extensions/session-query) | Ask focused questions about previous Pi session files. | @@ -94,14 +93,6 @@ The startup screen replaces only Pi's built-in header. It centres a PI wordmark, uses active theme roles for its colour sweep, shows the current path, and falls back to a compact π mark when the terminal is narrower than the full logo. -### File search - -File Search gives models typed `fd` and `rg` tools. It prefers compatible system -binaries and can install checksum-pinned managed fallbacks under Pi's agent -directory. Set `PI_OFFLINE=1` to forbid downloads; existing system and managed -binaries remain usable. Search output is bounded, with complete output retained -in a temporary file only when the visible result is truncated. - ### Compact tools and footer Tool Presentation is the sole owner of Pi's `read`, `bash`, `edit`, `write`, diff --git a/packages/pi-ext/extensions/file-search/index.ts b/packages/pi-ext/extensions/file-search/index.ts deleted file mode 100644 index d8c691f..0000000 --- a/packages/pi-ext/extensions/file-search/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { StringEnum } from "@earendil-works/pi-ai"; -import { Text } from "@earendil-works/pi-tui"; -import { Type } from "typebox"; -import { BinaryResolvers, type ResolvedBinary, type ToolName } from "./src/binaries.ts"; -import { type FdParams, type RgParams } from "./src/args.ts"; -import { FD_DESCRIPTION, FD_GUIDELINES, FD_SNIPPET, RG_DESCRIPTION, RG_GUIDELINES, RG_SNIPPET } from "./src/prompt.ts"; -import { searchWithBinary } from "./src/search.ts"; - -interface SearchDetails { - tool: ToolName; - kind: "progress" | "matches" | "no-matches"; - totalBytes?: number; - totalLines?: number; - truncated?: boolean; - outputPath?: string; - binarySource?: string; -} - -function compactCall(tool: ToolName, args: Record, theme: any): Text { - const pattern = typeof args.pattern === "string" ? args.pattern : "*"; - const path = typeof args.path === "string" ? args.path : "."; - return new Text(`${theme.fg("toolTitle", theme.bold(`${tool} `))}${theme.fg("accent", pattern)} ${theme.fg("muted", path)}`, 0, 0); -} - -function compactResult(result: any, options: any, theme: any): Text { - const details = result.details as SearchDetails | undefined; - const content = result.content?.[0]; - if (options?.expanded && content?.type === "text") return new Text(theme.fg(result.isError ? "error" : "toolOutput", content.text), 0, 0); - if (!details) { - return new Text(theme.fg(result.isError ? "error" : "toolOutput", content?.type === "text" ? content.text : ""), 0, 0); - } - if (details.kind === "progress") return new Text(theme.fg("muted", `${details.tool} searching…`), 0, 0); - if (details.kind === "no-matches") return new Text(theme.fg("muted", `${details.tool}: no matches`), 0, 0); - const count = `${details.totalLines ?? 0} lines, ${details.totalBytes ?? 0} bytes`; - const suffix = details.truncated ? ` · truncated · ${details.outputPath}` : ""; - return new Text(theme.fg(details.truncated ? "warning" : "success", `${details.tool}: ${count}${suffix}`), 0, 0); -} - -export default function fileSearch(pi: ExtensionAPI, resolvers = new BinaryResolvers()): void { - - async function execute(tool: ToolName, params: FdParams | RgParams, signal: AbortSignal, onUpdate: any, cwd: string) { - onUpdate?.({ content: [{ type: "text" as const, text: `${tool} searching…` }], details: { tool, kind: "progress" } satisfies SearchDetails }); - let binary: ResolvedBinary; - try { - binary = await resolvers.resolve(tool); - } catch (error) { - throw new Error(`${tool} setup failed: ${error instanceof Error ? error.message : String(error)}`); - } - let result; - try { - result = await searchWithBinary(tool, binary, params, cwd, signal); - } catch (error) { - throw new Error(`${tool} execution failed: ${error instanceof Error ? error.message : String(error)}`); - } - return { - content: [{ type: "text" as const, text: result.text }], - details: { - tool, - kind: result.kind, - totalBytes: result.totalBytes, - totalLines: result.totalLines, - truncated: result.truncated, - outputPath: result.outputPath, - binarySource: binary.source, - } satisfies SearchDetails, - }; - } - - pi.registerTool({ - name: "fd", - label: "fd", - description: FD_DESCRIPTION, - promptSnippet: FD_SNIPPET, - promptGuidelines: FD_GUIDELINES, - parameters: Type.Object({ - pattern: Type.Optional(Type.String({ description: "Name regex or glob. Omit to list all entries." })), - path: Type.Optional(Type.String({ description: "Directory to search. Defaults to Pi's current directory." })), - type: Type.Optional(StringEnum(["file", "directory", "symlink", "executable", "empty", "socket", "pipe"] as const)), - extension: Type.Optional(Type.String({ description: "File extension, with or without a leading dot." })), - glob: Type.Optional(Type.Boolean({ description: "Interpret pattern as a glob instead of a regex." })), - hidden: Type.Optional(Type.Boolean({ description: "Include hidden entries while still respecting ignore files." })), - maxDepth: Type.Optional(Type.Integer({ minimum: 0, maximum: 64 })), - limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10_000 })), - }, { additionalProperties: false }), - async execute(_id, params, signal, onUpdate, ctx) { return execute("fd", params, signal, onUpdate, ctx.cwd); }, - renderCall(args, theme) { return compactCall("fd", args, theme); }, - renderResult(result, options, theme) { return compactResult(result, options, theme); }, - }); - - pi.registerTool({ - name: "rg", - label: "rg", - description: RG_DESCRIPTION, - promptSnippet: RG_SNIPPET, - promptGuidelines: RG_GUIDELINES, - parameters: Type.Object({ - pattern: Type.String({ description: "Regex content pattern. Use literal=true for literal metacharacters." }), - path: Type.Optional(Type.String({ description: "File or directory to search. Defaults to Pi's current directory." })), - glob: Type.Optional(Type.String({ description: "Include or exclude file glob, for example '*.ts' or '!vendor/**'." })), - type: Type.Optional(Type.String({ description: "Ripgrep file type, for example ts or markdown." })), - case: Type.Optional(StringEnum(["smart", "insensitive", "sensitive"] as const)), - literal: Type.Optional(Type.Boolean({ description: "Match the pattern literally, not as a regular expression." })), - hidden: Type.Optional(Type.Boolean({ description: "Include hidden files while still respecting ignore files." })), - context: Type.Optional(Type.Integer({ minimum: 0, maximum: 20 })), - maxCount: Type.Optional(Type.Integer({ minimum: 1, maximum: 1_000 })), - }, { additionalProperties: false }), - async execute(_id, params, signal, onUpdate, ctx) { return execute("rg", params, signal, onUpdate, ctx.cwd); }, - renderCall(args, theme) { return compactCall("rg", args, theme); }, - renderResult(result, options, theme) { return compactResult(result, options, theme); }, - }); - - pi.on("session_start", async (_event, ctx) => { - for (const tool of ["fd", "rg"] as const) { - void resolvers.resolve(tool).then((binary) => { - if (binary.source === "installed") ctx.ui.notify(`${tool} ${binary.version} installed at ${binary.path}`, "info"); - }).catch((error) => ctx.ui.notify(`${tool} setup failed: ${error instanceof Error ? error.message : String(error)}`, "warning")); - } - }); -} diff --git a/packages/pi-ext/extensions/file-search/src/args.ts b/packages/pi-ext/extensions/file-search/src/args.ts deleted file mode 100644 index bee16b0..0000000 --- a/packages/pi-ext/extensions/file-search/src/args.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { homedir } from "node:os"; -import { isAbsolute, resolve } from "node:path"; -import { - FD_DEFAULT_LIMIT, - FD_MAX_DEPTH, - FD_MAX_LIMIT, - RG_DEFAULT_LIMIT, - RG_MAX_CONTEXT, - RG_MAX_LIMIT, - boundedInteger, -} from "./limits.ts"; - -export type FdEntryType = "file" | "directory" | "symlink" | "executable" | "empty" | "socket" | "pipe"; -export interface FdParams { - pattern?: string; - path?: string; - type?: FdEntryType; - extension?: string; - glob?: boolean; - hidden?: boolean; - maxDepth?: number; - limit?: number; -} - -export type RgCase = "smart" | "insensitive" | "sensitive"; -export interface RgParams { - pattern: string; - path?: string; - glob?: string; - type?: string; - case?: RgCase; - literal?: boolean; - hidden?: boolean; - context?: number; - maxCount?: number; -} - -export function normalizePath(input: string | undefined, cwd: string, home = homedir()): string { - let value = (input ?? ".").trim(); - while (value.startsWith("@")) value = value.slice(1); - if (value === "") value = "."; - if (value === "~") value = home; - else if (value.startsWith("~/")) value = resolve(home, value.slice(2)); - return isAbsolute(value) ? resolve(value) : resolve(cwd, value); -} - -export function normalizeExtension(extension: string): string { - return extension.replace(/^\.+/, ""); -} - -const fdTypes: Record = { - file: "f", - directory: "d", - symlink: "l", - executable: "x", - empty: "e", - socket: "s", - pipe: "p", -}; - -export function buildFdArgs(params: FdParams, cwd: string, home = homedir()): string[] { - const args = ["--color", "never", "--max-results", String(boundedInteger(params.limit, FD_DEFAULT_LIMIT, 1, FD_MAX_LIMIT))]; - if (params.type) args.push("--type", fdTypes[params.type]); - if (params.extension) { - const extension = normalizeExtension(params.extension); - if (extension) args.push("--extension", extension); - } - if (params.glob) args.push("--glob"); - if (params.hidden) args.push("--hidden"); - if (params.maxDepth !== undefined) args.push("--max-depth", String(boundedInteger(params.maxDepth, FD_MAX_DEPTH, 0, FD_MAX_DEPTH))); - args.push("--", params.pattern ?? ".", normalizePath(params.path, cwd, home)); - return args; -} - -export function buildRgArgs(params: RgParams, cwd: string, home = homedir()): string[] { - const args = [ - "--color", "never", - "--line-number", - "--with-filename", - "--max-count", String(boundedInteger(params.maxCount, RG_DEFAULT_LIMIT, 1, RG_MAX_LIMIT)), - ]; - const caseMode = params.case ?? "smart"; - args.push(caseMode === "insensitive" ? "--ignore-case" : caseMode === "sensitive" ? "--case-sensitive" : "--smart-case"); - if (params.glob) args.push("--glob", params.glob); - if (params.type) args.push("--type", params.type); - if (params.literal) args.push("--fixed-strings"); - if (params.hidden) args.push("--hidden"); - if (params.context !== undefined) args.push("--context", String(boundedInteger(params.context, 0, 0, RG_MAX_CONTEXT))); - args.push("--", params.pattern, normalizePath(params.path, cwd, home)); - return args; -} diff --git a/packages/pi-ext/extensions/file-search/src/binaries.ts b/packages/pi-ext/extensions/file-search/src/binaries.ts deleted file mode 100644 index 2576753..0000000 --- a/packages/pi-ext/extensions/file-search/src/binaries.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { createHash } from "node:crypto"; -import { createWriteStream } from "node:fs"; -import { chmod, copyFile, mkdir, mkdtemp, rename, rm } from "node:fs/promises"; -import { homedir, tmpdir } from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; -import { spawn } from "node:child_process"; -import { request as httpsRequest } from "node:https"; -import { ARCHIVE_MAX_BYTES, DOWNLOAD_TIMEOUT_MS, MAX_REDIRECTS, PROBE_TIMEOUT_MS } from "./limits.ts"; - -export type ToolName = "fd" | "rg"; -export type BinarySource = "system" | "fallback" | "installed"; -export interface ResolvedBinary { tool: ToolName; path: string; source: BinarySource; version?: string } -export interface ReleaseAsset { tool: ToolName; version: string; url: string; sha256: string; bytes: number; member: string } - -const FD_BASE = "https://github.com/sharkdp/fd/releases/download"; -const RG_BASE = "https://github.com/BurntSushi/ripgrep/releases/download"; -export const RELEASES: Record = { - "fd:darwin:arm64": { tool: "fd", version: "10.4.2", url: `${FD_BASE}/v10.4.2/fd-v10.4.2-aarch64-apple-darwin.tar.gz`, sha256: "623dc0afc81b92e4d4606b380d7bc91916ba7b97814263e554d50923a39e480a", bytes: 1328933, member: "fd-v10.4.2-aarch64-apple-darwin/fd" }, - "fd:darwin:x64": { tool: "fd", version: "10.3.0", url: `${FD_BASE}/v10.3.0/fd-v10.3.0-x86_64-apple-darwin.tar.gz`, sha256: "50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3", bytes: 1430203, member: "fd-v10.3.0-x86_64-apple-darwin/fd" }, - "fd:linux:arm64": { tool: "fd", version: "10.4.2", url: `${FD_BASE}/v10.4.2/fd-v10.4.2-aarch64-unknown-linux-gnu.tar.gz`, sha256: "6c51f7c5446b3338b1e401ff15dc194c590bb2fa64fd43ff3278300f073adec5", bytes: 1559490, member: "fd-v10.4.2-aarch64-unknown-linux-gnu/fd" }, - "fd:linux:x64": { tool: "fd", version: "10.4.2", url: `${FD_BASE}/v10.4.2/fd-v10.4.2-x86_64-unknown-linux-gnu.tar.gz`, sha256: "def59805cd14b5651b68990855f426ad087f3b96881296d963910431ba3143c8", bytes: 1700779, member: "fd-v10.4.2-x86_64-unknown-linux-gnu/fd" }, - "rg:darwin:arm64": { tool: "rg", version: "15.2.0", url: `${RG_BASE}/15.2.0/ripgrep-15.2.0-aarch64-apple-darwin.tar.gz`, sha256: "3750b2e93f37e0c692657da574d7019a101c0084da05a790c83fd335bad973e4", bytes: 1764284, member: "ripgrep-15.2.0-aarch64-apple-darwin/rg" }, - "rg:darwin:x64": { tool: "rg", version: "15.2.0", url: `${RG_BASE}/15.2.0/ripgrep-15.2.0-x86_64-apple-darwin.tar.gz`, sha256: "af7825fcc69a2afc7a7aea55fc9af90e26421d8f20fe59df32e233c0b8a231c1", bytes: 1878284, member: "ripgrep-15.2.0-x86_64-apple-darwin/rg" }, - "rg:linux:arm64": { tool: "rg", version: "15.2.0", url: `${RG_BASE}/15.2.0/ripgrep-15.2.0-aarch64-unknown-linux-gnu.tar.gz`, sha256: "a740b91c82eaf9914cfedd353572f2791cbe0162c84101ee0951058f4dcbc90d", bytes: 1854661, member: "ripgrep-15.2.0-aarch64-unknown-linux-gnu/rg" }, - "rg:linux:x64": { tool: "rg", version: "15.2.0", url: `${RG_BASE}/15.2.0/ripgrep-15.2.0-x86_64-unknown-linux-musl.tar.gz`, sha256: "33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c", bytes: 2265718, member: "ripgrep-15.2.0-x86_64-unknown-linux-musl/rg" }, -}; - -export function selectRelease(tool: ToolName, platform = process.platform, arch = process.arch): ReleaseAsset { - const release = RELEASES[`${tool}:${platform}:${arch}`]; - if (!release) throw new Error(`${tool} has no managed fallback for platform ${platform}/${arch}; install ${tool} manually`); - return release; -} - -export function managedBinDir(env: NodeJS.ProcessEnv = process.env, home = homedir()): string { - const agentDir = env.PI_CODING_AGENT_DIR ? resolve(env.PI_CODING_AGENT_DIR.replace(/^~(?=\/|$)/, home)) : join(home, ".pi", "agent"); - return join(agentDir, "bin"); -} - -async function commandExit(command: string, args: string[], timeoutMs: number): Promise { - return new Promise((done) => { - let settled = false; - const child = spawn(command, args, { stdio: "ignore", shell: false }); - const finish = (code: number | null) => { if (!settled) { settled = true; clearTimeout(timer); done(code); } }; - const timer = setTimeout(() => { child.kill("SIGKILL"); finish(null); }, timeoutMs); - timer.unref?.(); - child.once("error", () => finish(null)); - child.once("exit", (code) => finish(code)); - }); -} - -export async function defaultProbe(command: string): Promise { - return (await commandExit(command, ["--version"], PROBE_TIMEOUT_MS)) === 0; -} - -export interface FetchResponse { status: number; headers: Record; body: AsyncIterable } -export type HttpsFetcher = (url: URL, signal: AbortSignal) => Promise; - -export const defaultHttpsFetcher: HttpsFetcher = (url, signal) => new Promise((resolveResponse, reject) => { - const req = httpsRequest(url, { signal }, (response) => { - const headers: Record = {}; - for (const [key, value] of Object.entries(response.headers)) headers[key] = value; - resolveResponse({ status: response.statusCode ?? 0, headers, body: response as AsyncIterable }); - }); - req.once("error", reject); - req.end(); -}); - -export interface DownloadOptions { - fetcher?: HttpsFetcher; - maxBytes?: number; - maxRedirects?: number; - timeoutMs?: number; -} - -export async function downloadPinned(asset: ReleaseAsset, destination: string, options: DownloadOptions = {}): Promise { - const fetcher = options.fetcher ?? defaultHttpsFetcher; - const maxBytes = options.maxBytes ?? ARCHIVE_MAX_BYTES; - const maxRedirects = options.maxRedirects ?? MAX_REDIRECTS; - const timeoutMs = options.timeoutMs ?? DOWNLOAD_TIMEOUT_MS; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(new Error(`${asset.tool} download timeout limit ${timeoutMs}ms exceeded`)), timeoutMs); - let current = new URL(asset.url); - let redirects = 0; - try { - while (true) { - if (current.protocol !== "https:") throw new Error(`${asset.tool} download rejected non-HTTPS URL ${current.href}`); - const response = await fetcher(current, controller.signal); - if (response.status >= 300 && response.status < 400) { - const location = response.headers.location; - if (typeof location !== "string" || !location) throw new Error(`${asset.tool} download redirect ${response.status} has no location`); - if (++redirects > maxRedirects) throw new Error(`${asset.tool} download redirect limit ${maxRedirects} exceeded; requested ${redirects}`); - for await (const _chunk of response.body) { /* drain the response so its socket can close */ } - current = new URL(location, current); - continue; - } - if (response.status !== 200) throw new Error(`${asset.tool} download failed with HTTP ${response.status}`); - const declaredValue = response.headers["content-length"]; - const declared = Number(Array.isArray(declaredValue) ? declaredValue[0] : declaredValue); - if (Number.isFinite(declared) && declared > maxBytes) throw new Error(`${asset.tool} archive byte limit ${maxBytes} exceeded; requested ${declared}`); - const hash = createHash("sha256"); - const stream = createWriteStream(destination, { mode: 0o600 }); - let observed = 0; - try { - for await (const value of response.body) { - const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); - observed += chunk.length; - if (observed > maxBytes) throw new Error(`${asset.tool} archive byte limit ${maxBytes} exceeded; observed ${observed}`); - hash.update(chunk); - if (!stream.write(chunk)) await new Promise((resolveDrain, rejectDrain) => { stream.once("drain", resolveDrain); stream.once("error", rejectDrain); }); - } - await new Promise((resolveEnd, rejectEnd) => { stream.end(resolveEnd); stream.once("error", rejectEnd); }); - } catch (error) { - stream.destroy(); - throw error; - } - const actual = hash.digest("hex"); - if (actual !== asset.sha256) throw new Error(`${asset.tool} SHA-256 mismatch: expected ${asset.sha256}, observed ${actual}`); - return observed; - } - } catch (error) { - if (controller.signal.aborted) throw new Error(`${asset.tool} download timeout limit ${timeoutMs}ms exceeded`); - throw error; - } finally { - clearTimeout(timer); - } -} - -export interface InstallOptions extends DownloadOptions { - binDir?: string; - probe?: (command: string) => Promise; - platform?: NodeJS.Platform; - arch?: NodeJS.Architecture; - asset?: ReleaseAsset; -} - -export async function installManaged(tool: ToolName, options: InstallOptions = {}): Promise { - const asset = options.asset ?? selectRelease(tool, options.platform, options.arch); - const binDir = options.binDir ?? managedBinDir(); - const probe = options.probe ?? defaultProbe; - await mkdir(binDir, { recursive: true, mode: 0o700 }); - const temporary = await mkdtemp(join(tmpdir(), `pi-${tool}-`)); - const archive = join(temporary, basename(asset.url)); - const staged = join(binDir, `.${tool}.${process.pid}.${Date.now()}.tmp`); - try { - await downloadPinned(asset, archive, options); - const exit = await commandExit("tar", ["-xzf", archive, "-C", temporary, asset.member], PROBE_TIMEOUT_MS); - if (exit !== 0) throw new Error(`${tool} extraction failed (tar exit ${exit ?? "timeout"}; limit ${PROBE_TIMEOUT_MS}ms)`); - await copyFile(join(temporary, asset.member), staged); - await chmod(staged, 0o755); - if (!(await probe(staged))) throw new Error(`${tool} installed binary failed its --version probe`); - const destination = join(binDir, tool); - await rename(staged, destination); - return { tool, path: destination, source: "installed", version: asset.version }; - } finally { - await rm(staged, { force: true }).catch(() => undefined); - await rm(temporary, { recursive: true, force: true }).catch(() => undefined); - } -} - -export interface ResolveOptions extends InstallOptions { - env?: NodeJS.ProcessEnv; - installer?: (tool: ToolName) => Promise; -} - -export async function resolveBinary(tool: ToolName, options: ResolveOptions = {}): Promise { - const probe = options.probe ?? defaultProbe; - const commands = tool === "fd" ? ["fd", "fdfind"] : ["rg"]; - for (const command of commands) if (await probe(command)) return { tool, path: command, source: "system" }; - const fallback = join(options.binDir ?? managedBinDir(options.env), tool); - if (await probe(fallback)) return { tool, path: fallback, source: "fallback" }; - if ((options.env ?? process.env).PI_OFFLINE === "1") throw new Error(`${tool} is unavailable while PI_OFFLINE=1; install ${tool} manually or provide ${fallback}`); - return options.installer ? options.installer(tool) : installManaged(tool, { ...options, binDir: dirname(fallback), probe }); -} - -export class BinaryResolvers { - readonly #promises = new Map>(); - readonly options: ResolveOptions; - constructor(options: ResolveOptions = {}) { this.options = options; } - resolve(tool: ToolName): Promise { - let promise = this.#promises.get(tool); - if (!promise) { - promise = resolveBinary(tool, this.options); - this.#promises.set(tool, promise); - } - return promise; - } -} diff --git a/packages/pi-ext/extensions/file-search/src/limits.ts b/packages/pi-ext/extensions/file-search/src/limits.ts deleted file mode 100644 index abeb10d..0000000 --- a/packages/pi-ext/extensions/file-search/src/limits.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Pi's built-in tool boundary and the approved reference-compatible tripwires. -// Release asset receipts are recorded in tests and the implementation handoff. -export const PREVIEW_MAX_LINES = 2_000; -export const PREVIEW_MAX_BYTES = 50_000; -export const STDERR_MAX_BYTES = 64 * 1024; -export const ARCHIVE_MAX_BYTES = 25 * 1024 * 1024; -export const DOWNLOAD_TIMEOUT_MS = 30_000; -export const MAX_REDIRECTS = 10; -export const SEARCH_TIMEOUT_MS = 60_000; -export const PROBE_TIMEOUT_MS = 5_000; - -export const FD_DEFAULT_LIMIT = 1_000; -export const FD_MAX_LIMIT = 10_000; -export const FD_MAX_DEPTH = 64; -export const RG_DEFAULT_LIMIT = 100; -export const RG_MAX_LIMIT = 1_000; -export const RG_MAX_CONTEXT = 20; - -export function boundedInteger(value: number | undefined, fallback: number, min: number, max: number): number { - if (value === undefined) return fallback; - if (!Number.isFinite(value)) return fallback; - return Math.min(max, Math.max(min, Math.trunc(value))); -} diff --git a/packages/pi-ext/extensions/file-search/src/output.ts b/packages/pi-ext/extensions/file-search/src/output.ts deleted file mode 100644 index 98a976d..0000000 --- a/packages/pi-ext/extensions/file-search/src/output.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { PREVIEW_MAX_BYTES, PREVIEW_MAX_LINES } from "./limits.ts"; - -export interface PreviewSummary { - preview: string; - totalBytes: number; - totalLines: number; - truncated: boolean; -} - -function safeUtf8(buffer: Buffer): string { - for (let trim = 0; trim <= Math.min(3, buffer.length); trim++) { - try { - return new TextDecoder("utf-8", { fatal: true }).decode(trim ? buffer.subarray(0, -trim) : buffer); - } catch { - // A retained byte boundary may split one UTF-8 sequence. - } - } - return buffer.toString("utf8"); -} - -export class PreviewAccumulator { - readonly #chunks: Buffer[] = []; - #previewBytes = 0; - #previewNewlines = 0; - #totalBytes = 0; - #totalNewlines = 0; - #lastByteWasNewline = false; - #stopped = false; - - push(chunk: Buffer): void { - if (chunk.length === 0) return; - this.#totalBytes += chunk.length; - for (const byte of chunk) if (byte === 10) this.#totalNewlines++; - this.#lastByteWasNewline = chunk.at(-1) === 10; - if (this.#stopped) return; - - let take = Math.min(chunk.length, PREVIEW_MAX_BYTES - this.#previewBytes); - if (take <= 0) { - this.#stopped = true; - return; - } - for (let index = 0; index < take; index++) { - if (chunk[index] === 10 && ++this.#previewNewlines >= PREVIEW_MAX_LINES) { - take = index + 1; - this.#stopped = take < chunk.length || this.#totalBytes > this.#previewBytes + take; - break; - } - } - if (take > 0) { - this.#chunks.push(chunk.subarray(0, take)); - this.#previewBytes += take; - } - if (take < chunk.length || this.#previewBytes >= PREVIEW_MAX_BYTES || this.#previewNewlines >= PREVIEW_MAX_LINES) this.#stopped = true; - } - - summary(): PreviewSummary { - const bytes = Buffer.concat(this.#chunks, this.#previewBytes); - return { - preview: safeUtf8(bytes), - totalBytes: this.#totalBytes, - totalLines: this.#totalBytes === 0 ? 0 : this.#totalNewlines + (this.#lastByteWasNewline ? 0 : 1), - truncated: this.#totalBytes > this.#previewBytes, - }; - } -} - -export function truncationNotice(summary: PreviewSummary, outputPath: string): string { - return [ - `[Output truncated at ${PREVIEW_MAX_LINES.toLocaleString("en-US")} lines/${PREVIEW_MAX_BYTES.toLocaleString("en-US")} bytes.]`, - `Observed ${summary.totalLines.toLocaleString("en-US")} lines/${summary.totalBytes.toLocaleString("en-US")} bytes.`, - `Full output: ${outputPath}`, - ].join("\n"); -} diff --git a/packages/pi-ext/extensions/file-search/src/process.ts b/packages/pi-ext/extensions/file-search/src/process.ts deleted file mode 100644 index f459d23..0000000 --- a/packages/pi-ext/extensions/file-search/src/process.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { spawn } from "node:child_process"; -import { createWriteStream } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { PreviewAccumulator, truncationNotice, type PreviewSummary } from "./output.ts"; -import { SEARCH_TIMEOUT_MS, STDERR_MAX_BYTES } from "./limits.ts"; -import type { ToolName } from "./binaries.ts"; - -export interface SearchExecution { - tool: ToolName; - command: string; - args: string[]; - cwd: string; - timeoutMs?: number; - signal?: AbortSignal; -} - -export interface SearchProcessResult extends PreviewSummary { - kind: "matches" | "no-matches"; - exitCode: number; - stderr: string; - stderrTruncated: boolean; - outputPath?: string; - text: string; -} - -function terminate(pid: number | undefined): NodeJS.Timeout | undefined { - if (!pid) return undefined; - try { - if (process.platform !== "win32") process.kill(-pid, "SIGTERM"); - else process.kill(pid, "SIGTERM"); - } catch { - // It may have exited between the event and signal. - } - const timer = setTimeout(() => { - try { - if (process.platform !== "win32") process.kill(-pid, "SIGKILL"); - else process.kill(pid, "SIGKILL"); - } catch { - // Already gone. - } - }, 250); - timer.unref?.(); - return timer; -} - -export async function runSearch(input: SearchExecution): Promise { - const timeoutMs = input.timeoutMs ?? SEARCH_TIMEOUT_MS; - if (input.signal?.aborted) throw new Error(`${input.tool} search cancelled`); - const directory = await mkdtemp(join(tmpdir(), `pi-${input.tool}-search-`)); - const outputPath = join(directory, "output.txt"); - let retain = false; - try { - const child = spawn(input.command, input.args, { - cwd: input.cwd, - detached: process.platform !== "win32", - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }); - let timedOut = false; - let cancelled = false; - let killTimer: NodeJS.Timeout | undefined; - const onAbort = () => { cancelled = true; killTimer ??= terminate(child.pid); }; - input.signal?.addEventListener("abort", onAbort, { once: true }); - const timer = setTimeout(() => { timedOut = true; killTimer ??= terminate(child.pid); }, timeoutMs); - timer.unref?.(); - - const preview = new PreviewAccumulator(); - const output = createWriteStream(outputPath, { mode: 0o600 }); - const stdoutTask = (async () => { - for await (const value of child.stdout) { - const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); - preview.push(chunk); - if (!output.write(chunk)) await new Promise((resolveDrain, rejectDrain) => { output.once("drain", resolveDrain); output.once("error", rejectDrain); }); - } - await new Promise((resolveEnd, rejectEnd) => { output.end(resolveEnd); output.once("error", rejectEnd); }); - })(); - - let stderrBytes = 0; - let stderrTruncated = false; - const stderrChunks: Buffer[] = []; - const stderrTask = (async () => { - for await (const value of child.stderr) { - const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); - const remaining = STDERR_MAX_BYTES - stderrBytes; - if (remaining > 0) { - const kept = chunk.subarray(0, remaining); - stderrChunks.push(kept); - stderrBytes += kept.length; - } - if (chunk.length > remaining) stderrTruncated = true; - } - })(); - - const exitCode = await new Promise((resolveExit, rejectExit) => { - child.once("error", rejectExit); - child.once("close", (code) => resolveExit(code ?? -1)); - }).finally(() => { - clearTimeout(timer); - if (killTimer) clearTimeout(killTimer); - input.signal?.removeEventListener("abort", onAbort); - }); - await Promise.all([stdoutTask, stderrTask]); - const summary = preview.summary(); - const stderr = Buffer.concat(stderrChunks, stderrBytes).toString("utf8"); - if (timedOut) throw new Error(`${input.tool} search timeout limit ${timeoutMs}ms exceeded`); - if (cancelled) throw new Error(`${input.tool} search cancelled`); - const noMatches = summary.totalBytes === 0 && (exitCode === 0 || (input.tool === "rg" && exitCode === 1)); - if (!noMatches && exitCode !== 0) { - const suffix = stderr.trim() || `exit code ${exitCode}`; - throw new Error(`${input.tool} search failed: ${suffix}${stderrTruncated ? ` (stderr limit ${STDERR_MAX_BYTES} bytes reached)` : ""}`); - } - if (noMatches) return { ...summary, kind: "no-matches", exitCode, stderr, stderrTruncated, text: "No matches found." }; - if (summary.truncated) { - retain = true; - const notice = truncationNotice(summary, outputPath); - return { ...summary, kind: "matches", exitCode, stderr, stderrTruncated, outputPath, text: `${summary.preview}${summary.preview.endsWith("\n") ? "" : "\n"}${notice}` }; - } - return { ...summary, kind: "matches", exitCode, stderr, stderrTruncated, text: summary.preview }; - } finally { - if (!retain) await rm(directory, { recursive: true, force: true }); - } -} diff --git a/packages/pi-ext/extensions/file-search/src/prompt.ts b/packages/pi-ext/extensions/file-search/src/prompt.ts deleted file mode 100644 index eb0e648..0000000 --- a/packages/pi-ext/extensions/file-search/src/prompt.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const FD_DESCRIPTION = "Discover files and directories by name with fd. Git ignore files are respected unless hidden entries are explicitly requested."; -export const RG_DESCRIPTION = "Search file contents with ripgrep. Uses smart case and respects ignore files by default."; - -export const FD_SNIPPET = "fd — fast typed file-name discovery with ignore-aware defaults"; -export const RG_SNIPPET = "rg — fast typed content search with smart case and ignore-aware defaults"; - -export const FD_GUIDELINES = [ - "Use fd for file-name or directory discovery; omit pattern to list entries.", - "Use rg instead when searching file contents.", - "Use bash only when a pipeline or post-processing is required.", -]; - -export const RG_GUIDELINES = [ - "Use rg for content search; use fd for file-name discovery.", - "Set literal=true when the pattern contains regex metacharacters that should be matched literally.", - "Use bash only when a pipeline or post-processing is required.", -]; diff --git a/packages/pi-ext/extensions/file-search/src/search.ts b/packages/pi-ext/extensions/file-search/src/search.ts deleted file mode 100644 index f267e34..0000000 --- a/packages/pi-ext/extensions/file-search/src/search.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { buildFdArgs, buildRgArgs, type FdParams, type RgParams } from "./args.ts"; -import type { ResolvedBinary, ToolName } from "./binaries.ts"; -import { runSearch, type SearchProcessResult } from "./process.ts"; - -export async function searchWithBinary( - tool: ToolName, - binary: ResolvedBinary, - params: FdParams | RgParams, - cwd: string, - signal?: AbortSignal, - timeoutMs?: number, -): Promise { - const args = tool === "fd" ? buildFdArgs(params as FdParams, cwd) : buildRgArgs(params as RgParams, cwd); - return runSearch({ tool, command: binary.path, args, cwd, signal, timeoutMs }); -} diff --git a/packages/pi-ext/package.json b/packages/pi-ext/package.json index 201486b..fa96068 100644 --- a/packages/pi-ext/package.json +++ b/packages/pi-ext/package.json @@ -61,7 +61,7 @@ "homepage": "https://github.com/iurysza/pi-extensions/tree/main/packages/pi-ext#readme", "scripts": { "sem:evaluate": "node extensions/pi-sem/bin/sem-eval.mjs", - "test": "npm run test:pi-sem && npm run test:tool-presentation && node --experimental-strip-types --test tests/tool-trim/tool-trim.test.ts && node --test tests/leader-key/favourite-models.test.mjs tests/leader-key/skill-editor.test.mjs tests/chat-to-md/chat-to-md.test.mjs tests/handoff/dependency-contract.test.mjs tests/startup-screen/startup-screen.test.mjs tests/file-search/*.test.mjs", + "test": "npm run test:pi-sem && npm run test:tool-presentation && node --experimental-strip-types --test tests/tool-trim/tool-trim.test.ts && node --test tests/leader-key/favourite-models.test.mjs tests/leader-key/skill-editor.test.mjs tests/chat-to-md/chat-to-md.test.mjs tests/handoff/dependency-contract.test.mjs tests/startup-screen/startup-screen.test.mjs", "test:pi-sem": "node --test tests/pi-sem/core.test.mjs tests/review/sem-guidance.test.mjs", "test:tool-presentation": "node scripts/test-tool-presentation.mjs", "typecheck": "tsc --noEmit -p tsconfig.json", @@ -73,7 +73,6 @@ "extensions": [ "./extensions/custom-footer/custom-footer.ts", "./extensions/startup-screen/index.ts", - "./extensions/file-search/index.ts", "./extensions/review/review.ts", "./extensions/leader-key/index.ts", "./extensions/chat-to-md/index.ts", diff --git a/packages/pi-ext/tests/file-search/file-search.test.mjs b/packages/pi-ext/tests/file-search/file-search.test.mjs deleted file mode 100644 index e985b35..0000000 --- a/packages/pi-ext/tests/file-search/file-search.test.mjs +++ /dev/null @@ -1,230 +0,0 @@ -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { describe, it } from "node:test"; -import { discoverAndLoadExtensions } from "@earendil-works/pi-coding-agent"; -import fileSearch from "../../extensions/file-search/index.ts"; -import { buildFdArgs, buildRgArgs, normalizeExtension, normalizePath } from "../../extensions/file-search/src/args.ts"; -import { - ARCHIVE_MAX_BYTES, - FD_DEFAULT_LIMIT, - FD_MAX_LIMIT, - PREVIEW_MAX_BYTES, - PREVIEW_MAX_LINES, - RG_DEFAULT_LIMIT, - RG_MAX_CONTEXT, -} from "../../extensions/file-search/src/limits.ts"; -import { PreviewAccumulator, truncationNotice } from "../../extensions/file-search/src/output.ts"; -import { - BinaryResolvers, - RELEASES, - downloadPinned, - installManaged, - managedBinDir, - resolveBinary, - selectRelease, -} from "../../extensions/file-search/src/binaries.ts"; - -function response(status, body = Buffer.alloc(0), headers = {}) { - return { status, headers, body: (async function* () { if (body.length) yield body; })() }; -} - -function sha256(buffer) { return createHash("sha256").update(buffer).digest("hex"); } - -describe("file-search argument contracts", () => { - it("normalizes model paths and extensions", () => { - assert.equal(normalizePath("@@~/src", "/work", "/home/me"), "/home/me/src"); - assert.equal(normalizePath("@../other", "/work/repo", "/home/me"), "/work/other"); - assert.equal(normalizePath(undefined, "/work/repo", "/home/me"), "/work/repo"); - assert.equal(normalizeExtension("..ts"), "ts"); - }); - - it("builds fd argv with every option and hostile patterns after --", () => { - assert.deepEqual(buildFdArgs({ pattern: "--exec", path: "@src", type: "file", extension: ".ts", glob: true, hidden: true, maxDepth: 99, limit: 99_999 }, "/repo", "/home"), [ - "--color", "never", "--max-results", String(FD_MAX_LIMIT), "--type", "f", "--extension", "ts", "--glob", "--hidden", "--max-depth", "64", "--", "--exec", "/repo/src", - ]); - assert.deepEqual(buildFdArgs({}, "/repo", "/home"), ["--color", "never", "--max-results", String(FD_DEFAULT_LIMIT), "--", ".", "/repo"]); - }); - - it("builds rg argv with smart and forced case, literal mode, and bounded values", () => { - assert.deepEqual(buildRgArgs({ pattern: "--files", path: "@~/code", glob: "*.ts", type: "ts", case: "insensitive", literal: true, hidden: true, context: 99, maxCount: -4 }, "/repo", "/home"), [ - "--color", "never", "--line-number", "--with-filename", "--max-count", "1", "--ignore-case", "--glob", "*.ts", "--type", "ts", "--fixed-strings", "--hidden", "--context", String(RG_MAX_CONTEXT), "--", "--files", "/home/code", - ]); - assert.ok(buildRgArgs({ pattern: "needle" }, "/repo").includes("--smart-case")); - assert.ok(buildRgArgs({ pattern: "needle", case: "sensitive" }, "/repo").includes("--case-sensitive")); - assert.equal(buildRgArgs({ pattern: "needle" }, "/repo")[5], String(RG_DEFAULT_LIMIT)); - }); -}); - -describe("bounded preview accounting", () => { - it("retains a UTF-8-safe head and reports both limits and observed output", () => { - const output = new PreviewAccumulator(); - const value = Buffer.from(`${"é".repeat(PREVIEW_MAX_BYTES)}\n${"x\n".repeat(PREVIEW_MAX_LINES)}`); - for (let offset = 0; offset < value.length; offset += 17) output.push(value.subarray(offset, offset + 17)); - const summary = output.summary(); - assert.equal(summary.truncated, true); - assert.ok(!summary.preview.endsWith("�")); - assert.ok(Buffer.byteLength(summary.preview) <= PREVIEW_MAX_BYTES); - const notice = truncationNotice(summary, "/tmp/output.txt"); - assert.match(notice, /2,000 lines\/50,000 bytes/); - assert.match(notice, /Observed .* lines\/.* bytes/); - assert.match(notice, /\/tmp\/output.txt/); - }); -}); - -describe("release selection and binary resolution", () => { - it("pins all eight supported assets below the 25 MB tripwire", () => { - assert.equal(Object.keys(RELEASES).length, 8); - for (const tool of ["fd", "rg"]) for (const platform of ["darwin", "linux"]) for (const arch of ["arm64", "x64"]) { - const asset = selectRelease(tool, platform, arch); - assert.match(asset.url, /^https:\/\/github\.com\//); - assert.match(asset.sha256, /^[a-f0-9]{64}$/); - assert.ok(asset.bytes < ARCHIVE_MAX_BYTES); - } - assert.throws(() => selectRelease("fd", "win32", "x64"), /no managed fallback.*win32\/x64/); - }); - - it("tries system names then the managed fallback and never installs offline", async () => { - const seen = []; - const binDir = "/managed/bin"; - const resolved = await resolveBinary("fd", { binDir, env: { PI_OFFLINE: "1" }, probe: async (name) => { seen.push(name); return name === `${binDir}/fd`; }, installer: async () => { throw new Error("network used"); } }); - assert.deepEqual(seen, ["fd", "fdfind", `${binDir}/fd`]); - assert.equal(resolved.source, "fallback"); - await assert.rejects(resolveBinary("rg", { binDir, env: { PI_OFFLINE: "1" }, probe: async () => false, installer: async () => { throw new Error("network used"); } }), /rg.*PI_OFFLINE=1.*install rg manually/); - }); - - it("caches each resolver independently", async () => { - const probes = []; - const resolvers = new BinaryResolvers({ binDir: "/managed", env: { PI_OFFLINE: "1" }, probe: async (name) => { probes.push(name); return name === "rg"; } }); - await assert.rejects(resolvers.resolve("fd"), /fd is unavailable/); - assert.equal((await resolvers.resolve("rg")).path, "rg"); - assert.equal((await resolvers.resolve("rg")).path, "rg"); - assert.equal(probes.filter((name) => name === "rg").length, 1); - }); - - it("derives managed paths from PI_CODING_AGENT_DIR", () => { - assert.equal(managedBinDir({ PI_CODING_AGENT_DIR: "~/custom" }, "/home/me"), "/home/me/custom/bin"); - }); -}); - -describe("secure pinned downloads", () => { - it("follows bounded HTTPS redirects and verifies the streamed digest", async () => { - const directory = await mkdtemp(join(tmpdir(), "download-test-")); - try { - const body = Buffer.from("verified archive"); - const asset = { tool: "fd", version: "test", url: "https://example.test/start", sha256: sha256(body), bytes: body.length, member: "root/fd" }; - const seen = []; - const bytes = await downloadPinned(asset, join(directory, "archive"), { fetcher: async (url) => { seen.push(url.href); return seen.length === 1 ? response(302, Buffer.alloc(0), { location: "https://cdn.example.test/archive" }) : response(200, body, { "content-length": String(body.length) }); } }); - assert.equal(bytes, body.length); - assert.deepEqual(seen, ["https://example.test/start", "https://cdn.example.test/archive"]); - assert.deepEqual(await readFile(join(directory, "archive")), body); - } finally { await rm(directory, { recursive: true, force: true }); } - }); - - it("rejects insecure redirects, redirect overflow, size excess, hash mismatch, and timeout", async () => { - const directory = await mkdtemp(join(tmpdir(), "download-reject-")); - const asset = { tool: "rg", version: "test", url: "https://example.test/start", sha256: sha256(Buffer.from("good")), bytes: 4, member: "root/rg" }; - try { - await assert.rejects(downloadPinned(asset, join(directory, "a"), { fetcher: async () => response(302, Buffer.alloc(0), { location: "http://bad.test/file" }) }), /non-HTTPS/); - await assert.rejects(downloadPinned(asset, join(directory, "b"), { maxRedirects: 1, fetcher: async () => response(302, Buffer.alloc(0), { location: "https://example.test/again" }) }), /redirect limit 1 exceeded; requested 2/); - await assert.rejects(downloadPinned(asset, join(directory, "c"), { maxBytes: 3, fetcher: async () => response(200, Buffer.from("four"), { "content-length": "4" }) }), /byte limit 3 exceeded; requested 4/); - await assert.rejects(downloadPinned(asset, join(directory, "d"), { maxBytes: 3, fetcher: async () => response(200, Buffer.from("four")) }), /byte limit 3 exceeded; observed 4/); - await assert.rejects(downloadPinned(asset, join(directory, "e"), { fetcher: async () => response(200, Buffer.from("evil")) }), /SHA-256 mismatch.*expected.*observed/); - await assert.rejects(downloadPinned(asset, join(directory, "f"), { fetcher: async () => response(302), maxRedirects: 2 }), /redirect 302 has no location/); - await assert.rejects(downloadPinned(asset, join(directory, "g"), { timeoutMs: 20, fetcher: async (_url, signal) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true })) }), /download timeout limit 20ms exceeded/); - } finally { await rm(directory, { recursive: true, force: true }); } - }); - - it("extracts the exact member, sets executable mode, and atomically replaces only its tool", async () => { - const directory = await mkdtemp(join(tmpdir(), "install-test-")); - try { - const source = join(directory, "source"); - const member = "fixture/fd"; - await mkdir(join(source, "fixture"), { recursive: true }); - await writeFile(join(source, member), "#!/bin/sh\necho fd test\n", { mode: 0o755 }); - const archive = join(directory, "fixture.tar.gz"); - assert.equal(spawnSync("tar", ["-czf", archive, "-C", source, "fixture"]).status, 0); - const body = await readFile(archive); - const binDir = join(directory, "bin"); - await mkdir(binDir); - await writeFile(join(binDir, "fd"), "old"); - await writeFile(join(binDir, "rg"), "untouched"); - const asset = { tool: "fd", version: "test", url: "https://example.test/fd.tar.gz", sha256: sha256(body), bytes: body.length, member }; - const result = await installManaged("fd", { asset, binDir, fetcher: async () => response(200, body, { "content-length": String(body.length) }), probe: async (path) => (await stat(path)).mode & 0o111 }); - assert.equal(result.source, "installed"); - assert.match(await readFile(join(binDir, "fd"), "utf8"), /fd test/); - assert.equal(await readFile(join(binDir, "rg"), "utf8"), "untouched"); - assert.equal((await stat(join(binDir, "fd"))).mode & 0o777, 0o755); - const installed = await readFile(join(binDir, "fd")); - await assert.rejects(installManaged("fd", { asset, binDir, fetcher: async () => response(200, body), probe: async () => false }), /failed its --version probe/); - assert.deepEqual(await readFile(join(binDir, "fd")), installed); - const invalid = Buffer.from("not a tar archive"); - await assert.rejects(installManaged("fd", { asset: { ...asset, sha256: sha256(invalid), bytes: invalid.length }, binDir, fetcher: async () => response(200, invalid), probe: async () => true }), /extraction failed/); - } finally { await rm(directory, { recursive: true, force: true }); } - }); -}); - -describe("Pi registration", () => { - it("registers strict fd and rg tools without resolving binaries during factory load", () => { - const tools = new Map(); - const handlers = new Map(); - fileSearch({ registerTool: (tool) => tools.set(tool.name, tool), on: (event, handler) => handlers.set(event, handler) }); - assert.deepEqual([...tools.keys()], ["fd", "rg"]); - assert.equal(tools.get("fd").parameters.additionalProperties, false); - assert.equal(tools.get("rg").parameters.required.includes("pattern"), true); - assert.match(tools.get("fd").promptGuidelines.join(" "), /fd.*file-name.*rg.*contents.*bash/); - assert.match(tools.get("rg").promptGuidelines.join(" "), /literal=true.*bash/); - assert.equal(typeof handlers.get("session_start"), "function"); - const theme = { fg: (_role, text) => text, bold: (text) => text }; - assert.match(tools.get("fd").renderCall({ pattern: "*.ts", path: "src" }, theme, {}).render(200).join(""), /fd .*\.ts.*src/); - assert.match(tools.get("rg").renderResult({ content: [{ type: "text", text: "No matches found." }], details: { tool: "rg", kind: "no-matches" } }, {}, theme, {}).render(200).join(""), /rg: no matches/); - assert.match(tools.get("fd").renderResult({ content: [{ type: "text", text: "head\nFull output: \/tmp\/all" }], details: { tool: "fd", kind: "matches", totalLines: 2001, totalBytes: 60000, truncated: true, outputPath: "/tmp/all" } }, {}, theme, {}).render(300).join(""), /truncated.*\/tmp\/all/); - assert.match(tools.get("fd").renderResult({ content: [{ type: "text", text: "head\nFull output: \/tmp\/all" }], details: { tool: "fd", kind: "matches" } }, { expanded: true }, theme, {}).render(300).join("\n"), /head.*Full output/s); - }); - - it("warms tools independently and notifies only installs or affected failures", async () => { - const tools = new Map(); - const handlers = new Map(); - const notifications = []; - fileSearch({ registerTool: (tool) => tools.set(tool.name, tool), on: (event, handler) => handlers.set(event, handler) }, { - resolve: async (tool) => tool === "fd" - ? { tool, path: "/managed/fd", source: "installed", version: "10.4.2" } - : Promise.reject(new Error("offline missing")), - }); - await handlers.get("session_start")({}, { ui: { notify: (...args) => notifications.push(args) } }); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual([...tools.keys()], ["fd", "rg"]); - assert.deepEqual(notifications, [ - ["fd 10.4.2 installed at /managed/fd", "info"], - ["rg setup failed: offline missing", "warning"], - ]); - }); - - it("labels execution failures with the affected tool", async () => { - const tools = new Map(); - fileSearch({ registerTool: (tool) => tools.set(tool.name, tool), on() {} }, { - resolve: async (tool) => ({ tool, path: "/definitely/missing/file-search-binary", source: "system" }), - }); - await assert.rejects(tools.get("rg").execute("call", { pattern: "x" }, new AbortController().signal, undefined, { cwd: process.cwd() }), /rg execution failed:.*ENOENT/); - }); - - it("loads through Pi's official loader in offline mode", async () => { - const entry = fileURLToPath(new URL("../../extensions/file-search/index.ts", import.meta.url)); - const previous = process.env.PI_OFFLINE; - process.env.PI_OFFLINE = "1"; - try { - const loaded = await discoverAndLoadExtensions([entry], process.cwd(), join(tmpdir(), "pi-loader-empty")); - assert.deepEqual(loaded.errors, []); - const extension = loaded.extensions.find((item) => item.tools.has("fd")); - assert.ok(extension); - assert.deepEqual([...extension.tools.keys()], ["fd", "rg"]); - } finally { - if (previous === undefined) delete process.env.PI_OFFLINE; - else process.env.PI_OFFLINE = previous; - } - }); -}); diff --git a/packages/pi-ext/tests/file-search/process.test.mjs b/packages/pi-ext/tests/file-search/process.test.mjs deleted file mode 100644 index 55270a1..0000000 --- a/packages/pi-ext/tests/file-search/process.test.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile, readdir, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { describe, it } from "node:test"; -import { PREVIEW_MAX_BYTES, STDERR_MAX_BYTES } from "../../extensions/file-search/src/limits.ts"; -import { runSearch } from "../../extensions/file-search/src/process.ts"; - -function nodeSearch(tool, source, options = {}) { - return runSearch({ tool, command: process.execPath, args: ["-e", source], cwd: process.cwd(), ...options }); -} - -async function spillDirectories(tool) { - return (await readdir(tmpdir())).filter((entry) => entry.startsWith(`pi-${tool}-search-`)).sort(); -} - -describe("bounded search process execution", () => { - it("streams normal output and removes its spill directory", async () => { - const before = await spillDirectories("fd"); - const result = await nodeSearch("fd", "process.stdout.write('one\\n'); setTimeout(() => process.stdout.write('two\\n'), 10)"); - assert.equal(result.kind, "matches"); - assert.equal(result.text, "one\ntwo\n"); - assert.equal(result.outputPath, undefined); - assert.deepEqual(await spillDirectories("fd"), before); - }); - - it("retains complete spill output only when the returned head is truncated", async () => { - const size = PREVIEW_MAX_BYTES + 12_345; - const result = await nodeSearch("fd", `process.stdout.write(Buffer.alloc(${size}, 120))`); - assert.equal(result.truncated, true); - assert.ok(Buffer.byteLength(result.preview) <= PREVIEW_MAX_BYTES); - assert.match(result.text, /Output truncated at 2,000 lines\/50,000 bytes/); - assert.equal((await readFile(result.outputPath)).length, size); - await rm(result.outputPath.slice(0, result.outputPath.lastIndexOf("/")), { recursive: true, force: true }); - }); - - it("classifies ripgrep exit 1 without output as a normal no-match", async () => { - const result = await nodeSearch("rg", "process.exitCode = 1"); - assert.equal(result.kind, "no-matches"); - assert.equal(result.text, "No matches found."); - }); - - it("bounds stderr and reports non-zero tool errors", async () => { - await assert.rejects(nodeSearch("rg", `process.stderr.write(Buffer.alloc(${STDERR_MAX_BYTES + 1000}, 101)); process.exitCode = 2`), (error) => { - assert.match(error.message, /rg search failed/); - assert.match(error.message, /stderr limit 65536 bytes reached/); - assert.ok(error.message.length < STDERR_MAX_BYTES + 200); - return true; - }); - }); - - it("terminates and cleans up on cancellation", async () => { - const before = await spillDirectories("fd"); - const controller = new AbortController(); - setTimeout(() => controller.abort(), 80); - const started = Date.now(); - await assert.rejects(nodeSearch("fd", "setInterval(() => process.stdout.write('still alive\\n'), 20)", { signal: controller.signal, timeoutMs: 5_000 }), /fd search cancelled/); - assert.ok(Date.now() - started < 2_000); - assert.deepEqual(await spillDirectories("fd"), before); - }); - - it("reports the configured timeout and cleans up", async () => { - const before = await spillDirectories("rg"); - await assert.rejects(nodeSearch("rg", "setInterval(() => {}, 1000)", { timeoutMs: 75 }), /rg search timeout limit 75ms exceeded/); - assert.deepEqual(await spillDirectories("rg"), before); - }); -});