Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 7b6258fbfd feat(core): add glob search options 2026-09-01 15:12:07 -05:00
4 changed files with 56 additions and 2 deletions
+2
View File
@@ -37,6 +37,8 @@ export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
pattern: Schema.String,
path: Schema.optionalKey(RelativePath),
hidden: Schema.optionalKey(Schema.Boolean),
gitignore: Schema.optionalKey(Schema.Boolean),
limit: Schema.optionalKey(PositiveInt),
}) {}
+8 -2
View File
@@ -4,6 +4,7 @@ import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Entry, Match } from "@opencode-ai/schema/filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Glob } from "@opencode-ai/util/glob"
import { collectStream, waitForAbort } from "@opencode-ai/util/process"
import { Environment } from "./environment/index.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
@@ -65,6 +66,7 @@ export interface GlobInput {
readonly pattern: string
readonly limit: number
readonly hidden?: boolean
readonly gitignore?: boolean
readonly follow?: boolean
readonly signal?: AbortSignal
}
@@ -169,12 +171,16 @@ const layer = Layer.effect(
"--no-config",
"--files",
...(input.hidden ? ["--hidden"] : []),
...(input.gitignore === false ? ["--no-ignore"] : []),
...(input.follow ? ["--follow"] : []),
`--glob=${input.pattern}`,
"--glob=!**/.git/**",
".",
],
parse: (line) => Effect.succeed(normalizePath(line)),
parse: (line) => {
const relative = normalizePath(line)
const pattern = input.pattern.includes("/") ? input.pattern : `**/${input.pattern}`
return Effect.succeed(Glob.match(pattern, relative) ? relative : undefined)
},
}).pipe(
Effect.map((result) =>
result.map((relative) =>
+10
View File
@@ -19,6 +19,12 @@ export const Input = Schema.Struct({
path: Schema.optionalKey(RelativePath).annotate({
description: "Directory to search. Defaults to the current working directory.",
}),
hidden: FileSystem.GlobInput.fields.hidden.annotate({
description: "Include hidden files (default: true).",
}),
gitignore: FileSystem.GlobInput.fields.gitignore.annotate({
description: "Respect .gitignore rules (default: true).",
}),
limit: FileSystem.GlobInput.fields.limit.annotate({
description: `Maximum number of matching files to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
}),
@@ -76,6 +82,8 @@ export const Plugin = {
metadata: {
root: searchPath ?? ".",
path: searchPath,
hidden: input.hidden,
gitignore: input.gitignore,
limit: input.limit,
},
sessionID: context.sessionID,
@@ -97,6 +105,8 @@ export const Plugin = {
.glob({
cwd: root,
pattern: input.pattern,
hidden: input.hidden ?? true,
gitignore: input.gitignore ?? true,
limit: limit + 1,
})
.pipe(
+36
View File
@@ -105,6 +105,42 @@ describe("search tools", () => {
),
)
it.live("controls hidden and ignored glob results", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.$`git init -q ${tmp.path}`)
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".gitignore"), "ignored.txt\n"))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".hidden.txt"), "hidden\n"))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "ignored.txt"), "ignored\n"))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "visible.txt"), "visible\n"))
yield* withTools(tmp.path, (registry) =>
Effect.gen(function* () {
const defaults = yield* executeTool(registry, call("glob", { pattern: "**/*" }))
expect(defaults).toMatchObject({ status: "completed" })
const defaultsText = defaults.content?.[0]?.type === "text" ? defaults.content[0].text : ""
expect(defaultsText).toContain(path.join(tmp.path, ".hidden.txt"))
expect(defaultsText).toContain(path.join(tmp.path, "visible.txt"))
expect(defaultsText).not.toContain(path.join(tmp.path, "ignored.txt"))
const visible = yield* executeTool(registry, call("glob", { pattern: "**/*", hidden: false }))
expect(visible).toMatchObject({ status: "completed" })
const visibleText = visible.content?.[0]?.type === "text" ? visible.content[0].text : ""
expect(visibleText).not.toContain(path.join(tmp.path, ".hidden.txt"))
const ignored = yield* executeTool(registry, call("glob", { pattern: "**/*", gitignore: false }))
expect(ignored).toMatchObject({ status: "completed" })
const ignoredText = ignored.content?.[0]?.type === "text" ? ignored.content[0].text : ""
expect(ignoredText).toContain(path.join(tmp.path, "ignored.txt"))
}),
)
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("rejects an empty grep pattern", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),