239 lines
9.7 KiB
TypeScript
239 lines
9.7 KiB
TypeScript
import path from "path"
|
|
import z from "zod"
|
|
import { Effect } from "effect"
|
|
import { AppFileSystem } from "@opencode-ai/shared/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"
|
|
|
|
const parameters = z
|
|
.object({
|
|
repository: z
|
|
.string()
|
|
.optional()
|
|
.describe("Cached repository to inspect, as a git URL, host/path reference, or GitHub owner/repo shorthand"),
|
|
path: z.string().optional().describe("Directory path to inspect instead of a cached repository"),
|
|
depth: z.number().int().positive().max(6).optional().describe("Maximum structure depth to include. Defaults to 3."),
|
|
})
|
|
.refine((input) => Boolean(input.repository || input.path), {
|
|
message: "Either repository or path is required",
|
|
})
|
|
|
|
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: z.infer<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 }
|
|
}
|
|
|
|
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,
|
|
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
|
|
Effect.gen(function* () {
|
|
const target = yield* resolveTarget(params)
|
|
const depth = params.depth ?? 3
|
|
|
|
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),
|
|
}
|
|
}),
|
|
)
|