Skip to content

Commit acb9442

Browse files
sanchitrkclaude
andauthored
feat(harness): grep, find and ls built-in tools (#64)
* feat(harness): grep, find and ls built-in tools grep runs ripgrep and find runs fd inside the mounted sandbox: the host's or the remote's own binary, or just-bash's built-in rg on a virtual mount, which has no fd and says so. A missing binary is installed on first use, into <home>/bin for the host and into the sandbox's own $HOME/.codework/bin for a remote, downloaded on the host so the sandbox needs no network. ls lists through the sandbox filesystem. Output, limits and truncation follow Pi. The tools ship off, as Pi ships them. A plugin can declare `optIn: true`: it keeps its built-in position but runs only once settings turn it on with `{ "plugin": "codework.tool.grep", "enabled": true }`. Plugin setup now gets `ctx.paths` (home, bin, cache, data, log). Covered end to end on the host, a memory sandbox, live Daytona and Vercel sandboxes, and live OpenAI and Anthropic models through the harness and the CLI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * tweak --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent aaada19 commit acb9442

31 files changed

Lines changed: 2089 additions & 6 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"off": [],
3+
"on": [
4+
"spec: src/nested/deep.spec.ts",
5+
"other: src/util.ts:3",
6+
"a: .gitignore, deep/, ignored.txt, kept.txt"
7+
]
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"off": [],
3+
"on": [
4+
"spec: src/nested/deep.spec.ts",
5+
"other: src/util.ts:3",
6+
"a: .gitignore, deep/, ignored.txt, kept.txt"
7+
]
8+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/* @effect-diagnostics nodeBuiltinImport:off -- this suite spawns the CLI as a child process. */
2+
import { spawnSync } from "node:child_process";
3+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { dirname, join } from "node:path";
6+
import { DatabaseSync } from "node:sqlite";
7+
import { fileURLToPath } from "node:url";
8+
import { describe, expect, it } from "vite-plus/test";
9+
import "./utils/env.ts";
10+
11+
/**
12+
* The search tools through the real CLI and a real model: off by default, as Pi ships them, and
13+
* on once a project's settings enable them. What the model called is read back from the session
14+
* database the CLI wrote.
15+
*/
16+
17+
const cli = fileURLToPath(new URL("../src/index.ts", import.meta.url));
18+
const models = fileURLToPath(new URL("../../../models.gen.json", import.meta.url));
19+
20+
const LIVE = [
21+
{ name: "openai", provider: "openai", id: "gpt-5.6-luna", env: "OPENAI_API_KEY" },
22+
{ name: "anthropic", provider: "anthropic", id: "claude-sonnet-5-5", env: "ANTHROPIC_API_KEY" },
23+
] as const;
24+
25+
const files: Record<string, string> = {
26+
"src/nested/deep.spec.ts": "describe('needle', () => {});\n",
27+
"src/util.ts": "// helpers\nexport const needle = 'NEEDLE';\nexport const other = 2;\n",
28+
"a/.gitignore": "ignored.txt\n",
29+
"a/ignored.txt": "",
30+
"a/kept.txt": "",
31+
"a/deep/.gitignore": "secret.txt\n",
32+
"a/deep/kept.txt": "",
33+
};
34+
35+
const optIn = {
36+
plugins: ["grep", "find", "ls"].map((tool) => ({ plugin: `codework.tool.${tool}`, enabled: true })),
37+
};
38+
39+
const PROMPT = [
40+
"Use only your tools; do not guess. Do all three:",
41+
"1. Use find to locate the spec file (pattern *.spec.ts).",
42+
"2. Use grep to find where `export const other` is defined.",
43+
"3. Use ls on the directory `a`.",
44+
"Then reply with exactly three lines and nothing else:",
45+
"spec: <path of the spec file, relative to the project root>",
46+
"other: <path relative to the project root>:<line number>",
47+
"a: <the entries ls printed, comma-separated, in the order printed>",
48+
].join("\n");
49+
50+
const EXPECTED = [
51+
"spec: src/nested/deep.spec.ts",
52+
"other: src/util.ts:3",
53+
"a: .gitignore, deep/, ignored.txt, kept.txt",
54+
];
55+
56+
const SEARCH = new Set(["grep", "find", "ls"]);
57+
58+
/** One `codework run` in a fresh project, returning its output and the tools it completed. */
59+
const run = (live: (typeof LIVE)[number], settings: object | undefined) => {
60+
const root = mkdtempSync(join(tmpdir(), "codework-cli-search-"));
61+
try {
62+
const project = join(root, "project");
63+
for (const [path, content] of Object.entries(files)) {
64+
mkdirSync(dirname(join(project, path)), { recursive: true });
65+
writeFileSync(join(project, path), content);
66+
}
67+
if (settings !== undefined) {
68+
mkdirSync(join(project, ".codework"));
69+
writeFileSync(join(project, ".codework", "settings.jsonc"), JSON.stringify(settings));
70+
}
71+
const home = join(root, "home");
72+
const database = join(root, "session.sqlite");
73+
const result = spawnSync(
74+
process.execPath,
75+
[
76+
"--conditions=development",
77+
cli,
78+
"--home",
79+
home,
80+
"--database",
81+
database,
82+
"run",
83+
"--provider",
84+
live.provider,
85+
"--model",
86+
live.id,
87+
PROMPT,
88+
],
89+
{
90+
encoding: "utf8",
91+
cwd: project,
92+
// HOME too, so nothing outside the temp root is read.
93+
env: { ...process.env, HOME: home, CODEWORK_MODELS_FILE: models },
94+
timeout: 240_000,
95+
},
96+
);
97+
const db = new DatabaseSync(database);
98+
try {
99+
const rows = db
100+
.prepare("SELECT tool_name, status FROM session_entry_part WHERE type = 'toolCall'")
101+
.all()
102+
.map((row) => ({ tool_name: String(row["tool_name"]), status: String(row["status"]) }));
103+
return {
104+
status: result.status,
105+
stdout: result.stdout,
106+
stderr: result.stderr,
107+
completed: [...new Set(rows.filter((row) => row.status === "completed").map((row) => row.tool_name))],
108+
};
109+
} finally {
110+
db.close();
111+
}
112+
} finally {
113+
rmSync(root, { recursive: true, force: true });
114+
}
115+
};
116+
117+
describe.each(LIVE)("search tools through the CLI against live $name", (live) => {
118+
it.skipIf(!process.env[live.env]?.trim())(
119+
"offers them only once settings enable them, then answers from them",
120+
async () => {
121+
const off = run(live, undefined);
122+
expect(off.status, off.stderr).toBe(0);
123+
const offSearch = off.completed.filter((tool) => SEARCH.has(tool));
124+
expect(offSearch).toEqual([]);
125+
126+
const on = run(live, optIn);
127+
expect(on.status, on.stderr).toBe(0);
128+
for (const tool of SEARCH) expect(on.completed, `${live.name} used ${tool}`).toContain(tool);
129+
const lines = on.stdout
130+
.split("\n")
131+
.map((line) => line.trim().replace(/^`|`$/g, "").replace(/,\s*/g, ", "))
132+
.filter((line) => /^(spec|other|a):/.test(line));
133+
expect(lines).toEqual(EXPECTED);
134+
135+
await expect(`${JSON.stringify({ off: offSearch, on: lines }, null, "\t")}\n`).toMatchFileSnapshot(
136+
`./__artifacts__/cli.search.live.${live.name}.json`,
137+
);
138+
},
139+
600_000,
140+
);
141+
});

‎packages/harness/src/global.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface Interface {
2323
readonly cache: string;
2424
readonly data: string;
2525
readonly log: string;
26+
readonly bin: string;
2627
}
2728

2829
export function make(input: Partial<Interface> = {}): Interface {
@@ -32,6 +33,7 @@ export function make(input: Partial<Interface> = {}): Interface {
3233
cache: input.cache ?? posix.join(home, "cache"),
3334
data: input.data ?? posix.join(home, "data"),
3435
log: input.log ?? posix.join(home, "log"),
36+
bin: input.bin ?? posix.join(home, "bin"),
3537
};
3638
}
3739

‎packages/harness/src/plugin/builtin.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,26 @@ import { defaultPromptPlugin } from "./builtin/prompt/default.ts";
22
import { instructionPlugin } from "./builtin/prompt/instruction.ts";
33
import { bashPlugin } from "./builtin/tool/bash.ts";
44
import { editPlugin } from "./builtin/tool/edit.ts";
5+
import { findPlugin } from "./builtin/tool/find.ts";
6+
import { grepPlugin } from "./builtin/tool/grep.ts";
7+
import { lsPlugin } from "./builtin/tool/ls.ts";
58
import { readPlugin } from "./builtin/tool/read.ts";
69
import { writePlugin } from "./builtin/tool/write.ts";
710

811
/**
912
* Every plugin the harness ships, and the selection a caller who passes no `plugins` gets. Tool
10-
* contributors come first; within the prompt domain, declaration order is run order.
13+
* contributors come first; within the prompt domain, declaration order is run order. The search
14+
* tools (grep, find, ls) are `optIn`, as Pi ships them off: settings turn each on with
15+
* `{ "plugin": "codework.tool.grep", "enabled": true }`.
1116
*/
1217
export const builtins = Object.freeze([
1318
bashPlugin,
1419
readPlugin,
1520
writePlugin,
1621
editPlugin,
22+
grepPlugin,
23+
findPlugin,
24+
lsPlugin,
1725
defaultPromptPlugin,
1826
instructionPlugin,
1927
]);
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { Effect, Layer, Schema } from "effect";
2+
import type { SandboxFileSystem } from "../../../sandbox/fs/filesystem.ts";
3+
import { SandboxIO } from "../../../sandbox/io.ts";
4+
import { quoteArgv } from "../../../sandbox/shell/shell.ts";
5+
import { Binary } from "../../../tool/binary.ts";
6+
import { ToolPath } from "../../../tool/path.ts";
7+
import * as Tool from "../../../tool/tool.ts";
8+
import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../../tool/truncate.ts";
9+
import { posix } from "../../../util/posix.ts";
10+
import { define } from "../../plugin.ts";
11+
12+
/**
13+
* The built-in find plugin: fd in the mounted sandbox. A virtual mount has no fd,
14+
* and the tool says so rather than emulating it.
15+
*/
16+
17+
const FindParams = Schema.Struct({
18+
pattern: Schema.String.annotate({
19+
description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
20+
}),
21+
path: Schema.optional(
22+
Schema.String.annotate({ description: "Directory to search in (default: current directory)" }),
23+
),
24+
limit: Schema.optional(Schema.Finite.annotate({ description: "Maximum number of results (default: 1000)" })),
25+
});
26+
27+
const FindDetails = Schema.Struct({
28+
truncation: Schema.optional(Schema.Unknown),
29+
resultLimitReached: Schema.optional(Schema.Finite),
30+
});
31+
32+
const FindSuccess = Schema.Struct({ text: Schema.String, details: Schema.optional(FindDetails) });
33+
34+
class FindFailed extends Schema.TaggedError<FindFailed>()("FindFailed", { message: Schema.String }) {}
35+
36+
const DEFAULT_LIMIT = 1000;
37+
const FD_ERROR = "[fd error]:";
38+
39+
export const findDef = Tool.define({
40+
name: "find",
41+
label: "find",
42+
description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
43+
promptSnippet: "Find files by glob pattern (respects .gitignore)",
44+
parameters: FindParams,
45+
constrainedSampling: { type: "json_schema", strict: "prefer" },
46+
success: FindSuccess,
47+
failure: FindFailed,
48+
encodeContent: (success) => [{ type: "text", text: success.text }],
49+
encodeFailureContent: (failure) => [{ type: "text", text: failure.message }],
50+
encodeDetails: (success) => success.details,
51+
});
52+
53+
const fail = (message: string) => Effect.fail(new FindFailed({ message }));
54+
55+
/** Relative to the search root, keeping a directory's trailing slash. */
56+
const relativize = (result: string, searchPath: string) => {
57+
const relative = posix.isAbsolute(result) ? posix.relative(searchPath, result) : result;
58+
return result.endsWith("/") && !relative.endsWith("/") ? `${relative}/` : relative;
59+
};
60+
61+
const insideGitRepo = (fs: SandboxFileSystem.Interface, searchPath: string) =>
62+
Effect.gen(function* () {
63+
for (let current = searchPath; ; current = posix.dirname(current)) {
64+
if (yield* fs.exists(posix.join(current, ".git")).pipe(Effect.orElseSucceed(() => false))) return true;
65+
if (posix.dirname(current) === current) return false;
66+
}
67+
});
68+
69+
export const findHandler: Tool.Handler<
70+
typeof FindParams,
71+
typeof FindSuccess,
72+
typeof FindFailed,
73+
SandboxIO.FileSystem | SandboxIO.Shell | SandboxIO.Current | Binary.HostBin
74+
> = (params) =>
75+
Effect.gen(function* () {
76+
const fs = yield* SandboxIO.FileSystem;
77+
const shell = yield* SandboxIO.Shell;
78+
const sandbox = yield* SandboxIO.Current;
79+
const searchPath = posix.resolve(sandbox.cwd, ToolPath.normalize(params.path || "."));
80+
const limit = Math.max(1, Math.trunc(params.limit ?? DEFAULT_LIMIT));
81+
if (!(yield* fs.exists(searchPath).pipe(Effect.orElseSucceed(() => false))))
82+
return yield* fail(`Path not found: ${searchPath}`);
83+
84+
// fd honours .gitignore outside a repo only when told to. Inside one, its own
85+
// git awareness stops a parent's rules at a nested repo.
86+
const repo = yield* insideGitRepo(fs, searchPath);
87+
const argv = ["--glob", "--color=never", "--hidden", ...(repo ? [] : ["--no-require-git"])];
88+
argv.push("--max-results", String(limit));
89+
// fd matches a glob against the basename unless `--full-path`, which matches the
90+
// absolute path, so a pattern with a directory in it needs a leading `**/`.
91+
let pattern = params.pattern;
92+
if (pattern.includes("/")) {
93+
argv.push("--full-path");
94+
if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") pattern = `**/${pattern}`;
95+
}
96+
argv.push("--", pattern, searchPath);
97+
98+
const run = yield* Binary.withCommand("fd", sandbox, shell, fs, (command) =>
99+
shell.exec(quoteArgv([command, ...argv])),
100+
);
101+
if ("_tag" in run) return yield* fail(run.message);
102+
103+
// Some backends merge stderr into stdout; fd marks every error line, so they are told apart.
104+
const output = run.stdout
105+
.split("\n")
106+
.map((line) => line.replace(/\r$/, "").trim())
107+
.filter((line) => line.length > 0);
108+
const lines = output.filter((line) => !line.startsWith(FD_ERROR));
109+
const errors = [run.stderr.trim(), ...output.filter((line) => line.startsWith(FD_ERROR))]
110+
.filter((text) => text.length > 0)
111+
.join("\n");
112+
if (run.exitCode !== 0 && lines.length === 0) return yield* fail(errors || `fd exited with code ${run.exitCode}`);
113+
if (lines.length === 0) return { text: "No files found matching pattern" };
114+
115+
// fd reports in thread order; sorting keeps the result repeatable.
116+
const results = lines.map((line) => relativize(line, searchPath)).sort();
117+
const resultLimitReached = results.length >= limit;
118+
const truncation = truncateHead(results.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER });
119+
const notices: Array<string> = [];
120+
const details: { truncation?: unknown; resultLimitReached?: number } = {};
121+
if (resultLimitReached) {
122+
notices.push(`${limit} results limit reached. Use limit=${limit * 2} for more, or refine pattern`);
123+
details.resultLimitReached = limit;
124+
}
125+
if (truncation.truncated) {
126+
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
127+
details.truncation = truncation;
128+
}
129+
const text = notices.length > 0 ? `${truncation.content}\n\n[${notices.join(". ")}]` : truncation.content;
130+
return Object.keys(details).length > 0 ? { text, details } : { text };
131+
}).pipe(Effect.catchTag("ShellError", (error) => Effect.die(error)));
132+
133+
/** The find tool: definition + handler, wired the testable (def/exec split) way. */
134+
export const findTool = Tool.implement(findDef, findHandler);
135+
136+
export const findPlugin = define({
137+
id: "codework.tool.find",
138+
kind: "tool",
139+
optIn: true,
140+
setup: Effect.fn("FindPlugin.setup")(function* (ctx) {
141+
const mounted = Layer.mergeAll(
142+
Layer.succeed(SandboxIO.FileSystem, yield* SandboxIO.FileSystem),
143+
Layer.succeed(SandboxIO.Shell, yield* SandboxIO.Shell),
144+
Layer.succeed(SandboxIO.Current, yield* SandboxIO.Current),
145+
Layer.succeed(Binary.HostBin, ctx.paths.bin),
146+
);
147+
ctx.plugin.tools.add(Tool.provide(findTool, mounted));
148+
}),
149+
});

0 commit comments

Comments
 (0)