feat(core): add scout agent for repo research (#24149)
Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
co-authored by
Dax Raad
parent
6e47ae769e
commit
40d5ea1cf1
@@ -0,0 +1,63 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import * as Tool from "./tool"
|
||||
import * as McpWebSearch from "./mcp-websearch"
|
||||
import DESCRIPTION from "./codesearch.txt"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
query: Schema.String.annotate({
|
||||
description:
|
||||
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
|
||||
}),
|
||||
tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000))
|
||||
.check(Schema.isLessThanOrEqualTo(50000))
|
||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000)))
|
||||
.annotate({
|
||||
description:
|
||||
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
|
||||
}),
|
||||
})
|
||||
|
||||
export const CodeSearchTool = Tool.define(
|
||||
"codesearch",
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({
|
||||
permission: "codesearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum,
|
||||
},
|
||||
})
|
||||
|
||||
const result = yield* McpWebSearch.call(
|
||||
http,
|
||||
McpWebSearch.EXA_URL,
|
||||
"get_code_context_exa",
|
||||
McpWebSearch.CodeArgs,
|
||||
{
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum,
|
||||
},
|
||||
"30 seconds",
|
||||
)
|
||||
|
||||
return {
|
||||
output:
|
||||
result ??
|
||||
"No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.",
|
||||
title: `Code search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
- Search and get relevant context for any programming task using Exa Code API
|
||||
- Provides the highest quality and freshest context for libraries, SDKs, and APIs
|
||||
- Use this tool for ANY question or task related to programming
|
||||
- Returns comprehensive code examples, documentation, and API references
|
||||
- Optimized for finding specific programming patterns and solutions
|
||||
|
||||
Usage notes:
|
||||
- Adjustable token count (1000-50000) for focused or comprehensive results
|
||||
- Default 5000 tokens provides balanced context for most queries
|
||||
- Use lower values for specific questions, higher values for comprehensive documentation
|
||||
- Supports queries about frameworks, libraries, APIs, and programming concepts
|
||||
- Examples: 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware'
|
||||
@@ -48,6 +48,11 @@ export const SearchArgs = Schema.Struct({
|
||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
export const CodeArgs = Schema.Struct({
|
||||
query: Schema.String,
|
||||
tokensNum: Schema.Number,
|
||||
})
|
||||
|
||||
export const ParallelSearchArgs = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
|
||||
@@ -22,6 +22,9 @@ import { Plugin } from "../plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID, type ModelID } from "../provider/schema"
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { CodeSearchTool } from "./codesearch"
|
||||
import { RepoCloneTool } from "./repo_clone"
|
||||
import { RepoOverviewTool } from "./repo_overview"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { LspTool } from "./lsp"
|
||||
@@ -44,6 +47,7 @@ import { Instruction } from "../session/instruction"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Bus } from "../bus"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Git } from "@/git"
|
||||
import { Skill } from "../skill"
|
||||
import { Permission } from "@/permission"
|
||||
|
||||
@@ -86,6 +90,7 @@ export const layer: Layer.Layer<
|
||||
| Skill.Service
|
||||
| Session.Service
|
||||
| Provider.Service
|
||||
| Git.Service
|
||||
| LSP.Service
|
||||
| Instruction.Service
|
||||
| AppFileSystem.Service
|
||||
@@ -113,6 +118,9 @@ export const layer: Layer.Layer<
|
||||
const plan = yield* PlanExitTool
|
||||
const webfetch = yield* WebFetchTool
|
||||
const websearch = yield* WebSearchTool
|
||||
const codesearch = yield* CodeSearchTool
|
||||
const repoClone = yield* RepoCloneTool
|
||||
const repoOverview = yield* RepoOverviewTool
|
||||
const shell = yield* ShellTool
|
||||
const globtool = yield* GlobTool
|
||||
const writetool = yield* WriteTool
|
||||
@@ -212,6 +220,9 @@ export const layer: Layer.Layer<
|
||||
fetch: Tool.init(webfetch),
|
||||
todo: Tool.init(todo),
|
||||
search: Tool.init(websearch),
|
||||
code: Tool.init(codesearch),
|
||||
repo_clone: Tool.init(repoClone),
|
||||
repo_overview: Tool.init(repoOverview),
|
||||
skill: Tool.init(skilltool),
|
||||
patch: Tool.init(patchtool),
|
||||
question: Tool.init(question),
|
||||
@@ -234,6 +245,7 @@ export const layer: Layer.Layer<
|
||||
tool.fetch,
|
||||
tool.todo,
|
||||
tool.search,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_SCOUT ? [tool.code, tool.repo_clone, tool.repo_overview] : []),
|
||||
tool.skill,
|
||||
tool.patch,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
|
||||
@@ -348,6 +360,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Git } from "@/git"
|
||||
import DESCRIPTION from "./repo_clone.txt"
|
||||
import * as Tool from "./tool"
|
||||
import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
repository: Schema.String.annotate({
|
||||
description: "Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}),
|
||||
refresh: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "When true, fetches the latest remote state into the managed cache",
|
||||
}),
|
||||
branch: Schema.optional(Schema.String).annotate({
|
||||
description: "Branch or ref to clone and inspect",
|
||||
}),
|
||||
})
|
||||
|
||||
type Metadata = {
|
||||
repository: string
|
||||
host: string
|
||||
remote: string
|
||||
localPath: string
|
||||
status: "cached" | "cloned" | "refreshed"
|
||||
head?: string
|
||||
branch?: string
|
||||
}
|
||||
|
||||
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
||||
if (!input.reuse) return "cloned" as const
|
||||
if (input.branchMatches === false) return "refreshed" as const
|
||||
if (input.refresh) return "refreshed" as const
|
||||
return "cached" as const
|
||||
}
|
||||
|
||||
function resetTarget(input: {
|
||||
requestedBranch?: string
|
||||
remoteHead: { code: number; stdout: string }
|
||||
branch: { code: number; stdout: string }
|
||||
}) {
|
||||
if (input.requestedBranch) return `origin/${input.requestedBranch}`
|
||||
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
|
||||
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
|
||||
}
|
||||
if (input.branch.code === 0 && input.branch.stdout) {
|
||||
return `origin/${input.branch.stdout}`
|
||||
}
|
||||
return "HEAD"
|
||||
}
|
||||
|
||||
function validateBranch(branch: string) {
|
||||
if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) {
|
||||
throw new Error(
|
||||
"Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSystem.Service | Git.Service>(
|
||||
"repo_clone",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
const reference = parseRepositoryReference(params.repository)
|
||||
if (!reference)
|
||||
throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
|
||||
if (reference.protocol === "file:") throw new Error("Local file repositories are not supported")
|
||||
if (params.branch) validateBranch(params.branch)
|
||||
|
||||
const repository = reference.label
|
||||
const remote = reference.remote
|
||||
const localPath = repositoryCachePath(reference)
|
||||
const cloneTarget = parseRepositoryReference(remote) ?? reference
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "repo_clone",
|
||||
patterns: [repository],
|
||||
always: [repository],
|
||||
metadata: {
|
||||
repository,
|
||||
remote,
|
||||
path: localPath,
|
||||
refresh: Boolean(params.refresh),
|
||||
branch: params.branch,
|
||||
},
|
||||
})
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
|
||||
|
||||
const exists = yield* fs.existsSafe(localPath)
|
||||
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
|
||||
const origin = hasGitDir
|
||||
? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
|
||||
: undefined
|
||||
const originReference =
|
||||
origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
|
||||
const reuse =
|
||||
hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
|
||||
if (exists && !reuse) {
|
||||
yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const currentBranch = hasGitDir ? yield* git.branch(localPath) : undefined
|
||||
const status = statusForRepository({
|
||||
reuse,
|
||||
refresh: params.refresh,
|
||||
branchMatches: params.branch ? currentBranch === params.branch : undefined,
|
||||
})
|
||||
|
||||
if (status === "cloned") {
|
||||
const clone = yield* git.run(
|
||||
[
|
||||
"clone",
|
||||
"--depth",
|
||||
"100",
|
||||
...(params.branch ? ["--branch", params.branch] : []),
|
||||
"--",
|
||||
remote,
|
||||
localPath,
|
||||
],
|
||||
{ cwd: path.dirname(localPath) },
|
||||
)
|
||||
if (clone.exitCode !== 0) {
|
||||
throw new Error(
|
||||
clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "refreshed") {
|
||||
const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath })
|
||||
if (fetch.exitCode !== 0) {
|
||||
throw new Error(
|
||||
fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (params.branch) {
|
||||
const checkout = yield* git.run(["checkout", "-B", params.branch, `origin/${params.branch}`], {
|
||||
cwd: localPath,
|
||||
})
|
||||
if (checkout.exitCode !== 0) {
|
||||
throw new Error(
|
||||
checkout.stderr.toString().trim() ||
|
||||
checkout.text().trim() ||
|
||||
`Failed to checkout ${params.branch}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
|
||||
const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
|
||||
const target = resetTarget({
|
||||
requestedBranch: params.branch,
|
||||
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
|
||||
branch: { code: branch.exitCode, stdout: branch.text().trim() },
|
||||
})
|
||||
|
||||
const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath })
|
||||
if (reset.exitCode !== 0) {
|
||||
throw new Error(
|
||||
reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath })
|
||||
const branch = yield* git.branch(localPath)
|
||||
const headText = head.exitCode === 0 ? head.text().trim() : undefined
|
||||
|
||||
return {
|
||||
title: repository,
|
||||
metadata: {
|
||||
repository,
|
||||
host: reference.host,
|
||||
remote,
|
||||
localPath,
|
||||
status,
|
||||
head: headText,
|
||||
branch,
|
||||
},
|
||||
output: [
|
||||
`Repository ready: ${repository}`,
|
||||
`Status: ${status}`,
|
||||
`Local path: ${localPath}`,
|
||||
...(branch ? [`Branch: ${branch}`] : []),
|
||||
...(headText ? [`HEAD: ${headText}`] : []),
|
||||
].join("\n"),
|
||||
}
|
||||
}),
|
||||
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
|
||||
)
|
||||
}).pipe(Effect.orDie),
|
||||
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
- Clone or refresh a repository into OpenCode's managed cache under the data directory
|
||||
- Accepts git URLs, forge host/path references, or GitHub owner/repo shorthand
|
||||
- Returns the cached absolute local path so other tools can explore the cloned source
|
||||
- Use this before Read, Glob, or Grep when the code you need lives outside the current workspace
|
||||
- This tool is intended for dependency and documentation research workflows, not for modifying the user's workspace
|
||||
@@ -0,0 +1,238 @@
|
||||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Git } from "@/git"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./repo_overview.txt"
|
||||
import * as Tool from "./tool"
|
||||
import { parseRepositoryReference, repositoryCachePath } from "@/util/repository"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
repository: Schema.optional(Schema.String).annotate({
|
||||
description: "Cached repository to inspect, as a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}),
|
||||
path: Schema.optional(Schema.String).annotate({
|
||||
description: "Directory path to inspect instead of a cached repository",
|
||||
}),
|
||||
depth: Schema.optional(Schema.Number).annotate({
|
||||
description: "Maximum structure depth to include. Defaults to 3.",
|
||||
})
|
||||
})
|
||||
|
||||
type Metadata = {
|
||||
path: string
|
||||
repository?: string
|
||||
branch?: string
|
||||
head?: string
|
||||
package_manager?: string
|
||||
ecosystems: string[]
|
||||
dependency_files: string[]
|
||||
entrypoints: string[]
|
||||
depth: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
const IGNORED_DIRS = new Set([".git", "node_modules", "__pycache__", ".venv", "dist", "build", ".next", "target", "vendor"])
|
||||
const STRUCTURE_LIMIT = 200
|
||||
const DEPENDENCY_FILES = [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"bun.lock",
|
||||
"bun.lockb",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"requirements.txt",
|
||||
"pyproject.toml",
|
||||
"go.mod",
|
||||
"Cargo.toml",
|
||||
"Gemfile",
|
||||
"build.gradle",
|
||||
"build.gradle.kts",
|
||||
"pom.xml",
|
||||
"composer.json",
|
||||
]
|
||||
|
||||
function packageManager(files: Set<string>) {
|
||||
if (files.has("bun.lock") || files.has("bun.lockb")) return "bun"
|
||||
if (files.has("pnpm-lock.yaml")) return "pnpm"
|
||||
if (files.has("yarn.lock")) return "yarn"
|
||||
if (files.has("package-lock.json")) return "npm"
|
||||
}
|
||||
|
||||
function ecosystems(files: Set<string>) {
|
||||
return [
|
||||
...(files.has("package.json") ? ["Node.js"] : []),
|
||||
...(files.has("pyproject.toml") || files.has("requirements.txt") ? ["Python"] : []),
|
||||
...(files.has("go.mod") ? ["Go"] : []),
|
||||
...(files.has("Cargo.toml") ? ["Rust"] : []),
|
||||
...(files.has("Gemfile") ? ["Ruby"] : []),
|
||||
...(files.has("build.gradle") || files.has("build.gradle.kts") || files.has("pom.xml") ? ["Java/Kotlin"] : []),
|
||||
...(files.has("composer.json") ? ["PHP"] : []),
|
||||
]
|
||||
}
|
||||
|
||||
function commonEntrypoints(files: Set<string>) {
|
||||
return ["index.ts", "index.tsx", "index.js", "index.mjs", "main.ts", "main.js", "src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"].filter((file) => files.has(file))
|
||||
}
|
||||
|
||||
export const RepoOverviewTool = Tool.define<typeof Parameters, Metadata, AppFileSystem.Service | Git.Service>(
|
||||
"repo_overview",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* (params: Schema.Schema.Type<typeof Parameters>) {
|
||||
if (params.path) {
|
||||
const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path)
|
||||
return { path: full, repository: params.repository }
|
||||
}
|
||||
|
||||
if (!params.repository) throw new Error("Either repository or path is required")
|
||||
|
||||
const parsed = parseRepositoryReference(params.repository)
|
||||
if (!parsed) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
|
||||
|
||||
const repository = parsed.label
|
||||
return {
|
||||
repository,
|
||||
path: repositoryCachePath(parsed),
|
||||
}
|
||||
})
|
||||
|
||||
const structure = Effect.fn("RepoOverviewTool.structure")(function* (root: string, depth: number) {
|
||||
let truncated = false
|
||||
const lines: string[] = []
|
||||
|
||||
const visit: (dir: string, level: number) => Effect.Effect<void> = Effect.fnUntraced(function* (dir: string, level: number) {
|
||||
if (level >= depth || lines.length >= STRUCTURE_LIMIT) {
|
||||
truncated = truncated || lines.length >= STRUCTURE_LIMIT
|
||||
return
|
||||
}
|
||||
|
||||
const entries = yield* fs.readDirectoryEntries(dir).pipe(Effect.orElseSucceed(() => []))
|
||||
const sorted = yield* Effect.forEach(
|
||||
entries,
|
||||
Effect.fnUntraced(function* (entry) {
|
||||
if (IGNORED_DIRS.has(entry.name)) return undefined
|
||||
const full = path.join(dir, entry.name)
|
||||
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) return undefined
|
||||
return { name: entry.name, full, directory: info.type === "Directory" }
|
||||
}),
|
||||
{ concurrency: 16 },
|
||||
).pipe(
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.filter((item): item is { name: string; full: string; directory: boolean } => Boolean(item))
|
||||
.sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)),
|
||||
),
|
||||
)
|
||||
|
||||
for (const entry of sorted) {
|
||||
if (lines.length >= STRUCTURE_LIMIT) {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
|
||||
lines.push(`${" ".repeat(level)}${entry.name}${entry.directory ? "/" : ""}`)
|
||||
if (entry.directory) yield* visit(entry.full, level + 1)
|
||||
}
|
||||
})
|
||||
|
||||
yield* visit(root, 0)
|
||||
return { lines, truncated }
|
||||
})
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* resolveTarget(params)
|
||||
const depth = !params.depth || !Number.isInteger(params.depth) || params.depth < 1 || params.depth > 6 ? 3 : params.depth
|
||||
|
||||
yield* assertExternalDirectoryEffect(ctx, target.path, { kind: "directory" })
|
||||
yield* ctx.ask({
|
||||
permission: "repo_overview",
|
||||
patterns: [target.repository ?? target.path],
|
||||
always: [target.repository ?? target.path],
|
||||
metadata: {
|
||||
repository: target.repository,
|
||||
path: target.path,
|
||||
depth,
|
||||
},
|
||||
})
|
||||
|
||||
const info = yield* fs.stat(target.path).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) {
|
||||
if (target.repository) throw new Error(`Repository is not cloned: ${target.repository}. Use repo_clone first.`)
|
||||
throw new Error(`Directory not found: ${target.path}`)
|
||||
}
|
||||
if (info.type !== "Directory") throw new Error(`Path is not a directory: ${target.path}`)
|
||||
|
||||
const entries = yield* fs.readDirectoryEntries(target.path).pipe(Effect.orElseSucceed(() => []))
|
||||
const topLevel = new Set(entries.map((entry) => entry.name))
|
||||
const dependencyFiles = DEPENDENCY_FILES.filter((file) => topLevel.has(file))
|
||||
const packageJson = topLevel.has("package.json")
|
||||
? (yield* fs.readJson(path.join(target.path, "package.json")).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
|
||||
: {}
|
||||
|
||||
const entrypoints = [
|
||||
...(typeof packageJson.main === "string" ? [`main: ${packageJson.main}`] : []),
|
||||
...(typeof packageJson.module === "string" ? [`module: ${packageJson.module}`] : []),
|
||||
...(typeof packageJson.types === "string" ? [`types: ${packageJson.types}`] : []),
|
||||
...(typeof packageJson.bin === "string" ? [`bin: ${packageJson.bin}`] : []),
|
||||
...(packageJson.bin && typeof packageJson.bin === "object" && !Array.isArray(packageJson.bin)
|
||||
? Object.keys(packageJson.bin as Record<string, unknown>).map((name) => `bin: ${name}`)
|
||||
: []),
|
||||
...(packageJson.exports && typeof packageJson.exports === "object" && !Array.isArray(packageJson.exports)
|
||||
? Object.keys(packageJson.exports as Record<string, unknown>).slice(0, 10).map((name) => `exports: ${name}`)
|
||||
: []),
|
||||
]
|
||||
|
||||
const common = commonEntrypoints(new Set([
|
||||
...topLevel,
|
||||
...entries
|
||||
.filter((entry) => entry.name === "src")
|
||||
.flatMap(() => ["src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"]),
|
||||
]))
|
||||
const structureResult = yield* structure(target.path, depth)
|
||||
const branch = yield* git.branch(target.path)
|
||||
const head = yield* git.run(["rev-parse", "HEAD"], { cwd: target.path })
|
||||
const headText = head.exitCode === 0 ? head.text().trim() : undefined
|
||||
|
||||
const metadata: Metadata = {
|
||||
path: target.path,
|
||||
repository: target.repository,
|
||||
branch,
|
||||
head: headText,
|
||||
package_manager: packageManager(topLevel),
|
||||
ecosystems: ecosystems(topLevel),
|
||||
dependency_files: dependencyFiles,
|
||||
entrypoints: [...entrypoints, ...common.map((file) => `file: ${file}`)],
|
||||
depth,
|
||||
truncated: structureResult.truncated,
|
||||
}
|
||||
|
||||
return {
|
||||
title: target.repository ?? path.basename(target.path),
|
||||
metadata,
|
||||
output: [
|
||||
`Path: ${target.path}`,
|
||||
...(target.repository ? [`Repository: ${target.repository}`] : []),
|
||||
...(branch ? [`Branch: ${branch}`] : []),
|
||||
...(headText ? [`HEAD: ${headText}`] : []),
|
||||
...(metadata.ecosystems.length ? [`Ecosystems: ${metadata.ecosystems.join(", ")}`] : []),
|
||||
...(metadata.package_manager ? [`Package manager: ${metadata.package_manager}`] : []),
|
||||
...(metadata.dependency_files.length ? [`Dependency files: ${metadata.dependency_files.join(", ")}`] : []),
|
||||
...(metadata.entrypoints.length ? ["Likely entrypoints:", ...metadata.entrypoints.map((entry) => `- ${entry}`)] : []),
|
||||
"Top-level structure:",
|
||||
...structureResult.lines,
|
||||
...(structureResult.truncated ? ["(Structure truncated)"] : []),
|
||||
].join("\n"),
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
- Summarize the structure and likely entrypoints of a cloned repository or local directory
|
||||
- Accepts either a cached repository reference or a directory path
|
||||
- Reports detected ecosystems, dependency files, package manager, likely entrypoints, and a compact structure tree
|
||||
- Use this after repo_clone to orient quickly before deeper Read, Glob, or Grep investigation
|
||||
Reference in New Issue
Block a user