Added nono profiles & orca integration - Added some extensions to start working on
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
import { NodeServices } from "@effect/platform-node";
|
||||
import { assert, it } from "@effect/vitest";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Effect, FileSystem } from "effect";
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
|
||||
import {
|
||||
buildFdArgs,
|
||||
buildRgArgs,
|
||||
FD_DEFAULT_LIMIT,
|
||||
normalizeSearchPath,
|
||||
} from "./src/args.ts";
|
||||
import {
|
||||
FD_INTEL_DARWIN_VERSION,
|
||||
InstallError,
|
||||
readBoundedResponse,
|
||||
releaseAsset,
|
||||
resolveBinary,
|
||||
TOOL_SPECS,
|
||||
UnsupportedPlatformError,
|
||||
type BinaryEnv,
|
||||
type ReleaseAsset,
|
||||
type ResolvedBinary,
|
||||
} from "./src/binaries.ts";
|
||||
import { formatCapturedOutput, formatOutput } from "./src/output.ts";
|
||||
import { executeSearchProcess } from "./src/process.ts";
|
||||
import { installNotifications, makeBinaryInitializers } from "./index.ts";
|
||||
|
||||
// --- argument construction -------------------------------------------------
|
||||
|
||||
it("fd args: defaults list everything with the default limit", () => {
|
||||
assert.deepEqual(buildFdArgs({}), [
|
||||
"--color=never",
|
||||
"--max-results",
|
||||
String(FD_DEFAULT_LIMIT),
|
||||
"--",
|
||||
"",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fd args: all options are translated and pattern stays behind --", () => {
|
||||
const args = buildFdArgs({
|
||||
pattern: "-rf",
|
||||
path: "@src",
|
||||
type: "file",
|
||||
extension: ".ts",
|
||||
glob: true,
|
||||
hidden: true,
|
||||
max_depth: 3,
|
||||
limit: 50,
|
||||
});
|
||||
assert.deepEqual(args, [
|
||||
"--color=never",
|
||||
"--hidden",
|
||||
"--glob",
|
||||
"--type",
|
||||
"f",
|
||||
"--extension",
|
||||
"ts",
|
||||
"--max-depth",
|
||||
"3",
|
||||
"--max-results",
|
||||
"50",
|
||||
"--",
|
||||
"-rf",
|
||||
"src",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fd args: out-of-range values are clamped", () => {
|
||||
const args = buildFdArgs({ max_depth: 500, limit: 1_000_000 });
|
||||
assert.deepEqual(args, [
|
||||
"--color=never",
|
||||
"--max-depth",
|
||||
"64",
|
||||
"--max-results",
|
||||
"10000",
|
||||
"--",
|
||||
"",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rg args: defaults use smart-case and safe separators", () => {
|
||||
assert.deepEqual(buildRgArgs({ pattern: "--help" }), [
|
||||
"--line-number",
|
||||
"--color=never",
|
||||
"--no-heading",
|
||||
"--with-filename",
|
||||
"--smart-case",
|
||||
"--max-count",
|
||||
"100",
|
||||
"--",
|
||||
"--help",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rg args: all options are translated", () => {
|
||||
const args = buildRgArgs({
|
||||
pattern: "TODO",
|
||||
path: "@lib",
|
||||
glob: "*.ts",
|
||||
file_type: "ts",
|
||||
case_sensitive: true,
|
||||
fixed_strings: true,
|
||||
hidden: true,
|
||||
context: 2,
|
||||
limit: 10,
|
||||
});
|
||||
assert.deepEqual(args, [
|
||||
"--line-number",
|
||||
"--color=never",
|
||||
"--no-heading",
|
||||
"--with-filename",
|
||||
"--case-sensitive",
|
||||
"--fixed-strings",
|
||||
"--hidden",
|
||||
"--context",
|
||||
"2",
|
||||
"--glob",
|
||||
"*.ts",
|
||||
"--type",
|
||||
"ts",
|
||||
"--max-count",
|
||||
"10",
|
||||
"--",
|
||||
"TODO",
|
||||
"lib",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rg args: case_sensitive false forces ignore-case", () => {
|
||||
const args = buildRgArgs({ pattern: "x", case_sensitive: false });
|
||||
assert.isTrue(args.includes("--ignore-case"));
|
||||
assert.isFalse(args.includes("--smart-case"));
|
||||
});
|
||||
|
||||
it("path normalization strips leading @ and expands ~", () => {
|
||||
assert.equal(normalizeSearchPath("@src/lib"), "src/lib");
|
||||
assert.equal(normalizeSearchPath("~"), homedir());
|
||||
assert.equal(normalizeSearchPath("~/projects"), join(homedir(), "projects"));
|
||||
assert.equal(normalizeSearchPath(" plain "), "plain");
|
||||
});
|
||||
|
||||
// --- binary resolution -----------------------------------------------------
|
||||
|
||||
function makeEnv(options: {
|
||||
available?: string[];
|
||||
installShouldFail?: boolean;
|
||||
}): BinaryEnv & { installs: ReleaseAsset[]; probes: string[] } {
|
||||
const installs: ReleaseAsset[] = [];
|
||||
const probes: string[] = [];
|
||||
const installed = new Set<string>();
|
||||
return {
|
||||
installs,
|
||||
probes,
|
||||
probe: (command) =>
|
||||
Effect.sync(() => {
|
||||
probes.push(command);
|
||||
return (
|
||||
(options.available ?? []).includes(command) || installed.has(command)
|
||||
);
|
||||
}),
|
||||
install: (asset, destination) => {
|
||||
if (options.installShouldFail) {
|
||||
return Effect.fail(new InstallError({ message: "network down" }));
|
||||
}
|
||||
return Effect.sync(() => {
|
||||
installs.push(asset);
|
||||
installed.add(destination);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const darwinArm = { os: "darwin", arch: "arm64" } as const;
|
||||
|
||||
it.effect("binary resolution: system fd wins and nothing is installed", () =>
|
||||
Effect.gen(function* () {
|
||||
const env = makeEnv({ available: ["fd"] });
|
||||
const resolved = yield* resolveBinary(
|
||||
TOOL_SPECS.fd,
|
||||
"/repo/bin",
|
||||
darwinArm,
|
||||
env,
|
||||
);
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
tool: "fd",
|
||||
command: "fd",
|
||||
source: "system",
|
||||
});
|
||||
assert.equal(env.installs.length, 0);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect("binary resolution: fdfind is accepted as a system fd", () =>
|
||||
Effect.gen(function* () {
|
||||
const env = makeEnv({ available: ["fdfind"] });
|
||||
const resolved = yield* resolveBinary(
|
||||
TOOL_SPECS.fd,
|
||||
"/repo/bin",
|
||||
darwinArm,
|
||||
env,
|
||||
);
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
tool: "fd",
|
||||
command: "fdfind",
|
||||
source: "system",
|
||||
});
|
||||
assert.equal(env.installs.length, 0);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect("binary resolution: existing bin fallback is used silently", () =>
|
||||
Effect.gen(function* () {
|
||||
const env = makeEnv({ available: ["/repo/bin/rg"] });
|
||||
const resolved = yield* resolveBinary(
|
||||
TOOL_SPECS.rg,
|
||||
"/repo/bin",
|
||||
darwinArm,
|
||||
env,
|
||||
);
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
tool: "rg",
|
||||
command: "/repo/bin/rg",
|
||||
source: "bundled",
|
||||
});
|
||||
assert.equal(env.installs.length, 0);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"binary resolution: missing everywhere triggers exactly one install",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const env = makeEnv({ available: [] });
|
||||
const resolved = yield* resolveBinary(
|
||||
TOOL_SPECS.rg,
|
||||
"/repo/bin",
|
||||
darwinArm,
|
||||
env,
|
||||
);
|
||||
|
||||
assert.equal(resolved.source, "installed");
|
||||
assert.equal(resolved.command, "/repo/bin/rg");
|
||||
assert.equal(env.installs.length, 1);
|
||||
assert.match(
|
||||
env.installs[0].url,
|
||||
/^https:\/\/github\.com\/BurntSushi\/ripgrep\//,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect("binary resolution: install failure surfaces a typed error", () =>
|
||||
Effect.gen(function* () {
|
||||
const env = makeEnv({ available: [], installShouldFail: true });
|
||||
const error = yield* Effect.flip(
|
||||
resolveBinary(TOOL_SPECS.fd, "/repo/bin", darwinArm, env),
|
||||
);
|
||||
|
||||
assert.instanceOf(error, InstallError);
|
||||
assert.equal(error.message, "network down");
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"binary resolution: unsupported platform fails without installing",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const env = makeEnv({ available: [] });
|
||||
const error = yield* Effect.flip(
|
||||
resolveBinary(
|
||||
TOOL_SPECS.fd,
|
||||
"/repo/bin",
|
||||
{ os: "linux", arch: "s390x" },
|
||||
env,
|
||||
),
|
||||
);
|
||||
|
||||
assert.instanceOf(error, UnsupportedPlatformError);
|
||||
assert.equal(env.installs.length, 0);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect("binary resolution: one failed tool does not disable the other", () =>
|
||||
Effect.gen(function* () {
|
||||
const env = makeEnv({
|
||||
available: ["rg"],
|
||||
installShouldFail: true,
|
||||
});
|
||||
const initializers = makeBinaryInitializers("/repo/bin", darwinArm, env);
|
||||
|
||||
const fdError = yield* Effect.flip(initializers.fd);
|
||||
const rg = yield* initializers.rg;
|
||||
|
||||
assert.instanceOf(fdError, InstallError);
|
||||
assert.deepEqual(rg, { tool: "rg", command: "rg", source: "system" });
|
||||
}),
|
||||
);
|
||||
|
||||
it("release assets cover macOS and Linux on arm64 and x64 over HTTPS", () => {
|
||||
for (const os of ["darwin", "linux"] as const) {
|
||||
for (const arch of ["arm64", "x64"] as const) {
|
||||
for (const tool of ["fd", "rg"] as const) {
|
||||
const asset = releaseAsset(tool, { os, arch });
|
||||
assert.isDefined(asset, `${tool} ${os}/${arch}`);
|
||||
assert.match(asset.url, /^https:\/\//);
|
||||
assert.isTrue(asset.url.endsWith(asset.fileName));
|
||||
assert.match(asset.sha256, /^[a-f0-9]{64}$/);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("linux assets use statically linked musl builds", () => {
|
||||
const asset = releaseAsset("fd", { os: "linux", arch: "x64" });
|
||||
assert.isTrue(asset?.url.includes("unknown-linux-musl"));
|
||||
});
|
||||
|
||||
it("Intel macOS uses the latest fd release that publishes that target", () => {
|
||||
const asset = releaseAsset("fd", { os: "darwin", arch: "x64" });
|
||||
assert.equal(asset?.version, FD_INTEL_DARWIN_VERSION);
|
||||
});
|
||||
|
||||
it.effect(
|
||||
"bounded downloads reject oversized declared and streamed bodies",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = HttpClientRequest.get(
|
||||
"https://example.com/archive.tar.gz",
|
||||
);
|
||||
const declared = HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response("small", {
|
||||
headers: { "content-length": "100" },
|
||||
}),
|
||||
);
|
||||
const declaredError = yield* Effect.flip(
|
||||
readBoundedResponse(declared, 10),
|
||||
);
|
||||
assert.match(declaredError.message, /size limit/);
|
||||
|
||||
const streamed = HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response("this body is too large"),
|
||||
);
|
||||
const streamedError = yield* Effect.flip(
|
||||
readBoundedResponse(streamed, 5),
|
||||
);
|
||||
assert.match(streamedError.message, /size limit/);
|
||||
}),
|
||||
);
|
||||
|
||||
// --- notification policy ----------------------------------------------------
|
||||
|
||||
it("notifications: only fresh installs notify", () => {
|
||||
const system: ResolvedBinary = {
|
||||
tool: "fd",
|
||||
command: "fd",
|
||||
source: "system",
|
||||
};
|
||||
const bundled: ResolvedBinary = {
|
||||
tool: "rg",
|
||||
command: "/repo/bin/rg",
|
||||
source: "bundled",
|
||||
};
|
||||
const installed: ResolvedBinary = {
|
||||
tool: "rg",
|
||||
command: "/repo/bin/rg",
|
||||
source: "installed",
|
||||
version: "15.2.0",
|
||||
};
|
||||
|
||||
assert.deepEqual(installNotifications([system, bundled]), []);
|
||||
const messages = installNotifications([system, installed]);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.match(messages[0], /downloaded rg 15\.2\.0/);
|
||||
});
|
||||
|
||||
// --- output truncation -------------------------------------------------------
|
||||
|
||||
it.effect("process output is streamed to a complete spill file", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* executeSearchProcess({
|
||||
command: process.execPath,
|
||||
args: ["-e", 'process.stdout.write("line\\n".repeat(3000))'],
|
||||
cwd: process.cwd(),
|
||||
tempPrefix: "pi-search-test-",
|
||||
});
|
||||
const formatted = formatCapturedOutput(result.output);
|
||||
|
||||
assert.equal(result.code, 0);
|
||||
assert.isTrue(formatted.truncated);
|
||||
assert.equal(formatted.lineCount, 3000);
|
||||
assert.match(formatted.text, /2000 of 3000 lines/);
|
||||
assert.isDefined(formatted.fullOutputPath);
|
||||
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const fullOutput = yield* fs.readFileString(formatted.fullOutputPath);
|
||||
assert.equal(fullOutput, "line\n".repeat(3000));
|
||||
yield* fs.remove(dirname(formatted.fullOutputPath), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}).pipe(Effect.provide(NodeServices.layer)),
|
||||
);
|
||||
|
||||
it("output: small results pass through untouched", async () => {
|
||||
const formatted = await formatOutput("a.ts\nb.ts\n", {
|
||||
tempPrefix: "pi-fd-",
|
||||
persistFullOutput: () => Promise.reject(new Error("should not persist")),
|
||||
});
|
||||
assert.equal(formatted.text, "a.ts\nb.ts");
|
||||
assert.equal(formatted.lineCount, 2);
|
||||
assert.isFalse(formatted.truncated);
|
||||
assert.isUndefined(formatted.fullOutputPath);
|
||||
});
|
||||
|
||||
it("output: oversized results are truncated and persisted", async () => {
|
||||
const bigOutput = Array.from({ length: 3000 }, (_, i) => `file-${i}.ts`).join(
|
||||
"\n",
|
||||
);
|
||||
let persisted: string | undefined;
|
||||
const formatted = await formatOutput(bigOutput, {
|
||||
tempPrefix: "pi-fd-",
|
||||
persistFullOutput: async (full) => {
|
||||
persisted = full;
|
||||
return "/tmp/fake/output.txt";
|
||||
},
|
||||
});
|
||||
assert.isTrue(formatted.truncated);
|
||||
assert.equal(formatted.fullOutputPath, "/tmp/fake/output.txt");
|
||||
assert.equal(persisted, bigOutput);
|
||||
assert.match(formatted.text, /\[Output truncated: 2000 of 3000 lines/);
|
||||
assert.match(
|
||||
formatted.text,
|
||||
/Full output saved to: \/tmp\/fake\/output\.txt\]/,
|
||||
);
|
||||
const shownLines = formatted.text.split("\n");
|
||||
assert.equal(shownLines[0], "file-0.ts");
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* file-search — first-class `fd` and `rg` tools for pi.
|
||||
*
|
||||
* On session start the extension resolves a usable binary for each tool:
|
||||
* a normally installed system binary is preferred (silently), then an
|
||||
* existing fallback in this repo's `bin/` directory (silently), and only
|
||||
* when neither exists is an official release downloaded into `bin/` — the
|
||||
* single case that shows a UI notification. Tools await that initialization
|
||||
* before executing, and report a clear error if it failed.
|
||||
*/
|
||||
|
||||
import { NodeServices } from "@effect/platform-node";
|
||||
import type {
|
||||
AgentToolResult,
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { Cause, Data, Effect, Exit } from "effect";
|
||||
import { Type } from "typebox";
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
buildFdArgs,
|
||||
buildRgArgs,
|
||||
FD_MAX_DEPTH_LIMIT,
|
||||
FD_MAX_LIMIT,
|
||||
RG_MAX_CONTEXT,
|
||||
RG_MAX_COUNT_LIMIT,
|
||||
} from "./src/args.ts";
|
||||
import {
|
||||
currentTarget,
|
||||
liveBinaryEnv,
|
||||
repositoryBinDir,
|
||||
resolveBinary,
|
||||
TOOL_SPECS,
|
||||
type BinaryEnv,
|
||||
type BinarySource,
|
||||
type PlatformTarget,
|
||||
type ResolvedBinary,
|
||||
} from "./src/binaries.ts";
|
||||
import { formatCapturedOutput, type CapturedOutput } from "./src/output.ts";
|
||||
import {
|
||||
FD_PARAMETER_DESCRIPTIONS,
|
||||
FD_PROMPT_GUIDELINES,
|
||||
FD_PROMPT_SNIPPET,
|
||||
FD_TOOL_DESCRIPTION,
|
||||
RG_PARAMETER_DESCRIPTIONS,
|
||||
RG_PROMPT_GUIDELINES,
|
||||
RG_PROMPT_SNIPPET,
|
||||
RG_TOOL_DESCRIPTION,
|
||||
} from "./src/prompt.ts";
|
||||
import { discardCapturedOutput, executeSearchProcess } from "./src/process.ts";
|
||||
|
||||
export function makeBinaryInitializers(
|
||||
binDir: string,
|
||||
target: PlatformTarget,
|
||||
env: BinaryEnv,
|
||||
) {
|
||||
return {
|
||||
fd: Effect.runSync(
|
||||
Effect.cached(resolveBinary(TOOL_SPECS.fd, binDir, target, env)),
|
||||
),
|
||||
rg: Effect.runSync(
|
||||
Effect.cached(resolveBinary(TOOL_SPECS.rg, binDir, target, env)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Human-readable install notice, shown only for fresh downloads. */
|
||||
export function installNotifications(binaries: readonly ResolvedBinary[]) {
|
||||
return binaries
|
||||
.filter((binary) => binary.source === "installed")
|
||||
.map(
|
||||
(binary) =>
|
||||
`file-search: no system ${binary.tool} found — downloaded ${binary.tool} ${binary.version ?? ""}`.trimEnd() +
|
||||
` to ${repositoryBinDir()}`,
|
||||
);
|
||||
}
|
||||
|
||||
class SearchError extends Data.TaggedError("SearchError")<{
|
||||
readonly message: string;
|
||||
}> {}
|
||||
|
||||
interface SearchOutcome {
|
||||
readonly output: CapturedOutput;
|
||||
readonly noMatches: boolean;
|
||||
readonly binarySource: BinarySource;
|
||||
}
|
||||
|
||||
export interface FdToolDetails {
|
||||
readonly binarySource: BinarySource;
|
||||
readonly matchCount: number;
|
||||
readonly truncated: boolean;
|
||||
readonly fullOutputPath?: string;
|
||||
}
|
||||
|
||||
export interface RgToolDetails {
|
||||
readonly binarySource: BinarySource;
|
||||
readonly outputLines: number;
|
||||
readonly truncated: boolean;
|
||||
readonly fullOutputPath?: string;
|
||||
}
|
||||
|
||||
const EXEC_TIMEOUT_MS = 60_000;
|
||||
|
||||
function causeMessage<E>(cause: Cause.Cause<E>) {
|
||||
const [first] = Cause.prettyErrors(cause);
|
||||
return first?.message ?? Cause.pretty(cause);
|
||||
}
|
||||
|
||||
function unwrapToolExit<A, E>(exit: Exit.Exit<A, E>, tool: "fd" | "rg") {
|
||||
if (Exit.isSuccess(exit)) return exit.value;
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) {
|
||||
throw new Error(`${tool} search was cancelled.`);
|
||||
}
|
||||
throw new Error(causeMessage(exit.cause));
|
||||
}
|
||||
|
||||
export default function fileSearchTools(pi: ExtensionAPI) {
|
||||
let notified = false;
|
||||
|
||||
const binDir = repositoryBinDir();
|
||||
const target = currentTarget();
|
||||
const initializers = makeBinaryInitializers(binDir, target, liveBinaryEnv);
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const exit = await Effect.runPromiseExit(
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* Effect.all(
|
||||
{
|
||||
fd: Effect.exit(initializers.fd),
|
||||
rg: Effect.exit(initializers.rg),
|
||||
},
|
||||
{ concurrency: "unbounded" },
|
||||
);
|
||||
if (!ctx.hasUI || notified) return;
|
||||
|
||||
notified = true;
|
||||
for (const tool of ["fd", "rg"] as const) {
|
||||
const toolExit = initialized[tool];
|
||||
if (Exit.isSuccess(toolExit)) {
|
||||
for (const message of installNotifications([toolExit.value])) {
|
||||
ctx.ui.notify(message, "info");
|
||||
}
|
||||
} else {
|
||||
ctx.ui.notify(
|
||||
`file-search ${tool} setup failed: ${causeMessage(toolExit.cause)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (Exit.isFailure(exit) && ctx.hasUI && !notified) {
|
||||
notified = true;
|
||||
ctx.ui.notify(
|
||||
`file-search setup failed: ${causeMessage(exit.cause)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/** Await init, stream the binary output to disk, and classify its exit. */
|
||||
function runSearch(tool: "fd" | "rg", args: string[], ctx: ExtensionContext) {
|
||||
return Effect.gen(function* () {
|
||||
const binary = yield* initializers[tool];
|
||||
const result = yield* executeSearchProcess({
|
||||
command: binary.command,
|
||||
args,
|
||||
cwd: ctx.cwd,
|
||||
tempPrefix: `pi-${tool}-`,
|
||||
});
|
||||
|
||||
// ripgrep exits 1 for "no matches"; fd exits 0 even with no results.
|
||||
if (tool === "rg" && result.code === 1 && result.output.lineCount === 0) {
|
||||
return {
|
||||
output: result.output,
|
||||
noMatches: true,
|
||||
binarySource: binary.source,
|
||||
} satisfies SearchOutcome;
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
yield* discardCapturedOutput(result.output);
|
||||
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
||||
return yield* new SearchError({ message: `${tool} failed: ${detail}` });
|
||||
}
|
||||
return {
|
||||
output: result.output,
|
||||
noMatches: result.output.lineCount === 0,
|
||||
binarySource: binary.source,
|
||||
} satisfies SearchOutcome;
|
||||
}).pipe(
|
||||
Effect.timeout(EXEC_TIMEOUT_MS),
|
||||
Effect.mapError((error) => {
|
||||
if (error instanceof SearchError) return error;
|
||||
return new SearchError({
|
||||
message:
|
||||
error._tag === "TimeoutError"
|
||||
? `${tool} timed out.`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
});
|
||||
}),
|
||||
Effect.provide(NodeServices.layer),
|
||||
);
|
||||
}
|
||||
|
||||
pi.registerTool<ReturnType<typeof fdParameters>, FdToolDetails>({
|
||||
name: "fd",
|
||||
label: "Find Files",
|
||||
description: FD_TOOL_DESCRIPTION,
|
||||
promptSnippet: FD_PROMPT_SNIPPET,
|
||||
promptGuidelines: FD_PROMPT_GUIDELINES,
|
||||
parameters: fdParameters(),
|
||||
|
||||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||
const exit = await Effect.runPromiseExit(
|
||||
Effect.gen(function* () {
|
||||
const outcome = yield* runSearch("fd", buildFdArgs(params), ctx);
|
||||
if (outcome.noMatches) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No files found" }],
|
||||
details: {
|
||||
binarySource: outcome.binarySource,
|
||||
matchCount: 0,
|
||||
truncated: false,
|
||||
},
|
||||
} satisfies AgentToolResult<FdToolDetails>;
|
||||
}
|
||||
|
||||
const formatted = formatCapturedOutput(outcome.output);
|
||||
return {
|
||||
content: [{ type: "text", text: formatted.text }],
|
||||
details: {
|
||||
binarySource: outcome.binarySource,
|
||||
matchCount: formatted.lineCount,
|
||||
truncated: formatted.truncated,
|
||||
fullOutputPath: formatted.fullOutputPath,
|
||||
},
|
||||
} satisfies AgentToolResult<FdToolDetails>;
|
||||
}),
|
||||
signal ? { signal } : undefined,
|
||||
);
|
||||
return unwrapToolExit(exit, "fd");
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("fd "));
|
||||
text += theme.fg("accent", args.pattern ? `"${args.pattern}"` : "(all)");
|
||||
if (args.path) text += theme.fg("muted", ` in ${args.path}`);
|
||||
const flags = [
|
||||
args.type && `type=${args.type}`,
|
||||
args.extension && `ext=${args.extension}`,
|
||||
args.glob && "glob",
|
||||
args.hidden && "hidden",
|
||||
args.max_depth !== undefined && `depth≤${args.max_depth}`,
|
||||
].filter((flag): flag is string => typeof flag === "string");
|
||||
if (flags.length > 0) text += " " + theme.fg("dim", flags.join(" "));
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
if (isPartial) return new Text(theme.fg("warning", "Searching..."), 0, 0);
|
||||
const details = result.details;
|
||||
if (!details || details.matchCount === 0) {
|
||||
return new Text(theme.fg("dim", "No files found"), 0, 0);
|
||||
}
|
||||
let text = theme.fg(
|
||||
"success",
|
||||
`${details.matchCount} ${details.matchCount === 1 ? "entry" : "entries"}`,
|
||||
);
|
||||
if (details.truncated) text += theme.fg("warning", " (truncated)");
|
||||
if (expanded)
|
||||
text += expandedPreview(result, details.fullOutputPath, theme);
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool<ReturnType<typeof rgParameters>, RgToolDetails>({
|
||||
name: "rg",
|
||||
label: "Search Content",
|
||||
description: RG_TOOL_DESCRIPTION,
|
||||
promptSnippet: RG_PROMPT_SNIPPET,
|
||||
promptGuidelines: RG_PROMPT_GUIDELINES,
|
||||
parameters: rgParameters(),
|
||||
|
||||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||
const exit = await Effect.runPromiseExit(
|
||||
Effect.gen(function* () {
|
||||
const outcome = yield* runSearch("rg", buildRgArgs(params), ctx);
|
||||
if (outcome.noMatches) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No matches found" }],
|
||||
details: {
|
||||
binarySource: outcome.binarySource,
|
||||
outputLines: 0,
|
||||
truncated: false,
|
||||
},
|
||||
} satisfies AgentToolResult<RgToolDetails>;
|
||||
}
|
||||
|
||||
const formatted = formatCapturedOutput(outcome.output);
|
||||
return {
|
||||
content: [{ type: "text", text: formatted.text }],
|
||||
details: {
|
||||
binarySource: outcome.binarySource,
|
||||
outputLines: formatted.lineCount,
|
||||
truncated: formatted.truncated,
|
||||
fullOutputPath: formatted.fullOutputPath,
|
||||
},
|
||||
} satisfies AgentToolResult<RgToolDetails>;
|
||||
}),
|
||||
signal ? { signal } : undefined,
|
||||
);
|
||||
return unwrapToolExit(exit, "rg");
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
let text = theme.fg("toolTitle", theme.bold("rg "));
|
||||
text += theme.fg("accent", `"${args.pattern}"`);
|
||||
if (args.path) text += theme.fg("muted", ` in ${args.path}`);
|
||||
const flags = [
|
||||
args.glob && `glob=${args.glob}`,
|
||||
args.file_type && `type=${args.file_type}`,
|
||||
args.fixed_strings && "literal",
|
||||
args.hidden && "hidden",
|
||||
args.context !== undefined && `ctx=${args.context}`,
|
||||
].filter((flag): flag is string => typeof flag === "string");
|
||||
if (flags.length > 0) text += " " + theme.fg("dim", flags.join(" "));
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded, isPartial }, theme) {
|
||||
if (isPartial) return new Text(theme.fg("warning", "Searching..."), 0, 0);
|
||||
const details = result.details;
|
||||
if (!details || details.outputLines === 0) {
|
||||
return new Text(theme.fg("dim", "No matches found"), 0, 0);
|
||||
}
|
||||
let text = theme.fg(
|
||||
"success",
|
||||
`${details.outputLines} output ${details.outputLines === 1 ? "line" : "lines"}`,
|
||||
);
|
||||
if (details.truncated) text += theme.fg("warning", " (truncated)");
|
||||
if (expanded)
|
||||
text += expandedPreview(result, details.fullOutputPath, theme);
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const PREVIEW_LINES = 20;
|
||||
|
||||
interface ThemeLike {
|
||||
fg(color: string, text: string): string;
|
||||
}
|
||||
|
||||
function expandedPreview(
|
||||
result: { content: { type: string; text?: string }[] },
|
||||
fullOutputPath: string | undefined,
|
||||
theme: ThemeLike,
|
||||
) {
|
||||
let text = "";
|
||||
const content = result.content[0];
|
||||
if (content?.type === "text" && content.text) {
|
||||
const lines = content.text.split("\n");
|
||||
for (const line of lines.slice(0, PREVIEW_LINES)) {
|
||||
text += `\n${theme.fg("dim", line)}`;
|
||||
}
|
||||
if (lines.length > PREVIEW_LINES) {
|
||||
text += `\n${theme.fg("muted", `... ${lines.length - PREVIEW_LINES} more lines`)}`;
|
||||
}
|
||||
}
|
||||
if (fullOutputPath) {
|
||||
text += `\n${theme.fg("dim", `Full output: ${fullOutputPath}`)}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function fdParameters() {
|
||||
return Type.Object({
|
||||
pattern: Type.Optional(
|
||||
Type.String({ description: FD_PARAMETER_DESCRIPTIONS.pattern }),
|
||||
),
|
||||
path: Type.Optional(
|
||||
Type.String({ description: FD_PARAMETER_DESCRIPTIONS.path }),
|
||||
),
|
||||
type: Type.Optional(
|
||||
StringEnum(["file", "directory", "symlink"] as const, {
|
||||
description: FD_PARAMETER_DESCRIPTIONS.type,
|
||||
}),
|
||||
),
|
||||
extension: Type.Optional(
|
||||
Type.String({ description: FD_PARAMETER_DESCRIPTIONS.extension }),
|
||||
),
|
||||
glob: Type.Optional(
|
||||
Type.Boolean({ description: FD_PARAMETER_DESCRIPTIONS.glob }),
|
||||
),
|
||||
hidden: Type.Optional(
|
||||
Type.Boolean({ description: FD_PARAMETER_DESCRIPTIONS.hidden }),
|
||||
),
|
||||
max_depth: Type.Optional(
|
||||
Type.Integer({
|
||||
description: FD_PARAMETER_DESCRIPTIONS.max_depth,
|
||||
minimum: 1,
|
||||
maximum: FD_MAX_DEPTH_LIMIT,
|
||||
}),
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Integer({
|
||||
description: FD_PARAMETER_DESCRIPTIONS.limit,
|
||||
minimum: 1,
|
||||
maximum: FD_MAX_LIMIT,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function rgParameters() {
|
||||
return Type.Object({
|
||||
pattern: Type.String({ description: RG_PARAMETER_DESCRIPTIONS.pattern }),
|
||||
path: Type.Optional(
|
||||
Type.String({ description: RG_PARAMETER_DESCRIPTIONS.path }),
|
||||
),
|
||||
glob: Type.Optional(
|
||||
Type.String({ description: RG_PARAMETER_DESCRIPTIONS.glob }),
|
||||
),
|
||||
file_type: Type.Optional(
|
||||
Type.String({ description: RG_PARAMETER_DESCRIPTIONS.file_type }),
|
||||
),
|
||||
case_sensitive: Type.Optional(
|
||||
Type.Boolean({ description: RG_PARAMETER_DESCRIPTIONS.case_sensitive }),
|
||||
),
|
||||
fixed_strings: Type.Optional(
|
||||
Type.Boolean({ description: RG_PARAMETER_DESCRIPTIONS.fixed_strings }),
|
||||
),
|
||||
hidden: Type.Optional(
|
||||
Type.Boolean({ description: RG_PARAMETER_DESCRIPTIONS.hidden }),
|
||||
),
|
||||
context: Type.Optional(
|
||||
Type.Integer({
|
||||
description: RG_PARAMETER_DESCRIPTIONS.context,
|
||||
minimum: 0,
|
||||
maximum: RG_MAX_CONTEXT,
|
||||
}),
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Integer({
|
||||
description: RG_PARAMETER_DESCRIPTIONS.limit,
|
||||
minimum: 1,
|
||||
maximum: RG_MAX_COUNT_LIMIT,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Pure CLI argument construction for the fd and rg tools.
|
||||
*
|
||||
* Everything here is synchronous and side-effect free so the exact argv
|
||||
* passed to pi.exec can be asserted in tests. Patterns are always placed
|
||||
* after a `--` separator so user-controlled input can never be parsed as a
|
||||
* flag, and paths are normalized (leading `@`, `~` expansion) before use.
|
||||
*/
|
||||
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const FD_DEFAULT_LIMIT = 1000;
|
||||
export const FD_MAX_LIMIT = 10_000;
|
||||
export const FD_MAX_DEPTH_LIMIT = 64;
|
||||
export const RG_DEFAULT_COUNT_LIMIT = 100;
|
||||
export const RG_MAX_COUNT_LIMIT = 1000;
|
||||
export const RG_MAX_CONTEXT = 20;
|
||||
|
||||
/** Some models prefix path arguments with @; built-in tools strip it, so do we. */
|
||||
export function normalizeSearchPath(raw: string) {
|
||||
let path = raw.trim();
|
||||
if (path.startsWith("@")) path = path.slice(1);
|
||||
if (path === "~") return homedir();
|
||||
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
||||
return path;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(maximum, Math.max(minimum, Math.floor(value)));
|
||||
}
|
||||
|
||||
function optionalPath(raw: string | undefined) {
|
||||
if (raw === undefined) return undefined;
|
||||
const normalized = normalizeSearchPath(raw);
|
||||
return normalized === "" ? undefined : normalized;
|
||||
}
|
||||
|
||||
export type FdEntryType = "file" | "directory" | "symlink";
|
||||
|
||||
export interface FdToolParams {
|
||||
pattern?: string;
|
||||
path?: string;
|
||||
type?: FdEntryType;
|
||||
extension?: string;
|
||||
glob?: boolean;
|
||||
hidden?: boolean;
|
||||
max_depth?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const FD_TYPE_FLAGS: Record<FdEntryType, string> = {
|
||||
file: "f",
|
||||
directory: "d",
|
||||
symlink: "l",
|
||||
};
|
||||
|
||||
export function buildFdArgs(params: FdToolParams) {
|
||||
const args = ["--color=never"];
|
||||
if (params.hidden) args.push("--hidden");
|
||||
if (params.glob) args.push("--glob");
|
||||
if (params.type) args.push("--type", FD_TYPE_FLAGS[params.type]);
|
||||
if (params.extension) {
|
||||
args.push("--extension", params.extension.replace(/^\.+/, ""));
|
||||
}
|
||||
if (params.max_depth !== undefined) {
|
||||
args.push(
|
||||
"--max-depth",
|
||||
String(clamp(params.max_depth, 1, FD_MAX_DEPTH_LIMIT)),
|
||||
);
|
||||
}
|
||||
args.push(
|
||||
"--max-results",
|
||||
String(clamp(params.limit ?? FD_DEFAULT_LIMIT, 1, FD_MAX_LIMIT)),
|
||||
);
|
||||
// An empty pattern matches everything, which keeps `path` usable without a pattern.
|
||||
args.push("--", params.pattern ?? "");
|
||||
const path = optionalPath(params.path);
|
||||
if (path) args.push(path);
|
||||
return args;
|
||||
}
|
||||
|
||||
export interface RgToolParams {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
glob?: string;
|
||||
file_type?: string;
|
||||
case_sensitive?: boolean;
|
||||
fixed_strings?: boolean;
|
||||
hidden?: boolean;
|
||||
context?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function buildRgArgs(params: RgToolParams) {
|
||||
const args = [
|
||||
"--line-number",
|
||||
"--color=never",
|
||||
"--no-heading",
|
||||
"--with-filename",
|
||||
];
|
||||
if (params.case_sensitive === true) args.push("--case-sensitive");
|
||||
else if (params.case_sensitive === false) args.push("--ignore-case");
|
||||
else args.push("--smart-case");
|
||||
if (params.fixed_strings) args.push("--fixed-strings");
|
||||
if (params.hidden) args.push("--hidden");
|
||||
if (params.context !== undefined) {
|
||||
args.push("--context", String(clamp(params.context, 0, RG_MAX_CONTEXT)));
|
||||
}
|
||||
if (params.glob) args.push("--glob", params.glob);
|
||||
if (params.file_type) args.push("--type", params.file_type);
|
||||
args.push(
|
||||
"--max-count",
|
||||
String(
|
||||
clamp(params.limit ?? RG_DEFAULT_COUNT_LIMIT, 1, RG_MAX_COUNT_LIMIT),
|
||||
),
|
||||
);
|
||||
args.push("--", params.pattern);
|
||||
const path = optionalPath(params.path);
|
||||
if (path) args.push(path);
|
||||
return args;
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Startup resolution of the fd and rg executables.
|
||||
*
|
||||
* Resolution order (per tool, first usable wins):
|
||||
* 1. A normally installed system binary (`fd`/`fdfind`, `rg`) — used silently.
|
||||
* 2. An existing fallback in this repository's `bin/` directory — used silently.
|
||||
* 3. A fresh download of an official release into `bin/` — the only case that
|
||||
* should surface a UI notification.
|
||||
*
|
||||
* The decision logic is an Effect over a small injectable environment
|
||||
* (`BinaryEnv`) so tests can drive it without touching the filesystem or the
|
||||
* network. `liveBinaryEnv` is the real implementation.
|
||||
*/
|
||||
|
||||
import { NodeHttpClient, NodeServices } from "@effect/platform-node";
|
||||
import { execFile } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Crypto, Data, Effect, Encoding, FileSystem, Stream } from "effect";
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http";
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const FD_VERSION = "10.4.2";
|
||||
export const FD_INTEL_DARWIN_VERSION = "10.3.0";
|
||||
export const RG_VERSION = "15.2.0";
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
||||
const MAX_ARCHIVE_BYTES = 25 * 1024 * 1024;
|
||||
const MAX_DOWNLOAD_REDIRECTS = 10;
|
||||
|
||||
const FD_SHA256: Readonly<Record<string, string>> = {
|
||||
"aarch64-apple-darwin":
|
||||
"623dc0afc81b92e4d4606b380d7bc91916ba7b97814263e554d50923a39e480a",
|
||||
"x86_64-apple-darwin":
|
||||
"50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3",
|
||||
"aarch64-unknown-linux-musl":
|
||||
"f32d3657473fba74e2600babc8db0b93420d51169223b7e8143b2ed55d8fd9e8",
|
||||
"x86_64-unknown-linux-musl":
|
||||
"e3257d48e29a6be965187dbd24ce9af564e0fe67b3e73c9bdcd180f4ec11bdde",
|
||||
};
|
||||
|
||||
const RG_SHA256: Readonly<Record<string, string>> = {
|
||||
"aarch64-apple-darwin":
|
||||
"3750b2e93f37e0c692657da574d7019a101c0084da05a790c83fd335bad973e4",
|
||||
"x86_64-apple-darwin":
|
||||
"af7825fcc69a2afc7a7aea55fc9af90e26421d8f20fe59df32e233c0b8a231c1",
|
||||
"aarch64-unknown-linux-musl":
|
||||
"800b1e7206afe799dfb5a6901f23147cfaabe0e52210538100f61e86e1740915",
|
||||
"x86_64-unknown-linux-musl":
|
||||
"33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c",
|
||||
};
|
||||
|
||||
export type ToolName = "fd" | "rg";
|
||||
export type BinarySource = "system" | "bundled" | "installed";
|
||||
|
||||
export interface ToolSpec {
|
||||
readonly tool: ToolName;
|
||||
/** Commands probed on PATH, in order. Debian/Ubuntu install fd as `fdfind`. */
|
||||
readonly systemCommands: readonly string[];
|
||||
/** Executable name used inside release archives and the repo bin directory. */
|
||||
readonly binaryName: string;
|
||||
}
|
||||
|
||||
export const TOOL_SPECS: Record<ToolName, ToolSpec> = {
|
||||
fd: { tool: "fd", systemCommands: ["fd", "fdfind"], binaryName: "fd" },
|
||||
rg: { tool: "rg", systemCommands: ["rg"], binaryName: "rg" },
|
||||
};
|
||||
|
||||
export interface PlatformTarget {
|
||||
readonly os: string;
|
||||
readonly arch: string;
|
||||
}
|
||||
|
||||
export interface ReleaseAsset {
|
||||
readonly url: string;
|
||||
readonly fileName: string;
|
||||
/** Top-level directory inside the tarball. */
|
||||
readonly archiveDir: string;
|
||||
readonly binaryName: string;
|
||||
readonly version: string;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
function targetTriple(target: PlatformTarget) {
|
||||
const cpu =
|
||||
target.arch === "arm64"
|
||||
? "aarch64"
|
||||
: target.arch === "x64"
|
||||
? "x86_64"
|
||||
: undefined;
|
||||
if (!cpu) return undefined;
|
||||
if (target.os === "darwin") return `${cpu}-apple-darwin`;
|
||||
// musl builds are statically linked, so they run on any Linux distribution.
|
||||
if (target.os === "linux") return `${cpu}-unknown-linux-musl`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Official GitHub release asset for a tool on a platform, if supported. */
|
||||
export function releaseAsset(
|
||||
tool: ToolName,
|
||||
target: PlatformTarget,
|
||||
): ReleaseAsset | undefined {
|
||||
const triple = targetTriple(target);
|
||||
if (!triple) return undefined;
|
||||
|
||||
if (tool === "fd") {
|
||||
const sha256 = FD_SHA256[triple];
|
||||
if (!sha256) return undefined;
|
||||
// fd 10.4.2 dropped the Intel macOS archive, so retain 10.3.0 there.
|
||||
const version =
|
||||
triple === "x86_64-apple-darwin" ? FD_INTEL_DARWIN_VERSION : FD_VERSION;
|
||||
const archiveDir = `fd-v${version}-${triple}`;
|
||||
const fileName = `${archiveDir}.tar.gz`;
|
||||
return {
|
||||
url: `https://github.com/sharkdp/fd/releases/download/v${version}/${fileName}`,
|
||||
fileName,
|
||||
archiveDir,
|
||||
binaryName: "fd",
|
||||
version,
|
||||
sha256,
|
||||
};
|
||||
}
|
||||
|
||||
const sha256 = RG_SHA256[triple];
|
||||
if (!sha256) return undefined;
|
||||
const archiveDir = `ripgrep-${RG_VERSION}-${triple}`;
|
||||
const fileName = `${archiveDir}.tar.gz`;
|
||||
return {
|
||||
url: `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${fileName}`,
|
||||
fileName,
|
||||
archiveDir,
|
||||
binaryName: "rg",
|
||||
version: RG_VERSION,
|
||||
sha256,
|
||||
};
|
||||
}
|
||||
|
||||
export function currentTarget(): PlatformTarget {
|
||||
return { os: process.platform, arch: process.arch };
|
||||
}
|
||||
|
||||
/** Repository root (`~/.pi/agent`) resolved from this module's location. */
|
||||
export function repositoryBinDir() {
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
return join(moduleDir, "..", "..", "..", "bin");
|
||||
}
|
||||
|
||||
export class UnsupportedPlatformError extends Data.TaggedError(
|
||||
"UnsupportedPlatformError",
|
||||
)<{
|
||||
readonly message: string;
|
||||
}> {}
|
||||
|
||||
export class InstallError extends Data.TaggedError("InstallError")<{
|
||||
readonly message: string;
|
||||
readonly cause?: unknown;
|
||||
}> {}
|
||||
|
||||
export interface BinaryEnv {
|
||||
/** True when the executable runs and supports the flags this tool requires. */
|
||||
readonly probe: (command: string, tool: ToolName) => Effect.Effect<boolean>;
|
||||
/** Download and place a release binary at the destination path. */
|
||||
readonly install: (
|
||||
asset: ReleaseAsset,
|
||||
destination: string,
|
||||
) => Effect.Effect<void, InstallError>;
|
||||
}
|
||||
|
||||
export interface ResolvedBinary {
|
||||
readonly tool: ToolName;
|
||||
/** Command or absolute path passed to pi.exec. */
|
||||
readonly command: string;
|
||||
readonly source: BinarySource;
|
||||
readonly version?: string;
|
||||
}
|
||||
|
||||
/** Resolve one tool: system binary, existing bin fallback, or fresh install. */
|
||||
export function resolveBinary(
|
||||
spec: ToolSpec,
|
||||
binDir: string,
|
||||
target: PlatformTarget,
|
||||
env: BinaryEnv,
|
||||
): Effect.Effect<ResolvedBinary, UnsupportedPlatformError | InstallError> {
|
||||
return Effect.gen(function* () {
|
||||
for (const command of spec.systemCommands) {
|
||||
if (yield* env.probe(command, spec.tool)) {
|
||||
return { tool: spec.tool, command, source: "system" as const };
|
||||
}
|
||||
}
|
||||
|
||||
const bundled = join(binDir, spec.binaryName);
|
||||
if (yield* env.probe(bundled, spec.tool)) {
|
||||
return { tool: spec.tool, command: bundled, source: "bundled" as const };
|
||||
}
|
||||
|
||||
const asset = releaseAsset(spec.tool, target);
|
||||
if (!asset) {
|
||||
return yield* new UnsupportedPlatformError({
|
||||
message: `No ${spec.tool} binary is available for ${target.os}/${target.arch}. Install ${spec.tool} manually and restart pi.`,
|
||||
});
|
||||
}
|
||||
|
||||
yield* env.install(asset, bundled);
|
||||
|
||||
if (!(yield* env.probe(bundled, spec.tool))) {
|
||||
return yield* new InstallError({
|
||||
message: `${spec.tool} ${asset.version} was installed to ${bundled} but failed to run.`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tool: spec.tool,
|
||||
command: bundled,
|
||||
source: "installed" as const,
|
||||
version: asset.version,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** Read a response incrementally while enforcing the startup memory bound. */
|
||||
export function readBoundedResponse<E, R>(
|
||||
response: {
|
||||
readonly headers: Readonly<Record<string, string | undefined>>;
|
||||
readonly stream: Stream.Stream<Uint8Array, E, R>;
|
||||
},
|
||||
maxBytes = MAX_ARCHIVE_BYTES,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const declaredLength = Number(response.headers["content-length"]);
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
return yield* Effect.fail(
|
||||
new Error(`download exceeds the ${maxBytes}-byte size limit`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = yield* Stream.runFoldEffect(
|
||||
response.stream,
|
||||
() => ({ chunks: [] as Uint8Array[], totalBytes: 0 }),
|
||||
(accumulator, chunk) => {
|
||||
const totalBytes = accumulator.totalBytes + chunk.byteLength;
|
||||
if (totalBytes > maxBytes) {
|
||||
return Effect.fail(
|
||||
new Error(`download exceeds the ${maxBytes}-byte size limit`),
|
||||
);
|
||||
}
|
||||
return Effect.sync(() => {
|
||||
accumulator.chunks.push(chunk);
|
||||
accumulator.totalBytes = totalBytes;
|
||||
return accumulator;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return Buffer.concat(result.chunks, result.totalBytes);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadAsset(client: HttpClient.HttpClient, initialUrl: URL) {
|
||||
const scopedClient = client.pipe(HttpClient.withScope);
|
||||
|
||||
return Effect.gen(function* () {
|
||||
let url = initialUrl;
|
||||
|
||||
for (let redirects = 0; redirects <= MAX_DOWNLOAD_REDIRECTS; redirects++) {
|
||||
if (url.protocol !== "https:") {
|
||||
return yield* Effect.fail(
|
||||
new Error(`refusing non-HTTPS download URL: ${url.href}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const response = yield* scopedClient.get(url);
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
const location = response.headers.location;
|
||||
if (!location) {
|
||||
return yield* Effect.fail(
|
||||
new Error(`redirect from ${url.href} had no location header`),
|
||||
);
|
||||
}
|
||||
if (redirects === MAX_DOWNLOAD_REDIRECTS) {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`download exceeded ${MAX_DOWNLOAD_REDIRECTS} redirects`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!URL.canParse(location, url)) {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`download returned an invalid redirect URL: ${location}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return { _tag: "Redirect" as const, url: new URL(location, url) };
|
||||
}
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return yield* Effect.fail(
|
||||
new Error(`download failed with HTTP ${response.status}`),
|
||||
);
|
||||
}
|
||||
return {
|
||||
_tag: "Complete" as const,
|
||||
bytes: yield* readBoundedResponse(response),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (result._tag === "Complete") return result.bytes;
|
||||
url = result.url;
|
||||
}
|
||||
|
||||
return yield* Effect.fail(new Error("download redirect handling failed"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Real environment: probes via `--version`, installs via HTTPS + tar. */
|
||||
export const liveBinaryEnv: BinaryEnv = {
|
||||
probe: (command, tool) =>
|
||||
Effect.promise(async () => {
|
||||
try {
|
||||
const args =
|
||||
tool === "fd" ? ["--max-results", "1", "--", ""] : ["--version"];
|
||||
await execFileAsync(command, args, {
|
||||
cwd: tmpdir(),
|
||||
timeout: 5_000,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
|
||||
install: (asset, destination) => {
|
||||
const install = Effect.gen(function* () {
|
||||
if (!URL.canParse(asset.url)) {
|
||||
return yield* Effect.fail(
|
||||
new Error(`invalid download URL: ${asset.url}`),
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(asset.url);
|
||||
const client = yield* HttpClient.HttpClient;
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const crypto = yield* Crypto.Crypto;
|
||||
const bytes = yield* downloadAsset(client, url).pipe(
|
||||
Effect.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const digestBytes = yield* crypto.digest("SHA-256", bytes);
|
||||
const digest = Encoding.encodeHex(digestBytes);
|
||||
if (digest !== asset.sha256) {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`SHA-256 mismatch for ${asset.fileName}: expected ${asset.sha256}, received ${digest}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const workDir = yield* fs.makeTempDirectoryScoped({
|
||||
prefix: "pi-file-search-",
|
||||
});
|
||||
const archivePath = join(workDir, asset.fileName);
|
||||
yield* fs.writeFile(archivePath, bytes);
|
||||
|
||||
const tarExitCode = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const tar = yield* ChildProcess.make(
|
||||
"tar",
|
||||
["-xzf", archivePath, "-C", workDir],
|
||||
{ stdin: "ignore", stdout: "ignore", stderr: "ignore" },
|
||||
);
|
||||
return yield* tar.exitCode;
|
||||
}),
|
||||
).pipe(Effect.timeout(60_000));
|
||||
if (tarExitCode !== ChildProcessSpawner.ExitCode(0)) {
|
||||
return yield* Effect.fail(
|
||||
new Error(`tar failed with exit code ${tarExitCode}`),
|
||||
);
|
||||
}
|
||||
|
||||
const extracted = join(workDir, asset.archiveDir, asset.binaryName);
|
||||
yield* fs.makeDirectory(dirname(destination), { recursive: true });
|
||||
const uuid = yield* crypto.randomUUIDv4;
|
||||
const stagedDestination = `${destination}.${process.pid}.${uuid}.tmp`;
|
||||
yield* Effect.addFinalizer(() =>
|
||||
fs.remove(stagedDestination, { force: true }).pipe(Effect.orDie),
|
||||
);
|
||||
yield* fs.copyFile(extracted, stagedDestination);
|
||||
yield* fs.chmod(stagedDestination, 0o755);
|
||||
yield* fs.rename(stagedDestination, destination);
|
||||
});
|
||||
|
||||
return install.pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.provide(NodeHttpClient.layerFetch),
|
||||
Effect.provideService(FetchHttpClient.RequestInit, {
|
||||
redirect: "manual",
|
||||
}),
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new InstallError({
|
||||
message: `Failed to install ${asset.binaryName} ${asset.version} from ${asset.url}: ${errorMessage(cause)}`,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Shared output shaping for the fd and rg tools: standard pi truncation
|
||||
* (2000 lines / 50KB) with the full output persisted to a temp file when
|
||||
* anything is cut off.
|
||||
*/
|
||||
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
formatSize,
|
||||
truncateHead,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export interface FormattedOutput {
|
||||
readonly text: string;
|
||||
readonly lineCount: number;
|
||||
readonly truncated: boolean;
|
||||
readonly fullOutputPath?: string;
|
||||
}
|
||||
|
||||
export interface CapturedOutput {
|
||||
readonly preview: string;
|
||||
readonly lineCount: number;
|
||||
readonly totalBytes: number;
|
||||
readonly truncated: boolean;
|
||||
readonly fullOutputPath?: string;
|
||||
}
|
||||
|
||||
export interface FormatOutputOptions {
|
||||
/** Temp-file prefix, e.g. "pi-fd-". */
|
||||
readonly tempPrefix: string;
|
||||
/** Injectable for tests. */
|
||||
readonly persistFullOutput?: (output: string) => Promise<string>;
|
||||
}
|
||||
|
||||
async function persistToTempFile(prefix: string, output: string) {
|
||||
const directory = await mkdtemp(join(tmpdir(), prefix));
|
||||
const path = join(directory, "output.txt");
|
||||
await writeFile(path, output, "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
function truncationNotice(options: {
|
||||
content: string;
|
||||
outputLines: number;
|
||||
totalLines: number;
|
||||
outputBytes: number;
|
||||
totalBytes: number;
|
||||
fullOutputPath: string;
|
||||
}) {
|
||||
return (
|
||||
`${options.content}\n\n[Output truncated: ${options.outputLines} of ${options.totalLines} lines ` +
|
||||
`(${formatSize(options.outputBytes)} of ${formatSize(options.totalBytes)}). ` +
|
||||
`Full output saved to: ${options.fullOutputPath}]`
|
||||
);
|
||||
}
|
||||
|
||||
/** Format output already captured by a bounded-memory streaming process. */
|
||||
export function formatCapturedOutput(captured: CapturedOutput) {
|
||||
const trimmed = captured.preview.replace(/\n+$/, "");
|
||||
if (!captured.truncated || !captured.fullOutputPath) {
|
||||
return {
|
||||
text: trimmed,
|
||||
lineCount: captured.lineCount,
|
||||
truncated: false,
|
||||
} satisfies FormattedOutput;
|
||||
}
|
||||
|
||||
const truncation = truncateHead(trimmed, {
|
||||
maxLines: DEFAULT_MAX_LINES,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
});
|
||||
const content = truncation.content;
|
||||
const outputLines = content === "" ? 0 : content.split("\n").length;
|
||||
const outputBytes = Buffer.byteLength(content);
|
||||
return {
|
||||
text: truncationNotice({
|
||||
content,
|
||||
outputLines,
|
||||
totalLines: captured.lineCount,
|
||||
outputBytes,
|
||||
totalBytes: captured.totalBytes,
|
||||
fullOutputPath: captured.fullOutputPath,
|
||||
}),
|
||||
lineCount: captured.lineCount,
|
||||
truncated: true,
|
||||
fullOutputPath: captured.fullOutputPath,
|
||||
} satisfies FormattedOutput;
|
||||
}
|
||||
|
||||
/** Truncate to pi's standard limits, persisting the full output when cut. */
|
||||
export async function formatOutput(
|
||||
output: string,
|
||||
options: FormatOutputOptions,
|
||||
): Promise<FormattedOutput> {
|
||||
const trimmed = output.replace(/\n+$/, "");
|
||||
const lineCount = trimmed === "" ? 0 : trimmed.split("\n").length;
|
||||
|
||||
const truncation = truncateHead(trimmed, {
|
||||
maxLines: DEFAULT_MAX_LINES,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
});
|
||||
|
||||
if (!truncation.truncated) {
|
||||
return { text: trimmed, lineCount, truncated: false };
|
||||
}
|
||||
|
||||
const persist =
|
||||
options.persistFullOutput ??
|
||||
((full: string) => persistToTempFile(options.tempPrefix, full));
|
||||
const fullOutputPath = await persist(trimmed);
|
||||
|
||||
const text = truncationNotice({
|
||||
content: truncation.content,
|
||||
outputLines: truncation.outputLines,
|
||||
totalLines: truncation.totalLines,
|
||||
outputBytes: truncation.outputBytes,
|
||||
totalBytes: truncation.totalBytes,
|
||||
fullOutputPath,
|
||||
});
|
||||
|
||||
return { text, lineCount, truncated: true, fullOutputPath };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
truncateHead,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { Effect, FileSystem, Stream } from "effect";
|
||||
import { ChildProcess } from "effect/unstable/process";
|
||||
import type { CapturedOutput } from "./output.ts";
|
||||
|
||||
const STDERR_MAX_BYTES = 64 * 1024;
|
||||
|
||||
interface PreviewState {
|
||||
readonly decoder: TextDecoder;
|
||||
preview: string;
|
||||
totalBytes: number;
|
||||
lineBreaks: number;
|
||||
trailingLineBreaks: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
function makePreviewState(): PreviewState {
|
||||
return {
|
||||
decoder: new TextDecoder(),
|
||||
preview: "",
|
||||
totalBytes: 0,
|
||||
lineBreaks: 0,
|
||||
trailingLineBreaks: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function observeStdout(state: PreviewState, chunk: Uint8Array) {
|
||||
state.totalBytes += chunk.byteLength;
|
||||
for (const byte of chunk) {
|
||||
if (byte === 0x0a) {
|
||||
state.lineBreaks++;
|
||||
state.trailingLineBreaks++;
|
||||
} else {
|
||||
state.trailingLineBreaks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.truncated) return;
|
||||
state.preview += state.decoder.decode(chunk, { stream: true });
|
||||
const truncation = truncateHead(state.preview, {
|
||||
maxLines: DEFAULT_MAX_LINES,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
});
|
||||
if (truncation.truncated) {
|
||||
state.preview = truncation.content;
|
||||
state.truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
function finishStdout(state: PreviewState, fullOutputPath: string) {
|
||||
if (!state.truncated) state.preview += state.decoder.decode();
|
||||
const totalBytes = state.totalBytes - state.trailingLineBreaks;
|
||||
const lineCount =
|
||||
totalBytes === 0 ? 0 : state.lineBreaks - state.trailingLineBreaks + 1;
|
||||
return {
|
||||
preview: state.preview,
|
||||
lineCount,
|
||||
totalBytes,
|
||||
truncated: state.truncated,
|
||||
fullOutputPath: state.truncated ? fullOutputPath : undefined,
|
||||
} satisfies CapturedOutput;
|
||||
}
|
||||
|
||||
function collectStderr<E, R>(stream: Stream.Stream<Uint8Array, E, R>) {
|
||||
return Stream.runFold(
|
||||
stream,
|
||||
() => Buffer.alloc(0),
|
||||
(captured, chunk) => {
|
||||
if (captured.byteLength >= STDERR_MAX_BYTES) return captured;
|
||||
const remaining = STDERR_MAX_BYTES - captured.byteLength;
|
||||
return Buffer.concat([captured, chunk.subarray(0, remaining)]);
|
||||
},
|
||||
).pipe(Effect.map((bytes) => bytes.toString("utf8")));
|
||||
}
|
||||
|
||||
export function executeSearchProcess(options: {
|
||||
readonly command: string;
|
||||
readonly args: readonly string[];
|
||||
readonly cwd: string;
|
||||
readonly tempPrefix: string;
|
||||
}) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const directory = yield* fs.makeTempDirectory({
|
||||
prefix: options.tempPrefix,
|
||||
});
|
||||
const fullOutputPath = join(directory, "output.txt");
|
||||
let retainDirectory = false;
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const preview = makePreviewState();
|
||||
const process = yield* ChildProcess.make(options.command, options.args, {
|
||||
cwd: options.cwd,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const result = yield* Effect.all(
|
||||
{
|
||||
exitCode: process.exitCode,
|
||||
stdout: process.stdout.pipe(
|
||||
Stream.tap((chunk) =>
|
||||
Effect.sync(() => observeStdout(preview, chunk)),
|
||||
),
|
||||
Stream.run(fs.sink(fullOutputPath)),
|
||||
),
|
||||
stderr: collectStderr(process.stderr),
|
||||
},
|
||||
{ concurrency: "unbounded" },
|
||||
);
|
||||
const output = finishStdout(preview, fullOutputPath);
|
||||
retainDirectory = output.truncated;
|
||||
return {
|
||||
code: Number(result.exitCode),
|
||||
stderr: result.stderr,
|
||||
output,
|
||||
};
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.suspend(() =>
|
||||
retainDirectory
|
||||
? Effect.void
|
||||
: fs
|
||||
.remove(directory, { recursive: true, force: true })
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).pipe(Effect.scoped);
|
||||
}
|
||||
|
||||
export function discardCapturedOutput(output: CapturedOutput) {
|
||||
if (!output.fullOutputPath) return Effect.void;
|
||||
const directory = dirname(output.fullOutputPath);
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
yield* fs.remove(directory, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Model-facing text for the fd and rg tools. */
|
||||
|
||||
export const FD_TOOL_DESCRIPTION =
|
||||
"Find files and directories by name with fd. Respects .gitignore by default. Results are limited to 1000 entries unless a higher limit is given; output is limited to 2000 lines or 50KB, and complete truncated output is saved to a temporary file.";
|
||||
|
||||
export const FD_PROMPT_SNIPPET =
|
||||
"Find files and directories by name with fd (fast, gitignore-aware).";
|
||||
|
||||
export const FD_PROMPT_GUIDELINES = [
|
||||
"Use fd as the primary tool for discovering files and directories by name, extension, or glob instead of bash with find or ls -R.",
|
||||
"Use rg instead of fd when searching file contents rather than file names.",
|
||||
"Keep using bash for complex multi-step workflows that pipe or post-process file listings.",
|
||||
];
|
||||
|
||||
export const FD_PARAMETER_DESCRIPTIONS = {
|
||||
pattern:
|
||||
"Regex matched against file names (or a glob when glob is true). Omit to list everything under path.",
|
||||
path: "Directory to search. Defaults to the current working directory.",
|
||||
type: "Only return entries of this type: file, directory, or symlink.",
|
||||
extension: "Only return files with this extension, e.g. 'ts' or 'md'.",
|
||||
glob: "Treat pattern as a glob (e.g. '*.test.ts') instead of a regex.",
|
||||
hidden: "Include hidden files and directories. Defaults to false.",
|
||||
max_depth: "Maximum directory depth to descend (1-64).",
|
||||
limit: "Maximum number of results (1-10000). Defaults to 1000.",
|
||||
};
|
||||
|
||||
export const RG_TOOL_DESCRIPTION =
|
||||
"Search file contents with ripgrep. Uses smart-case matching, respects .gitignore by default, and returns at most 100 matches per file unless a different limit is given. Output is limited to 2000 lines or 50KB; complete truncated output is saved to a temporary file.";
|
||||
|
||||
export const RG_PROMPT_SNIPPET =
|
||||
"Search file contents with ripgrep (fast regex content search).";
|
||||
|
||||
export const RG_PROMPT_GUIDELINES = [
|
||||
"Use rg as the primary tool for searching file contents instead of bash with grep.",
|
||||
"Use fd instead of rg when looking for files by name rather than content.",
|
||||
"Set fixed_strings on rg when searching for literal code snippets containing regex metacharacters.",
|
||||
"Keep using bash for complex multi-step workflows that combine searching with other commands.",
|
||||
];
|
||||
|
||||
export const RG_PARAMETER_DESCRIPTIONS = {
|
||||
pattern: "Regex to search for (literal text when fixed_strings is true).",
|
||||
path: "File or directory to search. Defaults to the current working directory.",
|
||||
glob: "Only search files matching this glob, e.g. '*.ts' or 'src/**'.",
|
||||
file_type:
|
||||
"Only search files of this ripgrep type, e.g. 'ts', 'js', 'py', 'rust'.",
|
||||
case_sensitive:
|
||||
"true forces case-sensitive matching, false forces case-insensitive. Defaults to smart-case.",
|
||||
fixed_strings: "Treat pattern as a literal string instead of a regex.",
|
||||
hidden: "Search hidden files and directories. Defaults to false.",
|
||||
context: "Lines of context to show around each match (0-20).",
|
||||
limit: "Maximum matches per file (1-1000). Defaults to 100.",
|
||||
};
|
||||
Reference in New Issue
Block a user