feat(scout): add repo research tools

This commit is contained in:
Shoubhit Dash
2026-04-24 16:29:19 +05:30
parent aed03078f8
commit 1e0246cdc8
20 changed files with 1004 additions and 16 deletions

View File

@@ -1561,6 +1561,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"
@@ -1583,6 +1585,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 "bash":
return []
default:

View File

@@ -11,6 +11,7 @@ import { ProviderTransform } from "../provider"
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"
@@ -83,6 +84,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(
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",
@@ -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.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",

View File

@@ -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.

View File

@@ -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"
import { parseGitHubRemote } from "@/util/github-remote"
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.

View File

@@ -170,6 +170,7 @@ export const Info = Schema.Struct({
// subagent
general: Schema.optional(AgentRef),
explore: Schema.optional(AgentRef),
scout: Schema.optional(AgentRef),
// specialized
title: Schema.optional(AgentRef),
summary: Schema.optional(AgentRef),

View File

@@ -44,6 +44,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),

View File

@@ -20,6 +20,7 @@ export const Path = {
data,
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
repos: path.join(data, "repos"),
cache,
config,
state,
@@ -34,6 +35,7 @@ await Promise.all([
fs.mkdir(Path.state, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }),
fs.mkdir(Path.repos, { recursive: true }),
])
const CACHE_VERSION = "21"

View File

@@ -21,6 +21,8 @@ import { Provider } from "../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 "@/flag/flag"
import { Log } from "@/util"
import { LspTool } from "./lsp"
@@ -43,6 +45,7 @@ import { Instruction } from "../session/instruction"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Bus } from "../bus"
import { Agent } from "../agent/agent"
import { Git } from "@/git"
import { Skill } from "../skill"
import { Permission } from "@/permission"
@@ -78,6 +81,7 @@ export const layer: Layer.Layer<
| Skill.Service
| Session.Service
| Provider.Service
| Git.Service
| LSP.Service
| Instruction.Service
| AppFileSystem.Service
@@ -107,6 +111,8 @@ export const layer: Layer.Layer<
const websearch = yield* WebSearchTool
const bash = yield* BashTool
const codesearch = yield* CodeSearchTool
const repoClone = yield* RepoCloneTool
const repoOverview = yield* RepoOverviewTool
const globtool = yield* GlobTool
const writetool = yield* WriteTool
const edit = yield* EditTool
@@ -189,6 +195,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),
@@ -212,6 +220,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] : []),
@@ -326,6 +336,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),

View File

@@ -0,0 +1,142 @@
import path from "path"
import z from "zod"
import { Effect } from "effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Flock } from "@opencode-ai/shared/util/flock"
import { Git } from "@/git"
import DESCRIPTION from "./repo_clone.txt"
import * as Tool from "./tool"
import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository"
const parameters = z.object({
repository: z
.string()
.describe("Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand"),
refresh: z.boolean().optional().describe("When true, fetches the latest remote state into the managed cache"),
})
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(
"repo_clone",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
return {
description: DESCRIPTION,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
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),
}
}),
)

View File

@@ -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

View File

@@ -0,0 +1,238 @@
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),
}
}),
)

View File

@@ -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

View File

@@ -0,0 +1,34 @@
function normalize(input: string) {
return input.trim().replace(/^git\+/, "").replace(/#.*$/, "")
}
export function parseGitHubRemote(url: string): { owner: string; repo: string } | null {
const match = normalize(url).match(/^(?:(?:https?|ssh|git):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
if (!match) return null
return { owner: match[1], repo: match[2] }
}
export function parseGitHubRepository(input: string): { owner: string; repo: string } | null {
const cleaned = normalize(input)
const remote = parseGitHubRemote(cleaned)
if (remote) return remote
const prefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/)
if (prefixed) {
return { owner: prefixed[1], repo: prefixed[2].replace(/\.git$/, "") }
}
const match = cleaned.match(/^([^/\s]+)\/([^/\s]+)$/)
if (!match) return null
return { owner: match[1], repo: match[2].replace(/\.git$/, "") }
}
export function githubRepositoryURL(input: { owner: string; repo: string }) {
return `https://github.com/${input.owner}/${input.repo}`
}
export function githubCloneURL(input: { owner: string; repo: string }) {
const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
if (!base) return `https://github.com/${input.owner}/${input.repo}.git`
return new URL(`${input.owner}/${input.repo}.git`, base.endsWith("/") ? base : `${base}/`).href
}

View File

@@ -0,0 +1,97 @@
import path from "path"
import { Global } from "@/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 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
}

View File

@@ -4,6 +4,7 @@ import path from "path"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
import { Global } from "../../src/global"
import { Permission } from "../../src/permission"
// Helper to evaluate permission for a tool with wildcard pattern
@@ -31,6 +32,7 @@ test("returns default native agents when no config", async () => {
expect(names).toContain("plan")
expect(names).toContain("general")
expect(names).toContain("explore")
expect(names).toContain("scout")
expect(names).toContain("compaction")
expect(names).toContain("title")
expect(names).toContain("summary")
@@ -49,6 +51,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")
},
})
})
@@ -97,6 +101,28 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
})
})
test("scout agent allows repo cloning and repo cache reads", async () => {
await using tmp = await tmpdir()
await Instance.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("general agent denies todo tools", async () => {
await using tmp = await tmpdir()
await Instance.provide({

View File

@@ -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" })
})

View File

@@ -15,6 +15,7 @@ import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/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"
@@ -175,6 +176,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),

View File

@@ -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 "../../src/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),

View File

@@ -0,0 +1,198 @@
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/shared/filesystem"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Git } from "../../src/git"
import { Global } from "../../src/global"
import { Instance } from "../../src/project/instance"
import { MessageID, SessionID } from "../../src/session/schema"
import { Truncate } from "../../src/tool"
import { RepoCloneTool } from "../../src/tool/repo_clone"
import { provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await Instance.disposeAll()
})
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("rejects invalid repository inputs", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const tool = yield* init()
const result = yield* tool.execute({ repository: "not-a-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("git URL")
}
}),
),
)
it.live("clones generic git URLs into the managed cache", () =>
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, "forge")
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 result = yield* tool.execute({ repository: pathToFileURL(remoteRepo).href }, ctx)
expect(result.metadata.status).toBe("cloned")
expect(result.metadata.host).toBe("file")
expect(result.metadata.localPath.startsWith(path.join(Global.Path.repos, "file"))).toBe(true)
expect(result.metadata.localPath.endsWith(path.join("forge", "repo"))).toBe(true)
expect(yield* fs.readFileString(path.join(result.metadata.localPath, "README.md"))).toBe("v1\n")
}),
),
)
})

View File

@@ -0,0 +1,151 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { Cause, Effect, Exit, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Git } from "../../src/git"
import { Global } from "../../src/global"
import { Instance } from "../../src/project/instance"
import { MessageID, SessionID } from "../../src/session/schema"
import { Truncate } from "../../src/tool"
import { RepoOverviewTool } from "../../src/tool/repo_overview"
import { provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await Instance.disposeAll()
})
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")
}),
),
)
})