summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 16:33:02 +0900
committerAdam Malczewski <[email protected]>2026-06-02 16:33:02 +0900
commit0f99a0c92707b44aef7c627012c4711bad5a8efd (patch)
tree4b9fdc0895b30339d6501ed6387b02bc364bcdb6 /packages
parentc0c08720cceb75b5e635e71190ae1f956f535133 (diff)
downloaddispatch-0f99a0c92707b44aef7c627012c4711bad5a8efd.tar.gz
dispatch-0f99a0c92707b44aef7c627012c4711bad5a8efd.zip
feat: add search_code tool wrapping the cs code-search engine
Add a dedicated, permission-gated search_code tool that wraps boyter/cs (code spelunker) — a fast, relevance-ranked, structure-aware code search engine — giving agents a better default than grep/find for exploratory 'where is X / how does Y work' searches (ranked results, snippets, ~5x smaller payloads). - packages/core/src/tools/search-code.ts: createSearchCodeTool factory; -f json invocation, workdir path containment, graceful missing-binary handling (DISPATCH_CS_BIN override), readable per-file formatted output. - Wire-up: export from core; register in agent-manager (both child-whitelist and parent perm paths) behind new perm_search_code; add to summon catalog + tools enum; frontend ToolPermissions + settings. - Docker: build a patched, statically-linked cs (pinned v3.1.0 commit) in a golang builder stage and bundle at /usr/local/bin/cs. - docker/cs/luau-declarations.patch: additive Luau declaration table so --only-declarations / definition ranking works for Roblox .luau files (upstream has Lua but not Luau). Applied during the Docker build. - Tests: new search-code.test.ts (stubbed JSON formatting + live-cs integration, skipped when cs absent); agent-manager/routes mocks + perm-gating assertions; loader pass-through. All tests (596), biome, and tsc (core/api/frontend) pass. cs-builder Docker stage verified to build and produce a working patched binary.
Diffstat (limited to 'packages')
-rw-r--r--packages/api/src/agent-manager.ts18
-rw-r--r--packages/api/tests/agent-manager.test.ts29
-rw-r--r--packages/api/tests/routes.test.ts8
-rw-r--r--packages/core/src/index.ts1
-rw-r--r--packages/core/src/tools/search-code.ts262
-rw-r--r--packages/core/src/tools/summon.ts2
-rw-r--r--packages/core/tests/agents/loader.test.ts2
-rw-r--r--packages/core/tests/tools/search-code.test.ts194
-rw-r--r--packages/frontend/src/lib/components/ToolPermissions.svelte5
-rw-r--r--packages/frontend/src/lib/settings.svelte.ts2
10 files changed, 522 insertions, 1 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 85dd160..48b869e 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -19,6 +19,7 @@ import {
createReadTabTool,
createRetrieveTool,
createRunShellTool,
+ createSearchCodeTool,
createSendToTabTool,
createSkillsWatcher,
createSummonTool,
@@ -75,6 +76,8 @@ const TOOL_DESCRIPTIONS: Record<string, string> = {
write_file: "Write content to a file (creates parent directories if needed)",
run_shell:
"Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Set background=true to run in the background and get a job_id for later retrieval. Do NOT run destructive or irreversible commands unless the user explicitly requests them.",
+ search_code:
+ "Search the codebase by query using the 'cs' code search engine (relevance-ranked, structure-aware). Returns the most relevant files first with matching snippets and line numbers. Better than grep/find for exploratory 'where is X / how does Y work' searches; use run_shell with rg for exhaustive exact-match lists.",
todo: "Create/maintain a todo list to plan and track work. Declarative whole-list write: send the entire list in `todos` each call (it replaces the previous list). Statuses: pending, in_progress, completed, cancelled.",
summon:
"Spawn a child agent to work on a task independently. By default blocks until the child finishes. Set background=true to return immediately with an agent_id for later retrieval.",
@@ -403,9 +406,10 @@ export class AgentManager {
const permSendToTab = getSetting("perm_send_to_tab") === "allow";
const permReadTab = getSetting("perm_read_tab") === "allow";
const permWebSearch = getSetting("perm_web_search") === "allow";
+ const permSearchCode = getSetting("perm_search_code") === "allow";
const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow";
const sysPrompt = getSetting("system_prompt") ?? "";
- const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${sysPrompt}`;
+ const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permSearchCode}:${sysPrompt}`;
// If the override differs or permissions changed, invalidate the cached agent
if (
@@ -479,6 +483,12 @@ export class AgentManager {
tool: createRunShellTool(workingDirectory, tabAgent.shellStore),
});
}
+ if (allowed.has("search_code")) {
+ toolEntries.push({
+ name: "search_code",
+ tool: createSearchCodeTool(workingDirectory),
+ });
+ }
if (allowed.has("web_search")) {
toolEntries.push({ name: "web_search", tool: createWebSearchTool() });
}
@@ -565,6 +575,12 @@ export class AgentManager {
tool: createRunShellTool(workingDirectory, tabAgent.shellStore),
});
}
+ if (permSearchCode) {
+ toolEntries.push({
+ name: "search_code",
+ tool: createSearchCodeTool(workingDirectory),
+ });
+ }
if (permWebSearch) {
toolEntries.push({ name: "web_search", tool: createWebSearchTool() });
}
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index 014022a..970ac1d 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -419,6 +419,14 @@ vi.mock("@dispatch/core", () => ({
execute: async () => "mock",
};
},
+ createSearchCodeTool(_wd: string) {
+ return {
+ name: "search_code",
+ description: "search code",
+ parameters: { _type: "z.ZodObject", shape: {} },
+ execute: async () => "mock",
+ };
+ },
createYoutubeTranscribeTool() {
return {
name: "youtube_transcribe",
@@ -1441,6 +1449,27 @@ describe("AgentManager", () => {
});
});
+ describe("search_code permission gating", () => {
+ // Reuses the parent-path tool construction to confirm the perm flag wires
+ // the search_code tool on/off correctly.
+ async function toolsForPerms(tabId: string, perms: Record<string, string>): Promise<string[]> {
+ for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v);
+ const manager = new AgentManager();
+ await manager.processMessage(tabId, "go");
+ return constructedAgents.at(-1)?.toolNames ?? [];
+ }
+
+ it("grants search_code when perm_search_code is allowed", async () => {
+ const tools = await toolsForPerms("tab-cs-on", { perm_search_code: "allow" });
+ expect(tools).toContain("search_code");
+ });
+
+ it("omits search_code when perm_search_code is not allowed", async () => {
+ const tools = await toolsForPerms("tab-cs-off", {});
+ expect(tools).not.toContain("search_code");
+ });
+ });
+
// ─── Usage side-channel persistence ──────────────────────────────
//
// `usage` AgentEvents (one per LLM round-trip) are persisted as invisible
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index a8db5ce..c85d43d 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -283,6 +283,14 @@ vi.mock("@dispatch/core", () => ({
execute: async () => "mock",
};
},
+ createSearchCodeTool(_wd: string) {
+ return {
+ name: "search_code",
+ description: "search code",
+ parameters: { _type: "z.ZodObject", shape: {} },
+ execute: async () => "mock",
+ };
+ },
createYoutubeTranscribeTool() {
return {
name: "youtube_transcribe",
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8334102..e5e6f9a 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -94,6 +94,7 @@ export { createReadTabTool, type ReadTabCallbacks } from "./tools/read-tab.js";
export { createToolRegistry } from "./tools/registry.js";
export { createRetrieveTool, type RetrieveCallbacks } from "./tools/retrieve.js";
export { BackgroundShellStore, createRunShellTool } from "./tools/run-shell.js";
+export { createSearchCodeTool } from "./tools/search-code.js";
export {
createSendToTabTool,
type ResolvedTabRef,
diff --git a/packages/core/src/tools/search-code.ts b/packages/core/src/tools/search-code.ts
new file mode 100644
index 0000000..39cbbdd
--- /dev/null
+++ b/packages/core/src/tools/search-code.ts
@@ -0,0 +1,262 @@
+import { spawn } from "node:child_process";
+import { relative } from "node:path";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+import { canonicalize } from "./path-utils.js";
+
+// Resolve the `cs` binary: an explicit override wins, otherwise rely on PATH.
+// The deployed images build a patched, statically-linked `cs` into
+// /usr/local/bin/cs (see Dockerfile); local dev can point DISPATCH_CS_BIN at a
+// custom build. Read at call time so the environment can change at runtime
+// (and so tests can point it at a stub or temp build).
+function resolveCsBin(): string {
+ return process.env.DISPATCH_CS_BIN || "cs";
+}
+
+const DEFAULT_RESULT_LIMIT = 20;
+const MAX_RESULT_LIMIT = 100;
+const MAX_CONTEXT = 20;
+const MIN_SNIPPET_LENGTH = 50;
+const MAX_SNIPPET_LENGTH = 2000;
+const TIMEOUT_MS = 30_000;
+
+/** Maps the `only` enum to the corresponding cs flag. */
+const ONLY_FLAGS: Record<string, string> = {
+ code: "--only-code",
+ comments: "--only-comments",
+ strings: "--only-strings",
+ declarations: "--only-declarations",
+ usages: "--only-usages",
+};
+
+/** One line within a cs JSON match result. */
+interface CsLine {
+ line_number: number;
+ content: string;
+ match_positions?: Array<[number, number]>;
+}
+
+/** A single file result in cs `-f json` output. */
+interface CsResult {
+ filename: string;
+ location: string;
+ score: number;
+ lines: CsLine[];
+ language?: string;
+ total_lines?: number;
+}
+
+export function createSearchCodeTool(workingDirectory: string): ToolDefinition {
+ return {
+ name: "search_code",
+ description:
+ "Search the codebase by query using `cs` (code spelunker) — a fast, relevance-ranked code search engine. " +
+ "Prefer this over grep/find for EXPLORATORY 'where is X / how does Y work' searches: it ranks the most " +
+ "relevant files first and returns matching snippets with line numbers, so you spend fewer turns and tokens. " +
+ "It respects .gitignore and skips hidden/binary files. " +
+ 'Query syntax: space-separated terms are AND\'d; supports OR, NOT, "exact phrases", fuzzy~1, /regex/, and ' +
+ "metadata filters like lang:Go, file:test, path:src. " +
+ "It is a ranked text search, NOT a semantic/LSP index: it won't resolve types or imports. For an EXHAUSTIVE " +
+ "list of every exact match (e.g. before a rename), use run_shell with ripgrep (rg) instead.",
+ parameters: z.object({
+ query: z
+ .string()
+ .describe(
+ 'The search query. Terms are AND\'d by default. Supports OR, NOT, "phrases", fuzzy~1, /regex/, and filters like lang:Go, file:test, path:src.',
+ ),
+ path: z
+ .string()
+ .optional()
+ .describe(
+ "Subdirectory to scope the search to, relative to the working directory. Defaults to the whole working directory.",
+ ),
+ case_sensitive: z
+ .boolean()
+ .optional()
+ .describe("Make the search case-sensitive. Default: false (case-insensitive)."),
+ include_ext: z
+ .string()
+ .optional()
+ .describe(
+ 'Comma-separated list of file extensions to limit the search to (case-sensitive), e.g. "go,ts,lua".',
+ ),
+ exclude_pattern: z
+ .string()
+ .optional()
+ .describe(
+ 'Comma-separated list of path patterns to exclude (case-sensitive), e.g. "vendor,_test.go".',
+ ),
+ context: z
+ .number()
+ .int()
+ .min(0)
+ .optional()
+ .describe(`Lines of context to show around each match (0-${MAX_CONTEXT}).`),
+ result_limit: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe(
+ `Maximum number of file results to return. Default: ${DEFAULT_RESULT_LIMIT}, max: ${MAX_RESULT_LIMIT}.`,
+ ),
+ snippet_length: z
+ .number()
+ .int()
+ .min(MIN_SNIPPET_LENGTH)
+ .optional()
+ .describe(
+ `Size in bytes of each snippet shown (${MIN_SNIPPET_LENGTH}-${MAX_SNIPPET_LENGTH}). Larger = more context per match.`,
+ ),
+ only: z
+ .enum(["code", "comments", "strings", "declarations", "usages"])
+ .optional()
+ .describe(
+ "Restrict matches structurally: code, comments, strings, declarations (definitions like func/class/type), " +
+ "or usages (call sites). Best-effort and language-dependent — strong for Go/TypeScript/Python/Lua/Luau, " +
+ "unavailable for unsupported languages (which fall back to plain text ranking).",
+ ),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const query = args.query as string;
+ if (typeof query !== "string" || query.trim() === "") {
+ return "Error: query is required.";
+ }
+
+ // Resolve and contain the optional search path within the workdir.
+ // Canonicalize so a symlink-in-workdir pointing outside is detected,
+ // matching the containment semantics of list_files / read_file.
+ const relPath = (args.path as string | undefined) ?? ".";
+ const absoluteWorkDir = await canonicalize(workingDirectory);
+ const searchDir = await canonicalize(workingDirectory, relPath);
+ if (searchDir !== absoluteWorkDir && !searchDir.startsWith(`${absoluteWorkDir}/`)) {
+ return `Error: Path "${relPath}" is outside the working directory.`;
+ }
+
+ const flags = buildFlags(args, searchDir);
+ const spawnArgs = [...flags, query];
+
+ let stdout = "";
+ let stderr = "";
+ const result = await new Promise<{ error?: string; errorCode?: string }>((resolve) => {
+ const child = spawn(resolveCsBin(), spawnArgs, {
+ cwd: workingDirectory,
+ env: process.env,
+ timeout: TIMEOUT_MS,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ child.stdout?.on("data", (d: Buffer) => {
+ stdout += d.toString();
+ });
+ child.stderr?.on("data", (d: Buffer) => {
+ stderr += d.toString();
+ });
+ child.on("close", () => resolve({}));
+ child.on("error", (err) =>
+ resolve({ error: err.message, errorCode: (err as NodeJS.ErrnoException).code }),
+ );
+ });
+
+ if (result.error) {
+ // The binary is missing or not executable — give an actionable hint.
+ if (result.errorCode === "ENOENT" || result.error.includes("ENOENT")) {
+ return missingBinaryError();
+ }
+ return `Error: failed to run cs: ${result.error}`;
+ }
+
+ // cs prints `null` (and exit 0) when there are no matches.
+ const trimmed = stdout.trim();
+ if (trimmed === "" || trimmed === "null") {
+ return "No matches found.";
+ }
+
+ let parsed: CsResult[];
+ try {
+ parsed = JSON.parse(trimmed) as CsResult[];
+ } catch {
+ // Couldn't parse — surface what cs produced so the caller isn't blind.
+ const detail = stderr.trim() ? `\nstderr: ${stderr.trim()}` : "";
+ return `Error: could not parse cs output as JSON.${detail}\n\nRaw output:\n${trimmed.slice(0, 2000)}`;
+ }
+
+ if (!Array.isArray(parsed) || parsed.length === 0) {
+ return "No matches found.";
+ }
+
+ return formatResults(parsed, absoluteWorkDir);
+ },
+ };
+}
+
+/** Build the cs CLI flags (everything except the trailing query). */
+function buildFlags(args: Record<string, unknown>, searchDir: string): string[] {
+ const flags: string[] = ["-f", "json", "--dir", searchDir];
+
+ if (args.case_sensitive === true) flags.push("-c");
+
+ const includeExt = args.include_ext as string | undefined;
+ if (includeExt?.trim()) flags.push("-i", includeExt.trim());
+
+ const excludePattern = args.exclude_pattern as string | undefined;
+ if (excludePattern?.trim()) flags.push("-x", excludePattern.trim());
+
+ if (typeof args.context === "number") {
+ const context = clamp(Math.floor(args.context), 0, MAX_CONTEXT);
+ flags.push("-C", String(context));
+ }
+
+ const requestedLimit =
+ typeof args.result_limit === "number"
+ ? clamp(Math.floor(args.result_limit), 1, MAX_RESULT_LIMIT)
+ : DEFAULT_RESULT_LIMIT;
+ flags.push("--result-limit", String(requestedLimit));
+
+ if (typeof args.snippet_length === "number") {
+ const snippet = clamp(Math.floor(args.snippet_length), MIN_SNIPPET_LENGTH, MAX_SNIPPET_LENGTH);
+ flags.push("-n", String(snippet));
+ }
+
+ const only = args.only as string | undefined;
+ if (only && ONLY_FLAGS[only]) flags.push(ONLY_FLAGS[only]);
+
+ return flags;
+}
+
+/** Render cs JSON results into compact, readable per-file blocks. */
+function formatResults(results: CsResult[], absoluteWorkDir: string): string {
+ const blocks: string[] = [];
+ for (const r of results) {
+ // Present paths relative to the workdir so output is portable and compact.
+ const rel = r.location.startsWith(absoluteWorkDir)
+ ? relative(absoluteWorkDir, r.location) || r.filename
+ : r.location;
+ const lang = r.language ? ` [${r.language}]` : "";
+ const score = typeof r.score === "number" ? ` (score ${r.score.toFixed(2)})` : "";
+ const header = `${rel}${lang}${score}`;
+
+ const lines = (r.lines ?? []).map((l) => {
+ const marker = l.match_positions && l.match_positions.length > 0 ? ">" : " ";
+ return ` ${marker} ${l.line_number}: ${l.content}`;
+ });
+
+ blocks.push([header, ...lines].join("\n"));
+ }
+
+ const count = results.length;
+ const heading = `Found matches in ${count} file${count === 1 ? "" : "s"} (ranked by relevance):`;
+ return [heading, "", blocks.join("\n\n")].join("\n");
+}
+
+function clamp(n: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, n));
+}
+
+function missingBinaryError(): string {
+ return [
+ "Error: search_code requires the 'cs' (code spelunker) binary, which was not found.",
+ "Install it with: go install github.com/boyter/cs/[email protected]",
+ "or set the DISPATCH_CS_BIN environment variable to the path of a cs binary.",
+ "(In the official Docker images cs is bundled at /usr/local/bin/cs.)",
+ ].join("\n");
+}
diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts
index 4820e89..250af25 100644
--- a/packages/core/src/tools/summon.ts
+++ b/packages/core/src/tools/summon.ts
@@ -172,6 +172,7 @@ export function createSummonTool(
" - list_files: List files and directories",
" - write_file: Write/edit files",
" - run_shell: Execute shell commands",
+ " - search_code: Search the codebase with the cs ranked code-search engine",
" - todo: Track work items",
" - summon: Spawn its own child agents (enables nesting)",
" - retrieve: Collect results from its children (required if summon is given)",
@@ -229,6 +230,7 @@ export function createSummonTool(
"list_files",
"write_file",
"run_shell",
+ "search_code",
"todo",
"summon",
"retrieve",
diff --git a/packages/core/tests/agents/loader.test.ts b/packages/core/tests/agents/loader.test.ts
index 92f9877..a223a4f 100644
--- a/packages/core/tests/agents/loader.test.ts
+++ b/packages/core/tests/agents/loader.test.ts
@@ -43,6 +43,7 @@ describe("expandAgentToolNames", () => {
"retrieve",
"web_search",
"youtube_transcribe",
+ "search_code",
"send_to_tab",
"read_tab",
]);
@@ -52,6 +53,7 @@ describe("expandAgentToolNames", () => {
"retrieve",
"web_search",
"youtube_transcribe",
+ "search_code",
"send_to_tab",
"read_tab",
]),
diff --git a/packages/core/tests/tools/search-code.test.ts b/packages/core/tests/tools/search-code.test.ts
new file mode 100644
index 0000000..b805d15
--- /dev/null
+++ b/packages/core/tests/tools/search-code.test.ts
@@ -0,0 +1,194 @@
+import { spawnSync } from "node:child_process";
+import { chmodSync, writeFileSync } from "node:fs";
+import { mkdtemp as mkdtempP, rm as rmP, writeFile as writeFileP } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createSearchCodeTool } from "../../src/tools/search-code.js";
+
+// A tiny stub that impersonates `cs`: it ignores its args and prints whatever
+// JSON we put in the CS_STUB_OUTPUT env var. This makes JSON→text formatting
+// tests fully deterministic without needing a real cs binary in CI.
+function writeStub(dir: string, body: string): string {
+ const stubPath = join(dir, "cs-stub.sh");
+ writeFileSync(stubPath, body, { mode: 0o755 });
+ chmodSync(stubPath, 0o755);
+ return stubPath;
+}
+
+const ECHO_ENV_STUB = `#!/usr/bin/env bash
+printf '%s' "$CS_STUB_OUTPUT"
+`;
+
+describe("search_code tool", () => {
+ let workDir: string;
+ const savedBin = process.env.DISPATCH_CS_BIN;
+ const savedStubOut = process.env.CS_STUB_OUTPUT;
+
+ beforeEach(async () => {
+ workDir = await mkdtempP(join(tmpdir(), "dispatch-cs-test-"));
+ });
+
+ afterEach(async () => {
+ await rmP(workDir, { recursive: true, force: true });
+ if (savedBin === undefined) delete process.env.DISPATCH_CS_BIN;
+ else process.env.DISPATCH_CS_BIN = savedBin;
+ if (savedStubOut === undefined) delete process.env.CS_STUB_OUTPUT;
+ else process.env.CS_STUB_OUTPUT = savedStubOut;
+ });
+
+ it("exposes the expected name and schema", () => {
+ const tool = createSearchCodeTool(workDir);
+ expect(tool.name).toBe("search_code");
+ expect(tool.description).toContain("cs");
+ // query is required; a representative set of optional knobs exist.
+ const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
+ expect(shape.query).toBeDefined();
+ expect(shape.path).toBeDefined();
+ expect(shape.only).toBeDefined();
+ expect(shape.result_limit).toBeDefined();
+ });
+
+ it("requires a non-empty query", async () => {
+ const tool = createSearchCodeTool(workDir);
+ const out = await tool.execute({ query: " " });
+ expect(out).toMatch(/^Error:/);
+ expect(out).toContain("query is required");
+ });
+
+ it("rejects a path outside the working directory", async () => {
+ const tool = createSearchCodeTool(workDir);
+ const out = await tool.execute({ query: "anything", path: "../../etc" });
+ expect(out).toMatch(/^Error:/);
+ expect(out).toContain("outside the working directory");
+ });
+
+ it("returns an actionable error when the cs binary is missing", async () => {
+ process.env.DISPATCH_CS_BIN = "/nonexistent/path/to/cs-binary-xyz";
+ const tool = createSearchCodeTool(workDir);
+ const out = await tool.execute({ query: "anything" });
+ expect(out).toMatch(/^Error:/);
+ expect(out).toContain("requires the 'cs'");
+ expect(out).toContain("DISPATCH_CS_BIN");
+ });
+
+ it("reports no matches when cs outputs null", async () => {
+ const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
+ try {
+ process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
+ process.env.CS_STUB_OUTPUT = "null";
+ const tool = createSearchCodeTool(workDir);
+ const out = await tool.execute({ query: "nothinghere" });
+ expect(out).toBe("No matches found.");
+ } finally {
+ await rmP(stubDir, { recursive: true, force: true });
+ }
+ });
+
+ it("formats cs JSON results into readable per-file blocks", async () => {
+ const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
+ try {
+ const csJson = JSON.stringify([
+ {
+ filename: "web-search.ts",
+ location: join(workDir, "packages/core/src/tools/web-search.ts"),
+ score: 5.24,
+ language: "TypeScript",
+ total_lines: 106,
+ lines: [
+ { line_number: 7, content: "" },
+ {
+ line_number: 8,
+ content: "export function createWebSearchTool(): ToolDefinition {",
+ match_positions: [[16, 35]],
+ },
+ { line_number: 9, content: "\treturn {" },
+ ],
+ },
+ {
+ filename: "index.ts",
+ location: join(workDir, "packages/core/src/index.ts"),
+ score: 1.1,
+ language: "TypeScript",
+ lines: [{ line_number: 113, content: 'export { createWebSearchTool } from "./web.js";' }],
+ },
+ ]);
+ process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
+ process.env.CS_STUB_OUTPUT = csJson;
+
+ const tool = createSearchCodeTool(workDir);
+ const out = await tool.execute({ query: "createWebSearchTool" });
+
+ expect(out).toContain("Found matches in 2 files");
+ // Paths are rendered relative to the workdir.
+ expect(out).toContain("packages/core/src/tools/web-search.ts [TypeScript] (score 5.24)");
+ expect(out).not.toContain(workDir);
+ // Matched line is marked with '>'; line numbers + content present.
+ expect(out).toContain("> 8: export function createWebSearchTool(): ToolDefinition {");
+ expect(out).toContain(" 7: ");
+ expect(out).toContain("packages/core/src/index.ts [TypeScript] (score 1.10)");
+ } finally {
+ await rmP(stubDir, { recursive: true, force: true });
+ }
+ });
+
+ it("surfaces raw output when cs returns unparseable JSON", async () => {
+ const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
+ try {
+ process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
+ process.env.CS_STUB_OUTPUT = "this is not json";
+ const tool = createSearchCodeTool(workDir);
+ const out = await tool.execute({ query: "x" });
+ expect(out).toMatch(/^Error:/);
+ expect(out).toContain("could not parse cs output");
+ expect(out).toContain("this is not json");
+ } finally {
+ await rmP(stubDir, { recursive: true, force: true });
+ }
+ });
+
+ // ── Live integration: only runs when a real `cs` binary is available. ──
+ const liveCsBin = findRealCs();
+ describe.runIf(liveCsBin)("live cs binary", () => {
+ it("finds a real match and ranks the defining file", async () => {
+ process.env.DISPATCH_CS_BIN = liveCsBin as string;
+ // Seed a small tree with a clear match.
+ await writeFileP(
+ join(workDir, "alpha.ts"),
+ "export function findTheNeedle() {\n return 42;\n}\n",
+ );
+ await writeFileP(join(workDir, "beta.ts"), "const x = 1;\n// nothing relevant here\n");
+ const tool = createSearchCodeTool(workDir);
+ const out = await tool.execute({ query: "findTheNeedle" });
+ expect(out).toContain("alpha.ts");
+ expect(out).toContain("findTheNeedle");
+ expect(out).not.toContain("Error:");
+ });
+
+ it("returns 'No matches found.' for a query with no hits", async () => {
+ process.env.DISPATCH_CS_BIN = liveCsBin as string;
+ await writeFileP(join(workDir, "alpha.ts"), "export const a = 1;\n");
+ const tool = createSearchCodeTool(workDir);
+ const out = await tool.execute({ query: "zzz_nonexistent_token_qqq" });
+ expect(out).toBe("No matches found.");
+ });
+ });
+});
+
+/**
+ * Locate a usable `cs` binary for live tests. Honors DISPATCH_CS_TEST_BIN, then
+ * a `cs` on PATH. Returns null when none is runnable, so the live suite is
+ * skipped rather than failing in environments without cs.
+ */
+function findRealCs(): string | null {
+ const candidates = [process.env.DISPATCH_CS_TEST_BIN, "cs"].filter(Boolean) as string[];
+ for (const bin of candidates) {
+ try {
+ const res = spawnSync(bin, ["--version"], { stdio: "ignore" });
+ if (res.status === 0) return bin;
+ } catch {
+ // try next
+ }
+ }
+ return null;
+}
diff --git a/packages/frontend/src/lib/components/ToolPermissions.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte
index 0caf0fa..db5055c 100644
--- a/packages/frontend/src/lib/components/ToolPermissions.svelte
+++ b/packages/frontend/src/lib/components/ToolPermissions.svelte
@@ -47,6 +47,11 @@ const toolPermissions: ToolPermission[] = [
label: "YouTube transcripts",
description: "Allow the AI to fetch YouTube video transcripts",
},
+ {
+ id: "search_code",
+ label: "Search code",
+ description: "Allow the AI to search the codebase with the cs ranked code-search engine",
+ },
];
const {
diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts
index 123008d..30361a7 100644
--- a/packages/frontend/src/lib/settings.svelte.ts
+++ b/packages/frontend/src/lib/settings.svelte.ts
@@ -14,6 +14,7 @@ let toolPerms = $state<Record<string, boolean>>({
external_directory: false,
web_search: false,
youtube_transcribe: false,
+ search_code: false,
});
let savedToolPerms = $state<Record<string, boolean>>({
read: true,
@@ -26,6 +27,7 @@ let savedToolPerms = $state<Record<string, boolean>>({
external_directory: false,
web_search: false,
youtube_transcribe: false,
+ search_code: false,
});
let skillChecks = $state<Record<string, boolean>>({});
let chunkLimit = $state(100);