From 1e0246cdc81c58d6ef533e928b047ea604f47eaf Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 16:29:19 +0530 Subject: [PATCH 1/6] feat(scout): add repo research tools --- packages/opencode/src/acp/agent.ts | 6 + packages/opencode/src/agent/agent.ts | 39 ++- packages/opencode/src/agent/prompt/scout.txt | 36 +++ packages/opencode/src/cli/cmd/github.ts | 14 +- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/config/permission.ts | 2 + packages/opencode/src/global/index.ts | 2 + packages/opencode/src/tool/registry.ts | 11 + packages/opencode/src/tool/repo_clone.ts | 142 +++++++++++ packages/opencode/src/tool/repo_clone.txt | 5 + packages/opencode/src/tool/repo_overview.ts | 238 ++++++++++++++++++ packages/opencode/src/tool/repo_overview.txt | 4 + packages/opencode/src/util/github-remote.ts | 34 +++ packages/opencode/src/util/repository.ts | 97 +++++++ packages/opencode/test/agent/agent.test.ts | 26 ++ .../opencode/test/cli/github-remote.test.ts | 10 + packages/opencode/test/session/prompt.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + .../opencode/test/tool/repo_clone.test.ts | 198 +++++++++++++++ .../opencode/test/tool/repo_overview.test.ts | 151 +++++++++++ 20 files changed, 1004 insertions(+), 16 deletions(-) create mode 100644 packages/opencode/src/agent/prompt/scout.txt create mode 100644 packages/opencode/src/tool/repo_clone.ts create mode 100644 packages/opencode/src/tool/repo_clone.txt create mode 100644 packages/opencode/src/tool/repo_overview.ts create mode 100644 packages/opencode/src/tool/repo_overview.txt create mode 100644 packages/opencode/src/util/github-remote.ts create mode 100644 packages/opencode/src/util/repository.ts create mode 100644 packages/opencode/test/tool/repo_clone.test.ts create mode 100644 packages/opencode/test/tool/repo_overview.test.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 672b93f6ceb7..6449e1b02ccd 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -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): { 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: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 355718b6bf39..60e9c72ee299 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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 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", diff --git a/packages/opencode/src/agent/prompt/scout.txt b/packages/opencode/src/agent/prompt/scout.txt new file mode 100644 index 000000000000..c315cc5a6b26 --- /dev/null +++ b/packages/opencode/src/agent/prompt/scout.txt @@ -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. diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index fe8e233dd176..c44b58d6a44e 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -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. diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5423ba3baf5f..032007aa7183 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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), diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index fdd574683705..73b21cbc53c4 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -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), diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 27bac598fb75..998d047fd341 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -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" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 0211e33bcbfa..2dfea58f2da8 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -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), diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts new file mode 100644 index 000000000000..0b22ae6432e3 --- /dev/null +++ b/packages/opencode/src/tool/repo_clone.ts @@ -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, 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), + } + }), +) diff --git a/packages/opencode/src/tool/repo_clone.txt b/packages/opencode/src/tool/repo_clone.txt new file mode 100644 index 000000000000..7944015506a3 --- /dev/null +++ b/packages/opencode/src/tool/repo_clone.txt @@ -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 diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts new file mode 100644 index 000000000000..650bc352f1f7 --- /dev/null +++ b/packages/opencode/src/tool/repo_overview.ts @@ -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) { + 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) { + 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) { + 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( + "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) { + 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 = 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, 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 + : {} + + 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).map((name) => `bin: ${name}`) + : []), + ...(packageJson.exports && typeof packageJson.exports === "object" && !Array.isArray(packageJson.exports) + ? Object.keys(packageJson.exports as Record).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), + } + }), +) diff --git a/packages/opencode/src/tool/repo_overview.txt b/packages/opencode/src/tool/repo_overview.txt new file mode 100644 index 000000000000..210983874655 --- /dev/null +++ b/packages/opencode/src/tool/repo_overview.txt @@ -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 diff --git a/packages/opencode/src/util/github-remote.ts b/packages/opencode/src/util/github-remote.ts new file mode 100644 index 000000000000..fc30e2cfcfde --- /dev/null +++ b/packages/opencode/src/util/github-remote.ts @@ -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 +} diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts new file mode 100644 index 000000000000..f9ffb0e49cd4 --- /dev/null +++ b/packages/opencode/src/util/repository.ts @@ -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 +} diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 50a3668f98a8..dee53a921224 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -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({ diff --git a/packages/opencode/test/cli/github-remote.test.ts b/packages/opencode/test/cli/github-remote.test.ts index 80102d986ead..ed37b92d4106 100644 --- a/packages/opencode/test/cli/github-remote.test.ts +++ b/packages/opencode/test/cli/github-remote.test.ts @@ -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" }) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 8ffb20f15419..f0eb23d0f0bf 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -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), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 651754733909..b1518eb1f09c 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -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), diff --git a/packages/opencode/test/tool/repo_clone.test.ts b/packages/opencode/test/tool/repo_clone.test.ts new file mode 100644 index 000000000000..bcc855843dac --- /dev/null +++ b/packages/opencode/test/tool/repo_clone.test.ts @@ -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 = (url: string, self: Effect.Effect) => + 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") + }), + ), + ) +}) diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts new file mode 100644 index 000000000000..b114659a2270 --- /dev/null +++ b/packages/opencode/test/tool/repo_overview.test.ts @@ -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") + }), + ), + ) +}) From 0db04ef69f7bbc5a60ae42b8d0187d6f4878d7f3 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 16:29:31 +0530 Subject: [PATCH 2/6] docs: add scout agent docs --- packages/web/src/content/docs/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/ar/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/bs/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/da/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/de/agents.mdx | 8 ++++++++ packages/web/src/content/docs/es/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/fr/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/it/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/ja/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/ko/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/nb/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/pl/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/pt-br/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/ru/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/th/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/tr/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/zh-cn/agents.mdx | 12 ++++++++++-- packages/web/src/content/docs/zh-tw/agents.mdx | 12 ++++++++++-- 18 files changed, 178 insertions(+), 34 deletions(-) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 5522f77aae61..96689d8e8700 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -36,13 +36,13 @@ look at these below. Subagents are specialized assistants that primary agents can invoke for specific tasks. You can also manually invoke them by **@ mentioning** them in your messages. -OpenCode comes with two built-in subagents, **General** and **Explore**. We'll look at this below. +OpenCode comes with three built-in subagents, **General**, **Explore**, and **Scout**. We'll look at this below. --- ## Built-in -OpenCode comes with two built-in primary agents and two built-in subagents. +OpenCode comes with two built-in primary agents and three built-in subagents. --- @@ -84,6 +84,14 @@ A fast, read-only agent for exploring codebases. Cannot modify files. Use this w --- +### Use scout + +_Mode_: `subagent` + +A read-only agent for external docs and dependency research. Use this when you need to clone a dependency repository into OpenCode's managed cache, inspect library source, or cross-reference local code against upstream implementations without modifying your workspace. + +--- + ### Use compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/ar/agents.mdx b/packages/web/src/content/docs/ar/agents.mdx index 01e13fda896c..af12a67691c4 100644 --- a/packages/web/src/content/docs/ar/agents.mdx +++ b/packages/web/src/content/docs/ar/agents.mdx @@ -35,13 +35,13 @@ description: هيّئ الوكلاء المتخصصين واستخدمهم. الوكلاء الفرعيون هم مساعدين متخصصين يمكن للوكلاء الأساسيين استدعاؤهم لمهام محددة. يمكنك أيضا استدعاؤهم يدويا عبر **الإشارة بـ @** في رسائلك. -يأتي OpenCode مع وكيلين فرعيين مدمجين: **General** و **Explore**. سنلقي نظرة على ذلك أدناه. +يأتي OpenCode مع ثلاثة وكلاء فرعيين مدمجين: **General** و **Explore** و **Scout**. سنلقي نظرة على ذلك أدناه. --- ## المدمجة -يأتي OpenCode مع وكيلين أساسيين مدمجين ووكيلين فرعيين مدمجين. +يأتي OpenCode مع وكيلين أساسيين مدمجين وثلاثة وكلاء فرعيين مدمجين. --- @@ -83,6 +83,14 @@ _الوضع_: `subagent` --- +### استخدام Scout + +_الوضع_: `subagent` + +وكيل للقراءة فقط مخصص للوثائق الخارجية وأبحاث التبعيات. استخدمه عندما تحتاج إلى استنساخ مستودع تبعية داخل ذاكرة التخزين المؤقت المُدارة في OpenCode، أو فحص الشفرة المصدرية لمكتبة، أو إجراء مراجع متقاطعة بين الشفرة المحلية والتنفيذات upstream بدون تعديل مساحة العمل الخاصة بك. + +--- + ### استخدام Compaction _الوضع_: `primary` diff --git a/packages/web/src/content/docs/bs/agents.mdx b/packages/web/src/content/docs/bs/agents.mdx index 8ff674ae67e6..a2e211b19add 100644 --- a/packages/web/src/content/docs/bs/agents.mdx +++ b/packages/web/src/content/docs/bs/agents.mdx @@ -35,13 +35,13 @@ OpenCode dolazi sa dva ugrađena primarna agenta, **Build** i **Plan**. Pogledat Subagenti su specijalizovani pomoćnici koje primarni agenti mogu pozvati za određene zadatke. Možete ih i ručno pozvati **@ spominjanjem** u svojim porukama. -OpenCode dolazi sa dva ugrađena subagenta, **General** i **Explore**. Ovo ćemo pogledati u nastavku. +OpenCode dolazi sa tri ugrađena subagenta, **General**, **Explore** i **Scout**. Ovo ćemo pogledati u nastavku. --- ## Ugrađeni -OpenCode dolazi sa dva ugrađena primarna agenta i dva ugrađena subagenta. +OpenCode dolazi sa dva ugrađena primarna agenta i tri ugrađena subagenta. --- @@ -83,6 +83,14 @@ Brzi agent samo za čitanje za istraživanje kodnih baza. Nije moguće mijenjati --- +### Scout agent + +_Režim_: `subagent` + +Agent samo za čitanje za istraživanje eksterne dokumentacije i zavisnosti. Koristite ga kada trebate klonirati repozitorij zavisnosti u OpenCode-ov upravljani cache, pregledati izvorni kod biblioteke ili uporediti lokalni kod sa upstream implementacijama bez mijenjanja vašeg radnog prostora. + +--- + ### Compaction agent _Režim_: `primary` diff --git a/packages/web/src/content/docs/da/agents.mdx b/packages/web/src/content/docs/da/agents.mdx index 6ab2e7c39d6e..058f9eec6e25 100644 --- a/packages/web/src/content/docs/da/agents.mdx +++ b/packages/web/src/content/docs/da/agents.mdx @@ -36,13 +36,13 @@ se på disse nedenfor. Subagenter er specialiserede assistenter, som primære agenter kan påbegynde sig til specifikke opgaver. Du kan også kalde dem manuelt ved at **@ nævne** dem i dine beskeder. -OpenCode leveres med to indbyggede underagenter, **Generelt** og **Udforsk**. Vi vil se på dette nedenfor. +OpenCode leveres med tre indbyggede subagenter, **General**, **Explore** og **Scout**. Vi ser nærmere på dem nedenfor. --- ## Indbyggede -OpenCode leveres med to indbyggede primære agenter og to indbyggede subagenter. +OpenCode leveres med to indbyggede primære agenter og tre indbyggede subagenter. --- @@ -84,6 +84,14 @@ En hurtig, skrivebeskyttet agent til at udforske kodebaser. Kan ikke ændre file --- +### Scout-agenten + +_Tilstand_: `subagent` + +En skrivebeskyttet agent til eksterne docs og research af dependencies. Brug denne, når du har brug for at klone et dependency-repository ind i OpenCode's administrerede cache, inspicere kildekoden i et bibliotek eller krydstjekke lokal kode mod upstream-implementeringer uden at ændre dit workspace. + +--- + ### Compact-agenten _Tilstand_: `primary` diff --git a/packages/web/src/content/docs/de/agents.mdx b/packages/web/src/content/docs/de/agents.mdx index 289b113cf647..6bca53488d69 100644 --- a/packages/web/src/content/docs/de/agents.mdx +++ b/packages/web/src/content/docs/de/agents.mdx @@ -70,6 +70,14 @@ Ein schneller, schreibgeschützter Agent zum Erkunden von Codebasen. Dateien kö --- +### Scout + +_Modus_: `subagent` + +Ein schreibgeschützter Agent für externe Dokumentation und Dependency-Recherche. Verwenden Sie ihn, wenn Sie ein Dependency-Repository in den von OpenCode verwalteten Cache klonen, den Quellcode einer Bibliothek untersuchen oder lokalen Code mit Upstream-Implementierungen abgleichen müssen, ohne Ihren Workspace zu verändern. + +--- + ### Compaction _Modus_: `primary` diff --git a/packages/web/src/content/docs/es/agents.mdx b/packages/web/src/content/docs/es/agents.mdx index 0b2736ac3732..c98a4eb99e59 100644 --- a/packages/web/src/content/docs/es/agents.mdx +++ b/packages/web/src/content/docs/es/agents.mdx @@ -36,13 +36,13 @@ mira estos a continuación. Los subagentes son asistentes especializados que los agentes principales pueden invocar para tareas específicas. También puedes invocarlos manualmente **@ mencionándolos** en tus mensajes. -OpenCode viene con dos subagentes integrados, **General** y **Explore**. Veremos esto a continuación. +OpenCode viene con tres subagentes integrados, **General**, **Explore** y **Scout**. Veremos esto a continuación. --- ## Integrados -OpenCode viene con dos agentes primarios integrados y dos subagentes integrados. +OpenCode viene con dos agentes primarios integrados y tres subagentes integrados. --- @@ -84,6 +84,14 @@ Un agente rápido y de solo lectura para explorar bases de código. No se pueden --- +### Scout + +_Modo_: `subagent` + +Un agente de solo lectura para investigar documentación externa y dependencias. Úsalo cuando necesites clonar el repositorio de una dependencia en la caché administrada de OpenCode, inspeccionar el código fuente de una librería o contrastar el código local con implementaciones upstream sin modificar tu espacio de trabajo. + +--- + ### Compactación _Modo_: `primary` diff --git a/packages/web/src/content/docs/fr/agents.mdx b/packages/web/src/content/docs/fr/agents.mdx index b18d33539436..a6b323dfc858 100644 --- a/packages/web/src/content/docs/fr/agents.mdx +++ b/packages/web/src/content/docs/fr/agents.mdx @@ -36,13 +36,13 @@ Nous les verrons ci-dessous. Les sous-agents sont des assistants spécialisés que les agents primaires peuvent appeler pour des tâches spécifiques. Vous pouvez également les invoquer manuellement en **@ les mentionnant** dans vos messages. -OpenCode est livré avec deux sous-agents intégrés, **General** et **Explore**. Nous verrons cela ci-dessous. +OpenCode est livré avec trois sous-agents intégrés, **General**, **Explore** et **Scout**. Nous les verrons ci-dessous. --- ## Agents intégrés -OpenCode est livré avec deux agents primaires intégrés et deux sous-agents intégrés. +OpenCode est livré avec deux agents primaires intégrés et trois sous-agents intégrés. --- @@ -84,6 +84,14 @@ Un agent rapide en lecture seule pour explorer les bases de code. Impossible de --- +### Agent Scout + +_Mode_ : `subagent` + +Un agent en lecture seule pour la recherche sur la documentation externe et les dépendances. Utilisez-le lorsque vous devez cloner le dépôt d'une dépendance dans le cache géré d'OpenCode, inspecter le code source d'une bibliothèque ou recouper le code local avec les implémentations upstream sans modifier votre espace de travail. + +--- + ### Agent Compaction _Mode_ : `primary` diff --git a/packages/web/src/content/docs/it/agents.mdx b/packages/web/src/content/docs/it/agents.mdx index 4ecc9fc2a293..70aea575339e 100644 --- a/packages/web/src/content/docs/it/agents.mdx +++ b/packages/web/src/content/docs/it/agents.mdx @@ -35,13 +35,13 @@ OpenCode include due agenti primari integrati: **Build** e **Plan**. Li vediamo I subagenti sono assistenti specializzati che gli agenti primari possono invocare per task specifici. Puoi anche invocarli manualmente **menzionandoli con @** nei tuoi messaggi. -OpenCode include due subagenti integrati: **General** e **Explore**. Li vediamo sotto. +OpenCode include tre subagenti integrati: **General**, **Explore** e **Scout**. Li vediamo sotto. --- ## Integrati -OpenCode include due agenti primari integrati e due subagenti integrati. +OpenCode include due agenti primari integrati e tre subagenti integrati. --- @@ -83,6 +83,14 @@ Un agente rapido in sola lettura per esplorare codebase. Non può modificare fil --- +### Scout + +_Mode_: `subagent` + +Un agente in sola lettura per la ricerca su documentazione esterna e dipendenze. Usalo quando devi clonare il repository di una dipendenza nella cache gestita di OpenCode, ispezionare il codice sorgente di una libreria o confrontare il codice locale con implementazioni upstream senza modificare il tuo workspace. + +--- + ### Compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/ja/agents.mdx b/packages/web/src/content/docs/ja/agents.mdx index 879a43b057a9..539d30faf838 100644 --- a/packages/web/src/content/docs/ja/agents.mdx +++ b/packages/web/src/content/docs/ja/agents.mdx @@ -35,13 +35,13 @@ OpenCode には、**Build** と **Plan** という 2 つの組み込みプライ サブエージェントは、プライマリエージェントが特定のタスクのために呼び出すことができる特殊なアシスタントです。メッセージ内で **@ メンション**することで、手動で呼び出すこともできます。 -OpenCode には、**General** と **Explore** という 2 つの組み込みサブエージェントが付属しています。これについては以下で見ていきます。 +OpenCode には、**General**、**Explore**、**Scout** という 3 つの組み込みサブエージェントが付属しています。これについては以下で見ていきます。 --- ## 組み込み -OpenCode には、2 つの組み込みプライマリエージェントと 2 つの組み込みサブエージェントが付属しています。 +OpenCode には、2 つの組み込みプライマリエージェントと 3 つの組み込みサブエージェントが付属しています。 --- @@ -83,6 +83,14 @@ _モード_: `subagent` --- +### Scout + +_モード_: `subagent` + +外部ドキュメントや依存関係の調査を行うための読み取り専用エージェントです。依存関係のリポジトリを OpenCode の管理キャッシュにクローンしたいとき、ライブラリのソースコードを調べたいとき、あるいはワークスペースを変更せずにローカルコードを upstream の実装と突き合わせたいときに使用します。 + +--- + ### Compact _モード_: `primary` diff --git a/packages/web/src/content/docs/ko/agents.mdx b/packages/web/src/content/docs/ko/agents.mdx index 34de6250d1d3..02f31c5b6201 100644 --- a/packages/web/src/content/docs/ko/agents.mdx +++ b/packages/web/src/content/docs/ko/agents.mdx @@ -35,13 +35,13 @@ OpenCode에는 기본 제공 primary agent인 **Build**와 **Plan**이 포함되 subagent는 primary agent가 특정 작업을 위해 호출하는 전문 assistant입니다. 메시지에서 **@ mention**으로 직접 호출할 수도 있습니다. -OpenCode에는 기본 제공 subagent인 **General**과 **Explore**가 포함되어 있습니다. 아래에서 살펴보겠습니다. +OpenCode에는 기본 제공 subagent인 **General**, **Explore**, **Scout**가 포함되어 있습니다. 아래에서 살펴보겠습니다. --- ## 기본 제공 -OpenCode는 기본적으로 primary agent 2개와 subagent 2개를 제공합니다. +OpenCode는 기본적으로 primary agent 2개와 subagent 3개를 제공합니다. --- @@ -83,6 +83,14 @@ _Mode_: `subagent` --- +### Use Scout + +_Mode_: `subagent` + +외부 docs와 dependency 리서치를 위한 읽기 전용 agent입니다. dependency repository를 OpenCode의 관리형 cache에 clone하거나, 라이브러리 소스를 살펴보거나, workspace를 수정하지 않고 로컬 코드를 upstream 구현과 교차 확인해야 할 때 사용하세요. + +--- + ### Use compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/nb/agents.mdx b/packages/web/src/content/docs/nb/agents.mdx index d7831e3387db..f9971758d54a 100644 --- a/packages/web/src/content/docs/nb/agents.mdx +++ b/packages/web/src/content/docs/nb/agents.mdx @@ -35,13 +35,13 @@ OpenCode kommer med to innebygde primære agenter, **Build** og **Plan**. Vi ser Underagenter er spesialiserte assistenter som primære agenter kan påkalle for spesifikke oppgaver. Du kan også starte dem manuelt ved å **@ nevne** dem i meldingene dine. -OpenCode kommer med to innebygde underagenter, **General** og **Explore**. Vi skal se på dette nedenfor. +OpenCode kommer med tre innebygde underagenter, **General**, **Explore** og **Scout**. Vi skal se på dette nedenfor. --- ## Innebygd -OpenCode kommer med to innebygde primære agenter og to innebygde underagenter. +OpenCode kommer med to innebygde primære agenter og tre innebygde underagenter. --- @@ -83,6 +83,14 @@ En rask, skrivebeskyttet agent for å utforske kodebaser. Kan ikke endre filer. --- +### Bruk av Scout + +_Modus_: `subagent` + +En skrivebeskyttet agent for ekstern dokumentasjon og forskning på avhengigheter. Bruk denne når du trenger å klone et avhengighetsrepo inn i OpenCode sin administrerte cache, inspisere kildekoden til et bibliotek eller kryssjekke lokal kode mot upstream-implementasjoner uten å endre arbeidsområdet ditt. + +--- + ### Bruk av Compaction _Modus_: `primary` diff --git a/packages/web/src/content/docs/pl/agents.mdx b/packages/web/src/content/docs/pl/agents.mdx index 7a4d7a9960c0..8cf9561e16e7 100644 --- a/packages/web/src/content/docs/pl/agents.mdx +++ b/packages/web/src/content/docs/pl/agents.mdx @@ -35,13 +35,13 @@ OpenCode zawiera dwa wbudowane agenty główne: **Build** i **Plan**. Przyjrzymy Subagenci to asystenci pomocniczy, których mogą przywoływać agenci główni w celu wykonania konkretnych zadań. Możesz także wywoływać ich ręcznie, **wzmiankując ich (@)** w swoich wiadomościach. -OpenCode ma dwóch wbudowanych subagentów: **General** i **Explore**. Przyjrzymy się im poniżej. +OpenCode ma trzech wbudowanych subagentów: **General**, **Explore** i **Scout**. Przyjrzymy się im poniżej. --- ## Wbudowane -OpenCode ma dwa wbudowane agenty główne i dwa wbudowane subagenty. +OpenCode ma dwa wbudowane agenty główne i trzech wbudowanych subagentów. --- @@ -83,6 +83,14 @@ Szybki agent tylko do odczytu do eksploracji baz kodu. Nie może modyfikować pl --- +### Scout + +_Mode_: `subagent` + +Agent tylko do odczytu do pracy z zewnętrzną dokumentacją i badaniem zależności. Używaj go, gdy chcesz sklonować repozytorium zależności do zarządzanej pamięci podręcznej OpenCode, przejrzeć kod źródłowy biblioteki albo porównać lokalny kod z implementacjami upstream bez modyfikowania swojego workspace. + +--- + ### Compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/pt-br/agents.mdx b/packages/web/src/content/docs/pt-br/agents.mdx index 9a831e80489e..815264d840ce 100644 --- a/packages/web/src/content/docs/pt-br/agents.mdx +++ b/packages/web/src/content/docs/pt-br/agents.mdx @@ -36,13 +36,13 @@ ver isso abaixo. Subagentes são assistentes especializados que agentes primários podem invocar para tarefas específicas. Você também pode invocá-los manualmente mencionando-os com **@** em suas mensagens. -opencode vem com dois subagentes integrados, **General** e **Explore**. Vamos ver isso abaixo. +OpenCode vem com três subagentes integrados, **General**, **Explore** e **Scout**. Vamos ver isso abaixo. --- ## Integrados -opencode vem com dois agentes primários integrados e dois subagentes integrados. +OpenCode vem com dois agentes primários integrados e três subagentes integrados. --- @@ -84,6 +84,14 @@ Um agente rápido e somente leitura para explorar bases de código. Não pode mo --- +### Scout + +_Modo_: `subagent` + +Um agente somente leitura para pesquisa em documentação externa e dependências. Use-o quando você precisar clonar o repositório de uma dependência para o cache gerenciado do OpenCode, inspecionar o código-fonte de uma biblioteca ou cruzar o código local com implementações upstream sem modificar seu workspace. + +--- + ### compaction _Modo_: `primary` diff --git a/packages/web/src/content/docs/ru/agents.mdx b/packages/web/src/content/docs/ru/agents.mdx index f515c15d7bfc..767cbf862fe3 100644 --- a/packages/web/src/content/docs/ru/agents.mdx +++ b/packages/web/src/content/docs/ru/agents.mdx @@ -35,13 +35,13 @@ opencode поставляется с двумя встроенными осно Субагенты — это специализированные помощники, которых основные агенты могут вызывать для выполнения определенных задач. Вы также можете вызвать их вручную, **@ упомянув** их в своих сообщениях. -opencode поставляется с двумя встроенными субагентами: **General** и **Explore**. Мы рассмотрим это ниже. +OpenCode поставляется с тремя встроенными субагентами: **General**, **Explore** и **Scout**. Мы рассмотрим их ниже. --- ## Встроенные агенты -opencode поставляется с двумя встроенными основными агентами и двумя встроенными субагентами. +OpenCode поставляется с двумя встроенными основными агентами и тремя встроенными субагентами. --- @@ -83,6 +83,14 @@ _Режим_: `subagent` --- +### Использование Scout + +_Режим_: `subagent` + +Агент только для чтения для работы с внешней документацией и исследования зависимостей. Используйте его, когда нужно клонировать репозиторий зависимости в управляемый кэш OpenCode, изучить исходный код библиотеки или сверить локальный код с upstream-реализациями без изменений в рабочем пространстве. + +--- + ### Использование Compact _Режим_: `primary` diff --git a/packages/web/src/content/docs/th/agents.mdx b/packages/web/src/content/docs/th/agents.mdx index 567125aced0b..e37df6ce475e 100644 --- a/packages/web/src/content/docs/th/agents.mdx +++ b/packages/web/src/content/docs/th/agents.mdx @@ -36,13 +36,13 @@ OpenCode มีเอเจนต์หลักในตัวได้แก Subagent คือผู้ช่วยเฉพาะทางที่ Primary Agent สามารถเรียกใช้งานได้ หรือคุณสามารถเรียกใช้โดยตรงโดยพิมพ์ **@** ตามด้วยชื่อเอเจนต์ในข้อความของคุณ -OpenCode มี subagent ในตัวได้แก่ **General** และ **Explore** +OpenCode มี subagent ในตัวได้แก่ **General**, **Explore** และ **Scout** ดูรายละเอียดด้านล่าง --- ## บิวท์อิน -OpenCode มาพร้อมกับเอเจนต์หลักและ subagent ในตัวดังนี้ +OpenCode มาพร้อมกับเอเจนต์หลัก 2 ตัวและ subagent ในตัว 3 ตัว --- @@ -84,6 +84,14 @@ _Mode_: `subagent` --- +### Scout + +_Mode_: `subagent` + +เอเจนต์แบบอ่านอย่างเดียวสำหรับค้นคว้าเอกสารภายนอกและ dependency ใช้สิ่งนี้เมื่อคุณต้องการ clone repository ของ dependency เข้าไปใน cache ที่ OpenCode จัดการให้, ตรวจสอบ source code ของไลบรารี, หรือเทียบโค้ดในเครื่องกับ implementation จาก upstream โดยไม่แก้ไข workspace ของคุณ + +--- + ### Compact _Mode_: `primary` diff --git a/packages/web/src/content/docs/tr/agents.mdx b/packages/web/src/content/docs/tr/agents.mdx index 1f582511be09..c523b2b3bf94 100644 --- a/packages/web/src/content/docs/tr/agents.mdx +++ b/packages/web/src/content/docs/tr/agents.mdx @@ -35,13 +35,13 @@ opencode, **Build** ve **Plan** olmak üzere iki yerleşik birincil agent ile bi Alt agent'lar, birincil agent'ların belirli görevler için çağırabileceği uzman yardımcılardır. Ayrıca mesajlarınızda **@ bahsederek** bunları manuel olarak da çağırabilirsiniz. -opencode, **General** ve **Explore** olmak üzere iki yerleşik alt agent ile birlikte gelir. Buna aşağıda bakacağız. +OpenCode, **General**, **Explore** ve **Scout** olmak üzere üç yerleşik alt agent ile birlikte gelir. Buna aşağıda bakacağız. --- ## Yerleşik -opencode iki yerleşik birincil agent ve iki yerleşik alt agent ile birlikte gelir. +OpenCode iki yerleşik birincil agent ve üç yerleşik alt agent ile birlikte gelir. --- @@ -83,6 +83,14 @@ Kod tabanlarını keşfetmeye yönelik hızlı, salt okunur bir agent. Dosyalar --- +### Scout Kullanımı + +_Mod_: `subagent` + +Harici dokümanlar ve bağımlılık araştırmaları için salt okunur bir agent. Bir bağımlılık repository'sini OpenCode'un yönetilen cache'ine clone etmeniz, kütüphane kaynak kodunu incelemeniz veya workspace'inizi değiştirmeden yerel kodu upstream implementasyonlarla karşılaştırmanız gerektiğinde bunu kullanın. + +--- + ### Compaction Kullanımı _Mod_: `primary` diff --git a/packages/web/src/content/docs/zh-cn/agents.mdx b/packages/web/src/content/docs/zh-cn/agents.mdx index 2087c683668a..6f821ff7f869 100644 --- a/packages/web/src/content/docs/zh-cn/agents.mdx +++ b/packages/web/src/content/docs/zh-cn/agents.mdx @@ -35,13 +35,13 @@ OpenCode 内置了两个主代理:**Build** 和 **Plan**。我们将在下面 子代理是主代理可以调用来执行特定任务的专业助手。您也可以通过在消息中 **@ 提及**它们来手动调用。 -OpenCode 内置了两个子代理:**General** 和 **Explore**。我们将在下面介绍它们。 +OpenCode 内置了三个子代理:**General**、**Explore** 和 **Scout**。我们将在下面介绍它们。 --- ## 内置代理 -OpenCode 内置了两个主代理和两个子代理。 +OpenCode 内置了两个主代理和三个子代理。 --- @@ -83,6 +83,14 @@ _模式_:`subagent` --- +### 使用 Scout + +_模式_:`subagent` + +一个用于外部文档和依赖研究的只读代理。当您需要将某个依赖仓库克隆到 OpenCode 的托管缓存中、检查库的源代码,或在不修改工作区的情况下将本地代码与 upstream 实现进行交叉对照时,请使用此代理。 + +--- + ### 使用 Compaction _模式_:`primary` diff --git a/packages/web/src/content/docs/zh-tw/agents.mdx b/packages/web/src/content/docs/zh-tw/agents.mdx index fa8f10254348..a9c7bbadbf23 100644 --- a/packages/web/src/content/docs/zh-tw/agents.mdx +++ b/packages/web/src/content/docs/zh-tw/agents.mdx @@ -35,13 +35,13 @@ OpenCode 內建了兩個主代理:**Build** 和 **Plan**。我們將在下面 子代理是主代理可以呼叫來執行特定任務的專業助手。您也可以透過在訊息中 **@ 提及**它們來手動呼叫。 -OpenCode 內建了兩個子代理:**General** 和 **Explore**。我們將在下面介紹它們。 +OpenCode 內建了三個子代理:**General**、**Explore** 和 **Scout**。我們將在下面介紹它們。 --- ## 內建代理 -OpenCode 內建了兩個主代理和兩個子代理。 +OpenCode 內建了兩個主代理和三個子代理。 --- @@ -83,6 +83,14 @@ _模式_:`subagent` --- +### 使用 Scout + +_模式_:`subagent` + +一個用於外部文件與依賴研究的唯讀代理。當您需要將某個依賴儲存庫 clone 到 OpenCode 的託管快取中、檢查函式庫的原始碼,或在不修改工作區的情況下將本機程式碼與 upstream 實作交叉比對時,請使用此代理。 + +--- + ### 使用 Compaction _模式_:`primary` From 343e68853c2d4611ebb986997d8032cfec069cdd Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 16:34:45 +0530 Subject: [PATCH 3/6] fix(scout): type repo tool definitions --- packages/opencode/src/tool/repo_clone.ts | 16 +++++++++++++--- packages/opencode/src/tool/repo_overview.ts | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts index 0b22ae6432e3..108890c99834 100644 --- a/packages/opencode/src/tool/repo_clone.ts +++ b/packages/opencode/src/tool/repo_clone.ts @@ -15,6 +15,16 @@ const parameters = z.object({ refresh: z.boolean().optional().describe("When true, fetches the latest remote state into the managed cache"), }) +type Metadata = { + repository: string + host: string + remote: string + localPath: string + status: "cached" | "cloned" | "refreshed" + head?: string + branch?: string +} + function statusForRepository(input: { reuse: boolean; refresh?: boolean }) { if (!input.reuse) return "cloned" as const if (input.refresh) return "refreshed" as const @@ -34,7 +44,7 @@ function resetTarget(input: { return "HEAD" } -export const RepoCloneTool = Tool.define( +export const RepoCloneTool = Tool.define( "repo_clone", Effect.gen(function* () { const fs = yield* AppFileSystem.Service @@ -43,7 +53,7 @@ export const RepoCloneTool = Tool.define( return { description: DESCRIPTION, parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: z.infer, 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") @@ -137,6 +147,6 @@ export const RepoCloneTool = Tool.define( (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), ) }).pipe(Effect.orDie), - } + } satisfies Tool.DefWithoutID }), ) diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts index 650bc352f1f7..77f65e3488e2 100644 --- a/packages/opencode/src/tool/repo_overview.ts +++ b/packages/opencode/src/tool/repo_overview.ts @@ -147,7 +147,7 @@ export const RepoOverviewTool = Tool.define, ctx: Tool.Context) => + execute: (params: z.infer, ctx: Tool.Context) => Effect.gen(function* () { const target = yield* resolveTarget(params) const depth = params.depth ?? 3 @@ -233,6 +233,6 @@ export const RepoOverviewTool = Tool.define }), ) From 35a19df57d670dd778b6420897eec388b2ad88a0 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 16:38:37 +0530 Subject: [PATCH 4/6] fix(scout): widen repo tool schema types --- packages/opencode/src/tool/repo_clone.ts | 9 +++++++-- packages/opencode/src/tool/repo_overview.ts | 12 +++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts index 108890c99834..1d3c80a41396 100644 --- a/packages/opencode/src/tool/repo_clone.ts +++ b/packages/opencode/src/tool/repo_clone.ts @@ -8,7 +8,12 @@ import DESCRIPTION from "./repo_clone.txt" import * as Tool from "./tool" import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository" -const parameters = z.object({ +type Parameters = { + repository: string + refresh?: boolean +} + +const parameters: z.ZodType = z.object({ repository: z .string() .describe("Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand"), @@ -53,7 +58,7 @@ export const RepoCloneTool = Tool.define, ctx: Tool.Context) => + execute: (params: 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") diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts index 77f65e3488e2..f991a2e0fa78 100644 --- a/packages/opencode/src/tool/repo_overview.ts +++ b/packages/opencode/src/tool/repo_overview.ts @@ -9,7 +9,13 @@ import * as Tool from "./tool" import { parseRepositoryReference, repositoryCachePath } from "@/util/repository" import { Instance } from "@/project/instance" -const parameters = z +type Parameters = { + repository?: string + path?: string + depth?: number +} + +const parameters: z.ZodType = z .object({ repository: z .string() @@ -84,7 +90,7 @@ export const RepoOverviewTool = Tool.define) { + const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* (params: Parameters) { if (params.path) { const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path) return { path: full, repository: params.repository } @@ -147,7 +153,7 @@ export const RepoOverviewTool = Tool.define, ctx: Tool.Context) => + execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const target = yield* resolveTarget(params) const depth = params.depth ?? 3 From c750df3e86d180c3f6b3fcd936d9133e878166d8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 18:58:28 +0530 Subject: [PATCH 5/6] fix(scout): use effect schema tool params --- packages/opencode/src/tool/repo_clone.ts | 28 ++++++------- packages/opencode/src/tool/repo_overview.ts | 46 +++++++++------------ 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts index 1d3c80a41396..41fd2c5fd1da 100644 --- a/packages/opencode/src/tool/repo_clone.ts +++ b/packages/opencode/src/tool/repo_clone.ts @@ -1,6 +1,5 @@ import path from "path" -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Flock } from "@opencode-ai/shared/util/flock" import { Git } from "@/git" @@ -8,16 +7,13 @@ import DESCRIPTION from "./repo_clone.txt" import * as Tool from "./tool" import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository" -type Parameters = { - repository: string - refresh?: boolean -} - -const parameters: z.ZodType = 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"), +export const Parameters = Schema.Struct({ + repository: Schema.String.annotate({ + description: "Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand", + }), + refresh: Schema.optional(Schema.Boolean).annotate({ + description: "When true, fetches the latest remote state into the managed cache", + }), }) type Metadata = { @@ -49,7 +45,7 @@ function resetTarget(input: { return "HEAD" } -export const RepoCloneTool = Tool.define( +export const RepoCloneTool = Tool.define( "repo_clone", Effect.gen(function* () { const fs = yield* AppFileSystem.Service @@ -57,8 +53,8 @@ export const RepoCloneTool = Tool.define) => + parameters: Parameters, + execute: (params: Schema.Schema.Type, 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") @@ -152,6 +148,6 @@ export const RepoCloneTool = Tool.define Effect.promise(() => lock.release()).pipe(Effect.ignore), ) }).pipe(Effect.orDie), - } satisfies Tool.DefWithoutID + } satisfies Tool.DefWithoutID }), ) diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts index f991a2e0fa78..fcdd1e7da39e 100644 --- a/packages/opencode/src/tool/repo_overview.ts +++ b/packages/opencode/src/tool/repo_overview.ts @@ -1,6 +1,5 @@ import path from "path" -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Git } from "@/git" import { assertExternalDirectoryEffect } from "./external-directory" @@ -9,24 +8,17 @@ import * as Tool from "./tool" import { parseRepositoryReference, repositoryCachePath } from "@/util/repository" import { Instance } from "@/project/instance" -type Parameters = { - repository?: string - path?: string - depth?: number -} - -const parameters: z.ZodType = 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", +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 @@ -84,19 +76,21 @@ function commonEntrypoints(files: Set) { 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( +export const RepoOverviewTool = Tool.define( "repo_overview", Effect.gen(function* () { const fs = yield* AppFileSystem.Service const git = yield* Git.Service - const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* (params: Parameters) { + const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* (params: Schema.Schema.Type) { 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 (!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 @@ -152,11 +146,11 @@ export const RepoOverviewTool = Tool.define) => + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const target = yield* resolveTarget(params) - const depth = params.depth ?? 3 + 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({ @@ -239,6 +233,6 @@ export const RepoOverviewTool = Tool.define + } satisfies Tool.DefWithoutID }), ) From b633a8b1c828988ac350e7c3aa9299c6cf7d7227 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 19:03:56 +0530 Subject: [PATCH 6/6] refactor(scout): fold github remote parsing into repository --- packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/util/github-remote.ts | 34 --------------------- packages/opencode/src/util/repository.ts | 9 ++++++ 3 files changed, 10 insertions(+), 35 deletions(-) delete mode 100644 packages/opencode/src/util/github-remote.ts diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index c44b58d6a44e..898b7331286f 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -33,7 +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 { parseGitHubRemote } from "@/util/repository" import { Effect } from "effect" type GitHubAuthor = { diff --git a/packages/opencode/src/util/github-remote.ts b/packages/opencode/src/util/github-remote.ts deleted file mode 100644 index fc30e2cfcfde..000000000000 --- a/packages/opencode/src/util/github-remote.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 -} diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts index f9ffb0e49cd4..30c680b4ee2b 100644 --- a/packages/opencode/src/util/repository.ts +++ b/packages/opencode/src/util/repository.ts @@ -88,6 +88,15 @@ export function parseRepositoryReference(input: string) { } } +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) }