Apply PR #24149: feat(core): add scout agent for repo research
This commit is contained in:
@@ -1563,6 +1563,8 @@ function toToolKind(toolName: string): ToolKind {
|
||||
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "repo_clone":
|
||||
case "repo_overview":
|
||||
case "context7_resolve_library_id":
|
||||
case "context7_get_library_docs":
|
||||
return "search"
|
||||
@@ -1587,6 +1589,12 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
|
||||
case "glob":
|
||||
case "grep":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
case "repo_clone":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
case "repo_overview":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
case "bash":
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ProviderTransform } from "@/provider/transform"
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||
import PROMPT_SCOUT from "./prompt/scout.txt"
|
||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import { Permission } from "@/permission"
|
||||
@@ -82,6 +83,10 @@ export const layer = Layer.effect(
|
||||
const cfg = yield* config.get()
|
||||
const skillDirs = yield* skill.dirs()
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
const readonlyExternalDirectory = {
|
||||
"*": "ask",
|
||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||
} satisfies Record<string, "allow" | "ask" | "deny">
|
||||
|
||||
const defaults = Permission.fromConfig({
|
||||
"*": "allow",
|
||||
@@ -94,6 +99,8 @@ export const layer = Layer.effect(
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
edit: "ask",
|
||||
repo_clone: "deny",
|
||||
repo_overview: "deny",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
@@ -172,10 +179,7 @@ export const layer = Layer.effect(
|
||||
websearch: "allow",
|
||||
codesearch: "allow",
|
||||
read: "allow",
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||
},
|
||||
external_directory: readonlyExternalDirectory,
|
||||
}),
|
||||
user,
|
||||
),
|
||||
@@ -185,6 +189,33 @@ export const layer = Layer.effect(
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
},
|
||||
scout: {
|
||||
name: "scout",
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
webfetch: "allow",
|
||||
websearch: "allow",
|
||||
codesearch: "allow",
|
||||
read: "allow",
|
||||
repo_clone: "allow",
|
||||
repo_overview: "allow",
|
||||
external_directory: {
|
||||
...readonlyExternalDirectory,
|
||||
[path.join(Global.Path.data, "repos", "*")]: "allow",
|
||||
},
|
||||
}),
|
||||
user,
|
||||
),
|
||||
description: `Docs and dependency-source specialist. Use this when you need to inspect external documentation, clone dependency repositories into the managed cache, and research library implementation details without modifying the user's workspace.`,
|
||||
prompt: PROMPT_SCOUT,
|
||||
options: {},
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
},
|
||||
compaction: {
|
||||
name: "compaction",
|
||||
mode: "primary",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
You are `scout`, a read-only research agent for external libraries, dependency source, and documentation.
|
||||
|
||||
Your purpose is to investigate code outside the local workspace and return evidence-backed findings without modifying the user's workspace.
|
||||
|
||||
Use this agent when asked to:
|
||||
- inspect dependency repositories or library source
|
||||
- compare local code against upstream implementations
|
||||
- research public GitHub repositories the environment can clone
|
||||
- explain how a library or framework works by reading its source and docs
|
||||
- investigate third-party APIs, workflows, or behavior outside the current workspace
|
||||
|
||||
Working style:
|
||||
1. When the task involves a GitHub repository or dependency source, use `repo_clone` first.
|
||||
2. After cloning, use `Glob`, `Grep`, and `Read` to inspect the cloned repository.
|
||||
3. Use `WebFetch` for official documentation pages when source alone is not enough.
|
||||
4. Prefer direct code and documentation evidence over assumptions.
|
||||
5. If multiple external repositories are relevant, inspect each one before drawing conclusions.
|
||||
|
||||
Research standards:
|
||||
- cite exact absolute file paths and line references whenever possible
|
||||
- separate what is verified from what is inferred
|
||||
- if the answer depends on branch state, note that you are reading the repository's current default clone state unless the caller specifies otherwise
|
||||
- if a repository cannot be cloned or accessed, say so explicitly and continue with whatever evidence is still available
|
||||
- call out uncertainty clearly instead of smoothing over gaps
|
||||
|
||||
Output expectations:
|
||||
- start with the direct answer
|
||||
- then explain the evidence repository by repository or source by source
|
||||
- include file references when relevant
|
||||
- keep the explanation organized and easy to scan
|
||||
|
||||
Constraints:
|
||||
- do not modify files or run tools that change the user's workspace
|
||||
- return absolute file paths for cloned-repo findings in your final response
|
||||
|
||||
Complete the user's research request efficiently and report your findings clearly.
|
||||
@@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Git } from "@/git"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Process } from "@/util/process"
|
||||
import { parseGitHubRemote } from "@/util/repository"
|
||||
import { Effect } from "effect"
|
||||
|
||||
type GitHubAuthor = {
|
||||
@@ -152,18 +153,7 @@ const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const
|
||||
type UserEvent = (typeof USER_EVENTS)[number]
|
||||
type RepoEvent = (typeof REPO_EVENTS)[number]
|
||||
|
||||
// Parses GitHub remote URLs in various formats:
|
||||
// - https://github.com/owner/repo.git
|
||||
// - https://github.com/owner/repo
|
||||
// - git@github.com:owner/repo.git
|
||||
// - git@github.com:owner/repo
|
||||
// - ssh://git@github.com/owner/repo.git
|
||||
// - ssh://git@github.com/owner/repo
|
||||
export function parseGitHubRemote(url: string): { owner: string; repo: string } | null {
|
||||
const match = url.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
|
||||
if (!match) return null
|
||||
return { owner: match[1], repo: match[2] }
|
||||
}
|
||||
export { parseGitHubRemote }
|
||||
|
||||
/**
|
||||
* Extracts displayable text from assistant response parts.
|
||||
|
||||
@@ -169,6 +169,7 @@ export const Info = Schema.Struct({
|
||||
// subagent
|
||||
general: Schema.optional(ConfigAgent.Info),
|
||||
explore: Schema.optional(ConfigAgent.Info),
|
||||
scout: Schema.optional(ConfigAgent.Info),
|
||||
// specialized
|
||||
title: Schema.optional(ConfigAgent.Info),
|
||||
summary: Schema.optional(ConfigAgent.Info),
|
||||
|
||||
@@ -36,6 +36,8 @@ const InputObject = Schema.StructWithRest(
|
||||
webfetch: Schema.optional(Action),
|
||||
websearch: Schema.optional(Action),
|
||||
codesearch: Schema.optional(Action),
|
||||
repo_clone: Schema.optional(Rule),
|
||||
repo_overview: Schema.optional(Rule),
|
||||
lsp: Schema.optional(Rule),
|
||||
doom_loop: Schema.optional(Action),
|
||||
skill: Schema.optional(Rule),
|
||||
|
||||
@@ -23,6 +23,8 @@ 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"
|
||||
@@ -45,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"
|
||||
|
||||
@@ -80,6 +83,7 @@ export const layer: Layer.Layer<
|
||||
| Skill.Service
|
||||
| Session.Service
|
||||
| Provider.Service
|
||||
| Git.Service
|
||||
| LSP.Service
|
||||
| Instruction.Service
|
||||
| AppFileSystem.Service
|
||||
@@ -109,6 +113,8 @@ export const layer: Layer.Layer<
|
||||
const websearch = yield* WebSearchTool
|
||||
const shell = yield* ShellTool
|
||||
const codesearch = yield* CodeSearchTool
|
||||
const repoClone = yield* RepoCloneTool
|
||||
const repoOverview = yield* RepoOverviewTool
|
||||
const globtool = yield* GlobTool
|
||||
const writetool = yield* WriteTool
|
||||
const edit = yield* EditTool
|
||||
@@ -199,6 +205,8 @@ export const layer: Layer.Layer<
|
||||
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),
|
||||
@@ -222,6 +230,8 @@ export const layer: Layer.Layer<
|
||||
tool.todo,
|
||||
tool.search,
|
||||
tool.code,
|
||||
tool.repo_clone,
|
||||
tool.repo_overview,
|
||||
tool.skill,
|
||||
tool.patch,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
|
||||
@@ -336,6 +346,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,153 @@
|
||||
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",
|
||||
}),
|
||||
})
|
||||
|
||||
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 }) {
|
||||
if (!input.reuse) return "cloned" as const
|
||||
if (input.refresh) return "refreshed" as const
|
||||
return "cached" as const
|
||||
}
|
||||
|
||||
function resetTarget(input: {
|
||||
remoteHead: { code: number; stdout: string }
|
||||
branch: { code: number; stdout: string }
|
||||
}) {
|
||||
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"
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
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),
|
||||
},
|
||||
})
|
||||
|
||||
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 status = statusForRepository({ reuse, refresh: params.refresh })
|
||||
|
||||
if (status === "cloned") {
|
||||
const clone = yield* git.run(["clone", "--depth", "100", 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}`)
|
||||
}
|
||||
|
||||
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({
|
||||
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
|
||||
@@ -0,0 +1,106 @@
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
export type Reference = {
|
||||
host: string
|
||||
path: string
|
||||
segments: string[]
|
||||
owner?: string
|
||||
repo: string
|
||||
remote: string
|
||||
label: string
|
||||
}
|
||||
|
||||
function normalize(input: string) {
|
||||
return input.trim().replace(/^git\+/, "").replace(/#.*$/, "").replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function trimGitSuffix(input: string) {
|
||||
return input.replace(/\.git$/, "")
|
||||
}
|
||||
|
||||
function parts(input: string) {
|
||||
return input
|
||||
.split("/")
|
||||
.map((item) => trimGitSuffix(item.trim()))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function hostLike(input: string) {
|
||||
return input.includes(".") || input.includes(":") || input === "localhost"
|
||||
}
|
||||
|
||||
function withSlash(input: string) {
|
||||
return input.endsWith("/") ? input : `${input}/`
|
||||
}
|
||||
|
||||
function githubRemote(pathname: string) {
|
||||
const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||
if (!base) return `https://github.com/${pathname}.git`
|
||||
return new URL(`${pathname}.git`, withSlash(base)).href
|
||||
}
|
||||
|
||||
function build(input: { host: string; segments: string[]; remote?: string }) {
|
||||
const segments = input.segments.map(trimGitSuffix).filter(Boolean)
|
||||
if (!segments.length) return null
|
||||
const pathname = segments.join("/")
|
||||
const repo = segments[segments.length - 1]
|
||||
const host = input.host.toLowerCase()
|
||||
return {
|
||||
host,
|
||||
path: pathname,
|
||||
segments,
|
||||
owner: segments.length === 2 ? segments[0] : undefined,
|
||||
repo,
|
||||
remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`),
|
||||
label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`,
|
||||
} satisfies Reference
|
||||
}
|
||||
|
||||
export function parseRepositoryReference(input: string) {
|
||||
const cleaned = normalize(input)
|
||||
if (!cleaned) return null
|
||||
|
||||
const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/)
|
||||
if (githubPrefixed) return build({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] })
|
||||
|
||||
if (!cleaned.includes("://")) {
|
||||
const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/)
|
||||
if (scp) return build({ host: scp[1], segments: parts(scp[2]), remote: cleaned })
|
||||
|
||||
const direct = parts(cleaned)
|
||||
if (direct.length >= 2 && hostLike(direct[0])) {
|
||||
return build({ host: direct[0], segments: direct.slice(1) })
|
||||
}
|
||||
|
||||
if (direct.length === 2) {
|
||||
return build({ host: "github.com", segments: direct })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(cleaned)
|
||||
const pathname = parts(url.pathname)
|
||||
const host = url.protocol === "file:" ? "file" : url.host
|
||||
return build({ host, segments: pathname, remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitHubRemote(input: string) {
|
||||
const cleaned = normalize(input)
|
||||
if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null
|
||||
|
||||
const parsed = parseRepositoryReference(cleaned)
|
||||
if (!parsed || parsed.host !== "github.com" || !parsed.owner || parsed.segments.length !== 2) return null
|
||||
return { owner: parsed.owner, repo: parsed.repo }
|
||||
}
|
||||
|
||||
export function repositoryCachePath(input: Reference) {
|
||||
return path.join(Global.Path.data, "repos", ...input.host.split(":"), ...input.segments)
|
||||
}
|
||||
|
||||
export function sameRepositoryReference(left: Reference, right: Reference) {
|
||||
return left.host === right.host && left.path === right.path
|
||||
}
|
||||
Reference in New Issue
Block a user