chore: fork — remove vendor CI, repoint release checks to Neuron Gitea
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./git.txt"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
const MAX_OUTPUT_BYTES = 50 * 1024
|
||||
|
||||
const OPERATIONS = ["status", "diff", "log", "blame", "branch"] as const
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
operation: Schema.Literals(OPERATIONS).annotate({
|
||||
description: "The read-only git operation to run",
|
||||
}),
|
||||
path: Schema.optional(Schema.String).annotate({
|
||||
description:
|
||||
"File or directory the operation applies to. Required for blame. Defaults to the working directory for status/diff/log/branch.",
|
||||
}),
|
||||
ref: Schema.optional(Schema.String).annotate({
|
||||
description: 'Revision argument for diff/log (e.g. "HEAD~1", "main").',
|
||||
}),
|
||||
})
|
||||
|
||||
export const GitTool = Tool.define(
|
||||
"git",
|
||||
Effect.gen(function* () {
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: { operation: string; path?: string; ref?: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const ins = yield* InstanceState.context
|
||||
let target = params.path ?? ins.directory
|
||||
target = path.isAbsolute(target) ? target : path.resolve(ins.directory, target)
|
||||
yield* assertExternalDirectoryEffect(ctx, target, {
|
||||
bypass: false,
|
||||
kind: "directory",
|
||||
})
|
||||
yield* ctx.ask({
|
||||
permission: "read",
|
||||
patterns: [path.relative(ins.worktree, target)],
|
||||
always: ["*"],
|
||||
metadata: params,
|
||||
})
|
||||
|
||||
if (params.operation === "blame" && !params.path) {
|
||||
throw new Error("blame requires a file path")
|
||||
}
|
||||
|
||||
const args = ["git"]
|
||||
const dirInfo = yield* Effect.promise(() =>
|
||||
import("fs").then((fs) => fs.statSync(target).isDirectory()),
|
||||
).pipe(Effect.catch(() => Effect.succeed(true)))
|
||||
if (dirInfo) args.push("-C", target)
|
||||
else args.push("-C", path.dirname(target))
|
||||
args.push(params.operation)
|
||||
if (params.ref && (params.operation === "diff" || params.operation === "log")) args.push(params.ref)
|
||||
if (!dirInfo || params.operation === "blame") args.push(target)
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const proc = Bun.spawn(args, {
|
||||
cwd: ins.directory,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
return { stdout, stderr, code }
|
||||
},
|
||||
catch: (cause) => new Error(`git ${params.operation} failed: ${cause}`),
|
||||
})
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`git ${params.operation} failed: ${result.stderr.trim() || `exit code ${result.code}`}`)
|
||||
}
|
||||
|
||||
let output = result.stdout
|
||||
const truncated = Buffer.byteLength(output) > MAX_OUTPUT_BYTES
|
||||
if (truncated) {
|
||||
output = Buffer.from(output).subarray(0, MAX_OUTPUT_BYTES).toString("utf8")
|
||||
output += `\n\n(Output truncated at ${MAX_OUTPUT_BYTES / 1024} KB. Narrow the query, e.g. a specific path or ref.)`
|
||||
}
|
||||
if (output.length === 0) output = `(no output from git ${params.operation})`
|
||||
|
||||
return {
|
||||
title: `git ${params.operation}`,
|
||||
metadata: {
|
||||
operation: params.operation,
|
||||
truncated,
|
||||
},
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
- Run read-only git operations: status, diff, log, blame, branch
|
||||
- Output is capped; narrow with a path or ref instead of dumping the whole repo history
|
||||
- Use this instead of shell git commands for inspection; use shell for anything that mutates state (commit, push, checkout, stash)
|
||||
- blame requires a file path
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
@@ -7,6 +7,8 @@ import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
const LIMIT = 200
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }),
|
||||
path: Schema.optional(Schema.String).annotate({
|
||||
@@ -38,7 +40,8 @@ export const GlobTool = Tool.define(
|
||||
let search = params.path ?? ins.directory
|
||||
search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search)
|
||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (info?.type === "File") {
|
||||
if (!info) throw new Error(`path not found: ${search}`)
|
||||
if (info.type === "File") {
|
||||
throw new Error(`glob path must be a directory: ${search}`)
|
||||
}
|
||||
yield* assertExternalDirectoryEffect(ctx, search, {
|
||||
@@ -46,18 +49,57 @@ export const GlobTool = Tool.define(
|
||||
kind: "directory",
|
||||
})
|
||||
|
||||
const limit = 100
|
||||
const files = yield* ripgrep.glob({ cwd: search, pattern: params.pattern, limit })
|
||||
const truncated = files.length === limit
|
||||
// request one extra so we can tell whether results were cut off
|
||||
const files = yield* ripgrep.glob({ cwd: search, pattern: params.pattern, limit: LIMIT + 1 })
|
||||
const fileTruncated = files.length > LIMIT
|
||||
const visibleFiles = fileTruncated ? files.slice(0, LIMIT) : [...files]
|
||||
|
||||
// rg --files only ever lists files; find matching directories separately
|
||||
// so patterns like "signal" or "src/*" can still surface them
|
||||
const scanned = yield* fs
|
||||
.glob(params.pattern, { cwd: search, include: "all", dot: true })
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const known = new Set<string>(visibleFiles.map((f) => f.path))
|
||||
const dirs: string[] = []
|
||||
for (const rel of scanned) {
|
||||
const normalized = rel.replaceAll("\\", "/").replace(/^(?:\.[\\/])+/, "")
|
||||
if (known.has(normalized)) continue
|
||||
const target = path.resolve(search, normalized)
|
||||
const st = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (st?.type !== "Directory") continue
|
||||
dirs.push(target + "/")
|
||||
}
|
||||
|
||||
const entries = [
|
||||
...visibleFiles.map((f) => path.resolve(search, f.path)),
|
||||
...dirs,
|
||||
]
|
||||
// sort newest first so recently changed matches come before stale ones
|
||||
const stamped = yield* Effect.forEach(
|
||||
entries,
|
||||
(p) =>
|
||||
fs.stat(p.replace(/\/$/, "")).pipe(
|
||||
Effect.map((st) => ({
|
||||
path: p,
|
||||
mtime: Option.getOrElse(st.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed({ path: p, mtime: 0 })),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
stamped.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
const truncated = fileTruncated || stamped.length > LIMIT
|
||||
const final = stamped.slice(0, LIMIT)
|
||||
|
||||
const output = []
|
||||
if (files.length === 0) output.push("No files found")
|
||||
if (files.length > 0) {
|
||||
output.push(...files.map((file) => path.resolve(search, file.path)))
|
||||
if (final.length === 0) output.push("No files found")
|
||||
if (final.length > 0) {
|
||||
output.push(...final.map((entry) => entry.path))
|
||||
if (truncated) {
|
||||
output.push("")
|
||||
output.push(
|
||||
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
||||
`(Results are truncated: showing first ${LIMIT} results. Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -65,7 +107,7 @@ export const GlobTool = Tool.define(
|
||||
return {
|
||||
title: path.relative(ins.worktree, search),
|
||||
metadata: {
|
||||
count: files.length,
|
||||
count: final.length,
|
||||
truncated,
|
||||
},
|
||||
output: output.join("\n"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- Fast file pattern matching tool that works with any codebase size
|
||||
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
|
||||
- Returns matching file paths
|
||||
- Use this tool when you need to find files by name patterns
|
||||
- Matches files and directories (directories end with "/"); results are sorted newest-first
|
||||
- Use this tool when you need to find files by name patterns; use ls to list a single directory
|
||||
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
|
||||
|
||||
@@ -7,6 +7,8 @@ import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
const LIMIT = 100
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
|
||||
path: Schema.optional(Schema.String).annotate({
|
||||
@@ -64,11 +66,13 @@ export const GrepTool = Tool.define(
|
||||
cwd,
|
||||
pattern: params.pattern,
|
||||
include: params.include,
|
||||
limit: 100,
|
||||
// request one extra so we can tell whether results were cut off
|
||||
limit: LIMIT + 1,
|
||||
})
|
||||
if (result.length === 0) return empty
|
||||
|
||||
const rows = result.map((item) => ({
|
||||
const hasMore = result.length > LIMIT
|
||||
const rows = result.slice(0, LIMIT).map((item) => ({
|
||||
path: path.resolve(
|
||||
requestedInfo?.type === "Directory" ? requested : path.dirname(requested),
|
||||
item.entry.path,
|
||||
@@ -77,17 +81,10 @@ export const GrepTool = Tool.define(
|
||||
text: item.text,
|
||||
}))
|
||||
|
||||
const limit = 100
|
||||
const truncated = rows.length === limit
|
||||
const final = rows
|
||||
if (final.length === 0) return empty
|
||||
|
||||
const total = rows.length
|
||||
const hasMore = truncated || result.length === limit
|
||||
const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`]
|
||||
const output = [`Found ${rows.length} matches${hasMore ? " (more matches available)" : ""}`]
|
||||
|
||||
let current = ""
|
||||
for (const match of final) {
|
||||
for (const match of rows) {
|
||||
if (current !== match.path) {
|
||||
if (current !== "") output.push("")
|
||||
current = match.path
|
||||
@@ -96,7 +93,7 @@ export const GrepTool = Tool.define(
|
||||
output.push(` Line ${match.line}: ${match.text}`)
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
if (hasMore) {
|
||||
output.push("")
|
||||
output.push("(Results truncated. Consider using a more specific path or pattern.)")
|
||||
}
|
||||
@@ -104,8 +101,8 @@ export const GrepTool = Tool.define(
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: {
|
||||
matches: total,
|
||||
truncated,
|
||||
matches: rows.length,
|
||||
truncated: hasMore,
|
||||
},
|
||||
output: output.join("\n"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./ls.txt"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
path: Schema.optional(Schema.String).annotate({
|
||||
description: `The directory to list. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior.`,
|
||||
}),
|
||||
})
|
||||
|
||||
export const LsTool = Tool.define(
|
||||
"ls",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: { path?: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const ins = yield* InstanceState.context
|
||||
let search = params.path ?? ins.directory
|
||||
search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search)
|
||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) throw new Error(`Directory not found: ${search}`)
|
||||
if (info.type === "File") throw new Error(`ls path must be a directory: ${search}`)
|
||||
yield* assertExternalDirectoryEffect(ctx, search, {
|
||||
bypass: false,
|
||||
kind: "directory",
|
||||
})
|
||||
yield* ctx.ask({
|
||||
permission: "read",
|
||||
patterns: [path.relative(ins.worktree, search)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
path: params.path,
|
||||
},
|
||||
})
|
||||
|
||||
const entries = yield* fs.readDirectoryEntries(search)
|
||||
const lines: string[] = []
|
||||
for (const item of entries) {
|
||||
if (item.type === "directory") {
|
||||
lines.push(item.name + "/")
|
||||
continue
|
||||
}
|
||||
if (item.type !== "symlink") {
|
||||
lines.push(item.name)
|
||||
continue
|
||||
}
|
||||
const target = yield* fs.stat(path.join(search, item.name)).pipe(Effect.catch(() => Effect.void))
|
||||
lines.push(target?.type === "Directory" ? item.name + "/" : item.name)
|
||||
}
|
||||
lines.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
return {
|
||||
title: path.relative(ins.worktree, search),
|
||||
metadata: {
|
||||
count: lines.length,
|
||||
},
|
||||
output: lines.length > 0 ? lines.join("\n") : "(empty directory)",
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
- Lists the contents of a directory: files and subdirectories (subdirectories end with "/")
|
||||
- Use this to see what exists in a directory or to check whether a directory is present
|
||||
- The glob tool only matches files, never directories; use ls for anything directory-related
|
||||
- Omit the path to list the current working directory
|
||||
@@ -19,6 +19,6 @@ All operations require:
|
||||
workspaceSymbol also accepts:
|
||||
- query: A query string to filter symbols by. Empty string requests all symbols.
|
||||
|
||||
For workspaceSymbol, filePath is not sent in the LSP workspace/symbol request. It is used by opencode to select and start the matching LSP server.
|
||||
For workspaceSymbol, filePath is not sent in the LSP workspace/symbol request. It is used by Neuron to select and start the matching LSP server.
|
||||
|
||||
Note: LSP servers must be configured for the file type. If no server is available, an error will be returned.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./move.txt"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
from: Schema.String.annotate({ description: "The file or directory to move or rename" }),
|
||||
to: Schema.String.annotate({
|
||||
description: "The destination path. Refuses to overwrite if the destination already exists.",
|
||||
}),
|
||||
})
|
||||
|
||||
export const MoveTool = Tool.define(
|
||||
"move",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: { from: string; to: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const ins = yield* InstanceState.context
|
||||
let from = params.from
|
||||
from = path.isAbsolute(from) ? from : path.resolve(ins.directory, from)
|
||||
let to = params.to
|
||||
to = path.isAbsolute(to) ? to : path.resolve(ins.directory, to)
|
||||
|
||||
const fromInfo = yield* fs.stat(from).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!fromInfo) throw new Error(`Source not found: ${from}`)
|
||||
const toInfo = yield* fs.stat(to).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (toInfo && toInfo.type === "Directory" && fromInfo.type === "File") {
|
||||
// moving a file into an existing directory keeps the basename
|
||||
to = path.join(to, path.basename(from))
|
||||
}
|
||||
const finalInfo = yield* fs.stat(to).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (finalInfo) throw new Error(`Destination already exists: ${to}`)
|
||||
|
||||
yield* assertExternalDirectoryEffect(ctx, from, {
|
||||
bypass: false,
|
||||
kind: fromInfo.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
yield* assertExternalDirectoryEffect(ctx, to, {
|
||||
bypass: false,
|
||||
kind: "file",
|
||||
})
|
||||
yield* ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [
|
||||
path.relative(ins.worktree, from),
|
||||
path.relative(ins.worktree, to),
|
||||
],
|
||||
always: ["*"],
|
||||
metadata: { from, to },
|
||||
})
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const nfs = await import("fs/promises")
|
||||
await nfs.mkdir(path.dirname(to), { recursive: true })
|
||||
await nfs.rename(from, to)
|
||||
},
|
||||
catch: (cause) => new Error(`Failed to move ${from} to ${to}: ${cause}`),
|
||||
})
|
||||
|
||||
return {
|
||||
title: path.relative(ins.worktree, to),
|
||||
metadata: { from, to },
|
||||
output: `Moved ${path.relative(ins.worktree, from)} to ${path.relative(ins.worktree, to)}`,
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
- Moves or renames a file or directory; creates destination parent directories as needed
|
||||
- Refuses to overwrite an existing destination
|
||||
- Prefer this over shell `mv` — it goes through the same permission checks and external-directory guards as other file tools
|
||||
@@ -8,6 +8,11 @@ import { ShellTool } from "./shell"
|
||||
import { EditTool } from "./edit"
|
||||
import { GlobTool } from "./glob"
|
||||
import { GrepTool } from "./grep"
|
||||
import { LsTool } from "./ls"
|
||||
import { TreeTool } from "./tree"
|
||||
import { GitTool } from "./git"
|
||||
import { MoveTool } from "./move"
|
||||
import { RemoveTool } from "./remove"
|
||||
import { ReadTool } from "./read"
|
||||
import { TaskTool } from "./task"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -109,6 +114,11 @@ const layer = Layer.effect(
|
||||
const websearch = yield* WebSearchTool
|
||||
const shell = yield* ShellTool
|
||||
const globtool = yield* GlobTool
|
||||
const lstool = yield* LsTool
|
||||
const treetool = yield* TreeTool
|
||||
const gittool = yield* GitTool
|
||||
const movetool = yield* MoveTool
|
||||
const removetool = yield* RemoveTool
|
||||
const writetool = yield* WriteTool
|
||||
const edit = yield* EditTool
|
||||
const greptool = yield* GrepTool
|
||||
@@ -211,6 +221,11 @@ const layer = Layer.effect(
|
||||
shell: Tool.init(shell),
|
||||
read: Tool.init(read),
|
||||
glob: Tool.init(globtool),
|
||||
ls: Tool.init(lstool),
|
||||
tree: Tool.init(treetool),
|
||||
git: Tool.init(gittool),
|
||||
move: Tool.init(movetool),
|
||||
remove: Tool.init(removetool),
|
||||
grep: Tool.init(greptool),
|
||||
edit: Tool.init(edit),
|
||||
write: Tool.init(writetool),
|
||||
@@ -234,6 +249,11 @@ const layer = Layer.effect(
|
||||
tool.shell,
|
||||
tool.read,
|
||||
tool.glob,
|
||||
tool.ls,
|
||||
tool.tree,
|
||||
tool.git,
|
||||
tool.move,
|
||||
tool.remove,
|
||||
tool.grep,
|
||||
tool.edit,
|
||||
tool.write,
|
||||
@@ -244,8 +264,8 @@ const layer = Layer.effect(
|
||||
tool.skill,
|
||||
tool.patch,
|
||||
...(tool.execute ? [tool.execute] : []),
|
||||
...(flags.experimentalLspTool ? [tool.lsp] : []),
|
||||
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
|
||||
tool.lsp,
|
||||
...(flags.client === "cli" ? [tool.plan] : []),
|
||||
],
|
||||
task: tool.task,
|
||||
read: tool.read,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./remove.txt"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
path: Schema.String.annotate({ description: "The file to delete. Directories must be empty." }),
|
||||
})
|
||||
|
||||
export const RemoveTool = Tool.define(
|
||||
"remove",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: { path: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const ins = yield* InstanceState.context
|
||||
let target = params.path
|
||||
target = path.isAbsolute(target) ? target : path.resolve(ins.directory, target)
|
||||
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) throw new Error(`Not found: ${target}`)
|
||||
|
||||
yield* assertExternalDirectoryEffect(ctx, target, {
|
||||
bypass: false,
|
||||
kind: info.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
yield* ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(ins.worktree, target)],
|
||||
always: ["*"],
|
||||
metadata: { path: params.path },
|
||||
})
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const nfs = await import("fs/promises")
|
||||
if (info.type === "Directory") await nfs.rmdir(target)
|
||||
else await nfs.unlink(target)
|
||||
},
|
||||
catch: (cause) => new Error(`Failed to remove ${target}: ${cause}`),
|
||||
})
|
||||
|
||||
return {
|
||||
title: path.relative(ins.worktree, target),
|
||||
metadata: { removed: target },
|
||||
output: `Removed ${path.relative(ins.worktree, target)}`,
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
- Deletes a file. Refuses directories unless they are empty.
|
||||
- Prefer this over shell `rm` — it goes through the same permission checks and external-directory guards as other file tools
|
||||
- This is permanent; there is no undo
|
||||
@@ -12,8 +12,6 @@ interface Metadata {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// TODO: remove this hack
|
||||
export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
|
||||
|
||||
/**
|
||||
* Raised when the LLM calls a tool with arguments that fail the parameter
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./tree.txt"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
const DEFAULT_DEPTH = 3
|
||||
const MAX_DEPTH = 8
|
||||
const MAX_ENTRIES = 500
|
||||
const IGNORED = new Set([".git", "node_modules"])
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
path: Schema.optional(Schema.String).annotate({
|
||||
description: `The directory to start from. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory.`,
|
||||
}),
|
||||
depth: Schema.optional(Schema.Number).annotate({
|
||||
description: `Maximum depth to descend (default ${DEFAULT_DEPTH}, max ${MAX_DEPTH}).`,
|
||||
}),
|
||||
})
|
||||
|
||||
export const TreeTool = Tool.define(
|
||||
"tree",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: { path?: string; depth?: number }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const ins = yield* InstanceState.context
|
||||
let search = params.path ?? ins.directory
|
||||
search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search)
|
||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) throw new Error(`Directory not found: ${search}`)
|
||||
if (info.type === "File") throw new Error(`tree path must be a directory: ${search}`)
|
||||
yield* assertExternalDirectoryEffect(ctx, search, {
|
||||
bypass: false,
|
||||
kind: "directory",
|
||||
})
|
||||
yield* ctx.ask({
|
||||
permission: "read",
|
||||
patterns: [path.relative(ins.worktree, search)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
path: params.path,
|
||||
depth: params.depth,
|
||||
},
|
||||
})
|
||||
|
||||
const maxDepth = Math.min(Math.max(1, params.depth ?? DEFAULT_DEPTH), MAX_DEPTH)
|
||||
const state = { count: 0, truncated: false }
|
||||
|
||||
const walk = (dir: string, prefix: string, depth: number): Effect.Effect<string[]> =>
|
||||
Effect.gen(function* () {
|
||||
if (depth > maxDepth || state.count >= MAX_ENTRIES) return []
|
||||
const entries = yield* fs.readDirectoryEntries(dir).pipe(Effect.catch(() => Effect.succeed([])))
|
||||
const visible = entries
|
||||
.filter((entry) => !IGNORED.has(entry.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
const lines: string[] = []
|
||||
for (const entry of visible) {
|
||||
if (state.count >= MAX_ENTRIES) {
|
||||
state.truncated = true
|
||||
break
|
||||
}
|
||||
let isDir = entry.type === "directory"
|
||||
if (entry.type === "symlink") {
|
||||
isDir = yield* fs.isDir(path.join(dir, entry.name))
|
||||
}
|
||||
lines.push(prefix + entry.name + (isDir ? "/" : ""))
|
||||
state.count++
|
||||
if (isDir) {
|
||||
const nested = yield* walk(path.join(dir, entry.name), prefix + entry.name + "/", depth + 1)
|
||||
lines.push(...nested)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
})
|
||||
|
||||
const lines = yield* walk(search, "", 1)
|
||||
|
||||
const output = [`${path.relative(ins.worktree, search) || "."}`, ...lines]
|
||||
if (state.truncated) {
|
||||
output.push("")
|
||||
output.push(`(Truncated at ${MAX_ENTRIES} entries. Use a more specific path or lower depth.)`)
|
||||
}
|
||||
|
||||
return {
|
||||
title: path.relative(ins.worktree, search),
|
||||
metadata: {
|
||||
count: state.count,
|
||||
truncated: state.truncated,
|
||||
},
|
||||
output: output.join("\n"),
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
- Recursive directory overview rendered as a tree, one entry per line (directories end with "/")
|
||||
- Use this to understand project structure at a glance; use ls for a single directory level
|
||||
- Skips .git and node_modules; caps output depth and entry count
|
||||
- Omit the path to start at the current working directory
|
||||
Reference in New Issue
Block a user