feat(core): add scout agent for repo research (#24149)

Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
Shoubhit Dash
2026-05-08 20:20:08 +00:00
committed by GitHub
co-authored by Dax Raad
parent 6e47ae769e
commit 40d5ea1cf1
44 changed files with 1622 additions and 66 deletions
+6
View File
@@ -1619,6 +1619,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"
@@ -1642,6 +1644,10 @@ 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 ShellID.ToolID:
return []
default:
+112 -4
View File
@@ -10,11 +10,13 @@ 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"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
@@ -25,6 +27,9 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { zod } from "@/util/effect-zod"
import { withStatics, type DeepMutable } from "@/util/schema"
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
export const Info = Schema.Struct({
name: Schema.String,
description: Schema.optional(Schema.String),
@@ -86,6 +91,10 @@ export const layer = Layer.effect(
path.join(Global.Path.tmp, "*"),
...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",
@@ -97,6 +106,8 @@ export const layer = Layer.effect(
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
repo_clone: "deny",
repo_overview: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
@@ -174,10 +185,7 @@ export const layer = Layer.effect(
webfetch: "allow",
websearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
external_directory: readonlyExternalDirectory,
}),
user,
),
@@ -187,6 +195,37 @@ export const layer = Layer.effect(
mode: "subagent",
native: true,
},
...(Flag.OPENCODE_EXPERIMENTAL_SCOUT
? {
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.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" as const,
native: true,
},
}
: {}),
compaction: {
name: "compaction",
mode: "primary",
@@ -264,6 +303,75 @@ export const layer = Layer.effect(
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
function referencePath(value: string) {
if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2))
return path.isAbsolute(value)
? value
: path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value)
}
function resolveReference(reference: ReferenceEntry): ResolvedReference {
if (typeof reference === "string") {
if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) {
return { kind: "local", path: referencePath(reference) }
}
return { kind: "git", repository: reference }
}
if ("path" in reference) return { kind: "local", path: referencePath(reference.path) }
return { kind: "git", repository: reference.repository, branch: reference.branch }
}
function referencePrompt(name: string, reference: ResolvedReference) {
if (reference.kind === "local") {
return [
PROMPT_SCOUT,
`You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`,
`Local directory: ${reference.path}`,
`When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
].join("\n\n")
}
return [
PROMPT_SCOUT,
`You are Scout reference @${name}. This reference points to a git repository.`,
`Repository: ${reference.repository}`,
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
`When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`,
].join("\n\n")
}
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
if (agents[name]) continue
const resolved = resolveReference(reference)
const localPath = resolved.kind === "local" ? resolved.path : undefined
agents[name] = {
name,
description:
resolved.kind === "local"
? `Scout reference for local directory ${resolved.path}`
: `Scout reference for repository ${resolved.repository}`,
permission: Permission.merge(
agents.scout.permission,
Permission.fromConfig(
localPath
? {
external_directory: {
[localPath]: "allow",
[path.join(localPath, "*")]: "allow",
},
}
: {},
),
),
prompt: referencePrompt(name, resolved),
options: { reference },
mode: "subagent",
native: false,
}
}
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in agents) {
const agent = agents[name]
@@ -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.
+2 -12
View File
@@ -32,6 +32,7 @@ import { SessionPrompt } from "@/session/prompt"
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 = {
@@ -151,18 +152,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.
+5
View File
@@ -37,6 +37,7 @@ import { ConfigPaths } from "./paths"
import { ConfigPermission } from "./permission"
import { ConfigPlugin } from "./plugin"
import { ConfigProvider } from "./provider"
import { ConfigReference } from "./reference"
import { ConfigServer } from "./server"
import { ConfigSkills } from "./skills"
import { ConfigVariable } from "./variable"
@@ -142,6 +143,9 @@ export const Info = Schema.Struct({
description: "Command configuration, see https://opencode.ai/docs/commands",
}),
skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),
reference: Schema.optional(ConfigReference.Info).annotate({
description: "Named git or local directory references that can be @ mentioned as Scout-backed subagents",
}),
watcher: Schema.optional(
Schema.Struct({
ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
@@ -201,6 +205,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),
@@ -35,6 +35,9 @@ const InputObject = Schema.StructWithRest(
question: Schema.optional(Action),
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),
+27
View File
@@ -0,0 +1,27 @@
export * as ConfigReference from "./reference"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const Git = Schema.Struct({
repository: Schema.String.annotate({
description: "Git repository URL, host/path reference, or GitHub owner/repo shorthand",
}),
branch: Schema.optional(Schema.String).annotate({
description: "Branch or ref Scout should clone and inspect",
}),
})
const Local = Schema.Struct({
path: Schema.String.annotate({
description: "Absolute path, ~/ path, or workspace-relative path to a local reference directory",
}),
})
export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" })
export const Info = Schema.Record(Schema.String, Entry)
.annotate({ identifier: "ReferenceConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
+63
View File
@@ -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),
}
}),
)
+12
View File
@@ -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),
+13
View File
@@ -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),
+209
View File
@@ -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
+238
View File
@@ -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
+139
View File
@@ -0,0 +1,139 @@
import path from "path"
import { fileURLToPath } from "url"
import { Global } from "@opencode-ai/core/global"
export type Reference = {
host: string
path: string
segments: string[]
owner?: string
repo: string
remote: string
label: string
protocol?: 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 safeHost(input: string) {
return Boolean(input) && !input.startsWith("-") && !/[\s/\\]/.test(input)
}
function safeSegment(input: string) {
return input !== "." && input !== ".." && !input.includes(":") && !/[\s/\\]/.test(input)
}
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; protocol?: string }) {
const segments = input.segments.map(trimGitSuffix).filter(Boolean)
if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) 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}`,
protocol: input.protocol,
} satisfies Reference
}
function buildFile(input: { url: URL; remote: string }) {
const filePath = path.normalize(fileURLToPath(input.url))
const segments = filePath.split(/[\\/]+/).filter(Boolean)
if (!segments.length) return null
return {
host: "file",
path: filePath,
segments: segments.map((segment) => segment.replace(/:$/, "")),
owner: undefined,
repo: trimGitSuffix(segments[segments.length - 1]),
remote: input.remote,
label: filePath,
protocol: "file:",
} 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)
if (url.protocol === "file:") return buildFile({ url, remote: cleaned })
const pathname = parts(url.pathname)
const host = url.host
return build({
host,
segments: pathname,
remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned,
protocol: url.protocol,
})
} 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.repos, ...input.host.split(":"), ...input.segments)
}
export function sameRepositoryReference(left: Reference, right: Reference) {
return left.host === right.host && left.path === right.path
}
+110 -16
View File
@@ -2,11 +2,11 @@ import { afterEach, test, expect } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Permission } from "../../src/permission"
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
@@ -18,25 +18,38 @@ function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}
async function withExperimentalScout(enabled: boolean, fn: () => Promise<void>) {
const original = Flag.OPENCODE_EXPERIMENTAL_SCOUT
Flag.OPENCODE_EXPERIMENTAL_SCOUT = enabled
try {
await fn()
} finally {
Flag.OPENCODE_EXPERIMENTAL_SCOUT = original
}
}
afterEach(async () => {
await disposeAllInstances()
})
test("returns default native agents when no config", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agents = await load(tmp.path, (svc) => svc.list())
const names = agents.map((a) => a.name)
expect(names).toContain("build")
expect(names).toContain("plan")
expect(names).toContain("general")
expect(names).toContain("explore")
expect(names).toContain("compaction")
expect(names).toContain("title")
expect(names).toContain("summary")
},
await withExperimentalScout(false, async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agents = await load(tmp.path, (svc) => svc.list())
const names = agents.map((a) => a.name)
expect(names).toContain("build")
expect(names).toContain("plan")
expect(names).toContain("general")
expect(names).toContain("explore")
expect(names).not.toContain("scout")
expect(names).toContain("compaction")
expect(names).toContain("title")
expect(names).toContain("summary")
},
})
})
})
@@ -51,6 +64,8 @@ test("build agent has correct default properties", async () => {
expect(build?.native).toBe(true)
expect(evalPerm(build, "edit")).toBe("allow")
expect(evalPerm(build, "bash")).toBe("allow")
expect(evalPerm(build, "repo_clone")).toBe("deny")
expect(evalPerm(build, "repo_overview")).toBe("deny")
},
})
})
@@ -102,6 +117,85 @@ test("explore agent asks for external directories and allows whitelisted externa
})
})
test("scout agent allows repo cloning and repo cache reads", async () => {
await withExperimentalScout(true, async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const scout = await load(tmp.path, (svc) => svc.get("scout"))
expect(scout).toBeDefined()
expect(scout?.mode).toBe("subagent")
expect(evalPerm(scout, "repo_clone")).toBe("allow")
expect(evalPerm(scout, "repo_overview")).toBe("allow")
expect(evalPerm(scout, "edit")).toBe("deny")
expect(
Permission.evaluate(
"external_directory",
path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"),
scout!.permission,
).action,
).toBe("allow")
},
})
})
})
test("reference config creates scout-backed subagents", async () => {
await withExperimentalScout(true, async () => {
await using tmp = await tmpdir({
config: {
reference: {
effect: "github.com/effect/effect-smol",
effectFull: {
repository: "Effect-TS/effect",
branch: "main",
},
localdocs: "../docs",
localdocsFull: {
path: "../local-docs",
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const effect = await load(tmp.path, (svc) => svc.get("effect"))
const effectFull = await load(tmp.path, (svc) => svc.get("effectFull"))
const local = await load(tmp.path, (svc) => svc.get("localdocs"))
const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull"))
expect(effect).toBeDefined()
expect(effect?.mode).toBe("subagent")
expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol")
expect(evalPerm(effect, "repo_clone")).toBe("allow")
expect(effectFull).toBeDefined()
expect(effectFull?.mode).toBe("subagent")
expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect")
expect(effectFull?.prompt).toContain("Branch/ref: main")
expect(evalPerm(effectFull, "repo_clone")).toBe("allow")
expect(local).toBeDefined()
expect(local?.mode).toBe("subagent")
expect(local?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../docs")}`)
expect(
Permission.evaluate(
"external_directory",
path.join(path.resolve(tmp.path, "../docs"), "README.md"),
local!.permission,
).action,
).toBe("allow")
expect(localFull).toBeDefined()
expect(localFull?.mode).toBe("subagent")
expect(localFull?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../local-docs")}`)
},
})
})
})
test("general agent denies todo tools", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
@@ -25,6 +25,16 @@ test("parses ssh:// URL without .git suffix", () => {
expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
})
test("parses git protocol URLs from package metadata", () => {
expect(parseGitHubRemote("git://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" })
expect(parseGitHubRemote("git+https://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" })
expect(parseGitHubRemote("git+ssh://git@github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" })
})
test("parses npm-style github shorthand", () => {
expect(parseGitHubRemote("github:facebook/react")).toBeNull()
})
test("parses http URL", () => {
expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" })
})
@@ -15,6 +15,7 @@ import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "@/provider/provider"
import { Env } from "../../src/env"
import { Git } from "../../src/git"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question"
import { Todo } from "../../src/session/todo"
@@ -178,6 +179,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),
@@ -30,6 +30,7 @@ import { TestLLMServer } from "../lib/llm-server"
// Same layer setup as prompt-effect.test.ts
import { NodeFileSystem } from "@effect/platform-node"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Git } from "../../src/git"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Config } from "@/config/config"
@@ -128,6 +129,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),
@@ -4,6 +4,7 @@ import fs from "fs/promises"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry"
import { Flag } from "@opencode-ai/core/flag/flag"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
@@ -15,6 +16,7 @@ import { Skill } from "@/skill"
import { Agent } from "@/agent/agent"
import { Session } from "@/session/session"
import { Provider } from "@/provider/provider"
import { Git } from "@/git"
import { LSP } from "@/lsp/lsp"
import { Instruction } from "@/session/instruction"
import { Bus } from "@/bus"
@@ -25,6 +27,7 @@ import * as Truncate from "@/tool/truncate"
import { InstanceState } from "@/effect/instance-state"
const node = CrossSpawnSpawner.defaultLayer
const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
const configLayer = TestConfig.layer({
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
})
@@ -38,6 +41,7 @@ const registryLayer = ToolRegistry.layer.pipe(
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),
@@ -52,10 +56,35 @@ const registryLayer = ToolRegistry.layer.pipe(
const it = testEffect(Layer.mergeAll(registryLayer, node))
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout
await disposeAllInstances()
})
describe("tool.registry", () => {
it.instance("hides repo research tools unless experimental", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_SCOUT = false
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).not.toContain("codesearch")
expect(ids).not.toContain("repo_clone")
expect(ids).not.toContain("repo_overview")
}),
)
it.instance("shows repo research tools when experimental scout is enabled", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_SCOUT = true
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("codesearch")
expect(ids).toContain("repo_clone")
expect(ids).toContain("repo_overview")
}),
)
it.instance("loads tools from .opencode/tool (singular)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
@@ -0,0 +1,226 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { pathToFileURL } from "node:url"
import { Cause, Effect, Exit, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Git } from "../../src/git"
import { Global } from "@opencode-ai/core/global"
import { MessageID, SessionID } from "../../src/session/schema"
import { Truncate } from "../../src/tool/truncate"
import { RepoCloneTool } from "../../src/tool/repo_clone"
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "scout",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Git.defaultLayer,
Truncate.defaultLayer,
),
)
const init = Effect.fn("RepoCloneToolTest.init")(function* () {
const info = yield* RepoCloneTool
return yield* info.init()
})
const git = Effect.fn("RepoCloneToolTest.git")(function* (cwd: string, args: string[]) {
return yield* Effect.promise(async () => {
const proc = Bun.spawn(["git", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (code !== 0) {
throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
}
return stdout.trim()
})
})
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
return previous
}),
() => self,
(previous) =>
Effect.sync(() => {
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
}),
)
describe("tool.repo_clone", () => {
it.live("clones a repo into the managed cache and reuses it on subsequent calls", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "owner")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add readme"])
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const tool = yield* init()
const cloned = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx))
const cached = yield* githubBase(
`file://${remoteRoot}/`,
tool.execute({ repository: "https://github.com/owner/repo.git" }, ctx),
)
expect(cloned.metadata.status).toBe("cloned")
expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo"))
expect(cached.metadata.status).toBe("cached")
expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n")
}),
),
)
it.live("refresh updates an existing cached clone", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "owner")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add readme"])
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const branch = yield* git(source, ["branch", "--show-current"])
yield* git(source, ["remote", "add", "origin", remoteRepo])
yield* git(source, ["push", "-u", "origin", `${branch}:${branch}`])
const tool = yield* init()
const first = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx))
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "update readme"])
yield* git(source, ["push", "origin", `${branch}:${branch}`])
const refreshed = yield* githubBase(
`file://${remoteRoot}/`,
tool.execute({ repository: "owner/repo", refresh: true }, ctx),
)
expect(first.metadata.status).toBe("cloned")
expect(refreshed.metadata.status).toBe("refreshed")
expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n")
}),
),
)
it.live("clones a configured branch", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "owner")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "main\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add readme"])
yield* git(source, ["checkout", "-b", "docs"])
yield* Effect.promise(() => Bun.write(path.join(source, "DOCS.md"), "docs\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add docs"])
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const tool = yield* init()
const result = yield* githubBase(
`file://${remoteRoot}/`,
tool.execute({ repository: "owner/repo", branch: "docs" }, ctx),
)
expect(result.metadata.status).toBe("cloned")
expect(result.metadata.branch).toBe("docs")
expect(yield* fs.readFileString(path.join(result.metadata.localPath, "DOCS.md"))).toBe("docs\n")
}),
),
)
it.live("rejects invalid repository inputs", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const tool = yield* init()
const inputs = [
{ repository: "not-a-repo", message: "git URL" },
{ repository: "git@github.com:../../../etc/passwd", message: "git URL" },
{ repository: "-u:foo/bar", message: "git URL" },
{ repository: pathToFileURL(path.join(_dir, "local.git")).href, message: "Local file" },
]
yield* Effect.forEach(
inputs,
(input) =>
Effect.gen(function* () {
const result = yield* tool.execute({ repository: input.repository }, ctx).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result)) {
const error = Cause.squash(result.cause)
expect(error instanceof Error ? error.message : String(error)).toContain(input.message)
}
}),
{ discard: true },
)
}),
),
)
it.live("rejects local file repository URLs", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const source = yield* tmpdirScoped({ git: true })
const tool = yield* init()
const result = yield* tool.execute({ repository: pathToFileURL(source).href }, ctx).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result)) {
const error = Cause.squash(result.cause)
expect(error instanceof Error ? error.message : String(error)).toContain("Local file")
}
}),
),
)
})
@@ -0,0 +1,150 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { Cause, Effect, Exit, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Git } from "../../src/git"
import { Global } from "@opencode-ai/core/global"
import { MessageID, SessionID } from "../../src/session/schema"
import { Truncate } from "../../src/tool/truncate"
import { RepoOverviewTool } from "../../src/tool/repo_overview"
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "scout",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Git.defaultLayer,
Truncate.defaultLayer,
),
)
const init = Effect.fn("RepoOverviewToolTest.init")(function* () {
const info = yield* RepoOverviewTool
return yield* info.init()
})
describe("tool.repo_overview", () => {
it.live("summarizes a local repository path", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const repo = yield* tmpdirScoped({ git: true })
const fs = yield* AppFileSystem.Service
yield* fs.writeWithDirs(
path.join(repo, "package.json"),
JSON.stringify(
{
name: "example-repo",
main: "dist/index.js",
module: "dist/index.mjs",
types: "dist/index.d.ts",
exports: {
".": "./dist/index.js",
"./server": "./dist/server.js",
},
bin: {
example: "./bin/example.js",
},
},
null,
2,
),
)
yield* fs.writeWithDirs(path.join(repo, "bun.lock"), "")
yield* fs.writeWithDirs(path.join(repo, "README.md"), "# Example\n")
yield* fs.writeWithDirs(path.join(repo, "src", "index.ts"), "export const value = 1\n")
const tool = yield* init()
const result = yield* tool.execute({ path: repo }, ctx)
expect(result.metadata.path).toBe(repo)
expect(result.metadata.ecosystems).toContain("Node.js")
expect(result.metadata.package_manager).toBe("bun")
expect(result.metadata.dependency_files).toEqual(expect.arrayContaining(["package.json", "bun.lock"]))
expect(result.metadata.entrypoints).toEqual(
expect.arrayContaining([
"main: dist/index.js",
"module: dist/index.mjs",
"types: dist/index.d.ts",
"exports: .",
"exports: ./server",
"bin: example",
"file: src/index.ts",
]),
)
expect(result.output).toContain("Top-level structure:")
expect(result.output).toContain("src/")
expect(result.output).toContain("README.md")
}),
),
)
it.live("resolves a cached repository from repository shorthand", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const cached = path.join(Global.Path.repos, "github.com", "owner", "repo")
yield* fs.writeWithDirs(path.join(cached, "package.json"), JSON.stringify({ name: "cached-repo" }, null, 2))
yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n")
const tool = yield* init()
const result = yield* tool.execute({ repository: "owner/repo" }, ctx)
expect(result.metadata.path).toBe(cached)
expect(result.metadata.repository).toBe("owner/repo")
expect(result.output).toContain("Repository: owner/repo")
expect(result.output).toContain(`Path: ${cached}`)
}),
),
)
it.live("fails clearly when a repository is not cloned", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const tool = yield* init()
const result = yield* tool.execute({ repository: "missing/repo" }, ctx).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result)) {
const error = Cause.squash(result.cause)
expect(error instanceof Error ? error.message : String(error)).toContain("Use repo_clone first")
}
}),
),
)
it.live("resolves cached repositories from host/path references", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const cached = path.join(Global.Path.repos, "gitlab.com", "group", "repo")
yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n")
const tool = yield* init()
const result = yield* tool.execute({ repository: "gitlab.com/group/repo" }, ctx)
expect(result.metadata.path).toBe(cached)
expect(result.metadata.repository).toBe("gitlab.com/group/repo")
expect(result.output).toContain("Repository: gitlab.com/group/repo")
}),
),
)
})