From 1e0246cdc81c58d6ef533e928b047ea604f47eaf Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Apr 2026 16:29:19 +0530 Subject: [PATCH] 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 672b93f6ce..6449e1b02c 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 355718b6bf..60e9c72ee2 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 0000000000..c315cc5a6b --- /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 fe8e233dd1..c44b58d6a4 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 5423ba3baf..032007aa71 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 fdd5746837..73b21cbc53 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 27bac598fb..998d047fd3 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 0211e33bcb..2dfea58f2d 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 0000000000..0b22ae6432 --- /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 0000000000..7944015506 --- /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 0000000000..650bc352f1 --- /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 0000000000..2109838746 --- /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 0000000000..fc30e2cfcf --- /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 0000000000..f9ffb0e49c --- /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 50a3668f98..dee53a9212 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 80102d986e..ed37b92d41 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 8ffb20f154..f0eb23d0f0 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 6517547339..b1518eb1f0 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 0000000000..bcc855843d --- /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 0000000000..b114659a22 --- /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") + }), + ), + ) +})