feat(scout): add repo research tools
This commit is contained in:
parent
aed03078f8
commit
1e0246cdc8
20 changed files with 1004 additions and 16 deletions
|
|
@ -1561,6 +1561,8 @@ function toToolKind(toolName: string): ToolKind {
|
||||||
|
|
||||||
case "grep":
|
case "grep":
|
||||||
case "glob":
|
case "glob":
|
||||||
|
case "repo_clone":
|
||||||
|
case "repo_overview":
|
||||||
case "context7_resolve_library_id":
|
case "context7_resolve_library_id":
|
||||||
case "context7_get_library_docs":
|
case "context7_get_library_docs":
|
||||||
return "search"
|
return "search"
|
||||||
|
|
@ -1583,6 +1585,10 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
|
||||||
case "glob":
|
case "glob":
|
||||||
case "grep":
|
case "grep":
|
||||||
return input["path"] ? [{ path: input["path"] }] : []
|
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":
|
case "bash":
|
||||||
return []
|
return []
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { ProviderTransform } from "../provider"
|
||||||
import PROMPT_GENERATE from "./generate.txt"
|
import PROMPT_GENERATE from "./generate.txt"
|
||||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||||
|
import PROMPT_SCOUT from "./prompt/scout.txt"
|
||||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||||
import PROMPT_TITLE from "./prompt/title.txt"
|
import PROMPT_TITLE from "./prompt/title.txt"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
|
|
@ -83,6 +84,10 @@ export const layer = Layer.effect(
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
const skillDirs = yield* skill.dirs()
|
const skillDirs = yield* skill.dirs()
|
||||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||||
|
const readonlyExternalDirectory = {
|
||||||
|
"*": "ask",
|
||||||
|
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||||
|
} satisfies Record<string, "allow" | "ask" | "deny">
|
||||||
|
|
||||||
const defaults = Permission.fromConfig({
|
const defaults = Permission.fromConfig({
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
|
|
@ -94,6 +99,8 @@ export const layer = Layer.effect(
|
||||||
question: "deny",
|
question: "deny",
|
||||||
plan_enter: "deny",
|
plan_enter: "deny",
|
||||||
plan_exit: "deny",
|
plan_exit: "deny",
|
||||||
|
repo_clone: "deny",
|
||||||
|
repo_overview: "deny",
|
||||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||||
read: {
|
read: {
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
|
|
@ -172,10 +179,7 @@ export const layer = Layer.effect(
|
||||||
websearch: "allow",
|
websearch: "allow",
|
||||||
codesearch: "allow",
|
codesearch: "allow",
|
||||||
read: "allow",
|
read: "allow",
|
||||||
external_directory: {
|
external_directory: readonlyExternalDirectory,
|
||||||
"*": "ask",
|
|
||||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
),
|
),
|
||||||
|
|
@ -185,6 +189,33 @@ export const layer = Layer.effect(
|
||||||
mode: "subagent",
|
mode: "subagent",
|
||||||
native: true,
|
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: {
|
compaction: {
|
||||||
name: "compaction",
|
name: "compaction",
|
||||||
mode: "primary",
|
mode: "primary",
|
||||||
|
|
|
||||||
36
packages/opencode/src/agent/prompt/scout.txt
Normal file
36
packages/opencode/src/agent/prompt/scout.txt
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
You are `scout`, a read-only research agent for external libraries, dependency source, and documentation.
|
||||||
|
|
||||||
|
Your purpose is to investigate code outside the local workspace and return evidence-backed findings without modifying the user's workspace.
|
||||||
|
|
||||||
|
Use this agent when asked to:
|
||||||
|
- inspect dependency repositories or library source
|
||||||
|
- compare local code against upstream implementations
|
||||||
|
- research public GitHub repositories the environment can clone
|
||||||
|
- explain how a library or framework works by reading its source and docs
|
||||||
|
- investigate third-party APIs, workflows, or behavior outside the current workspace
|
||||||
|
|
||||||
|
Working style:
|
||||||
|
1. When the task involves a GitHub repository or dependency source, use `repo_clone` first.
|
||||||
|
2. After cloning, use `Glob`, `Grep`, and `Read` to inspect the cloned repository.
|
||||||
|
3. Use `WebFetch` for official documentation pages when source alone is not enough.
|
||||||
|
4. Prefer direct code and documentation evidence over assumptions.
|
||||||
|
5. If multiple external repositories are relevant, inspect each one before drawing conclusions.
|
||||||
|
|
||||||
|
Research standards:
|
||||||
|
- cite exact absolute file paths and line references whenever possible
|
||||||
|
- separate what is verified from what is inferred
|
||||||
|
- if the answer depends on branch state, note that you are reading the repository's current default clone state unless the caller specifies otherwise
|
||||||
|
- if a repository cannot be cloned or accessed, say so explicitly and continue with whatever evidence is still available
|
||||||
|
- call out uncertainty clearly instead of smoothing over gaps
|
||||||
|
|
||||||
|
Output expectations:
|
||||||
|
- start with the direct answer
|
||||||
|
- then explain the evidence repository by repository or source by source
|
||||||
|
- include file references when relevant
|
||||||
|
- keep the explanation organized and easy to scan
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- do not modify files or run tools that change the user's workspace
|
||||||
|
- return absolute file paths for cloned-repo findings in your final response
|
||||||
|
|
||||||
|
Complete the user's research request efficiently and report your findings clearly.
|
||||||
|
|
@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { Process } from "@/util"
|
import { Process } from "@/util"
|
||||||
|
import { parseGitHubRemote } from "@/util/github-remote"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
|
||||||
type GitHubAuthor = {
|
type GitHubAuthor = {
|
||||||
|
|
@ -152,18 +153,7 @@ const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const
|
||||||
type UserEvent = (typeof USER_EVENTS)[number]
|
type UserEvent = (typeof USER_EVENTS)[number]
|
||||||
type RepoEvent = (typeof REPO_EVENTS)[number]
|
type RepoEvent = (typeof REPO_EVENTS)[number]
|
||||||
|
|
||||||
// Parses GitHub remote URLs in various formats:
|
export { parseGitHubRemote }
|
||||||
// - 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] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts displayable text from assistant response parts.
|
* Extracts displayable text from assistant response parts.
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,7 @@ export const Info = Schema.Struct({
|
||||||
// subagent
|
// subagent
|
||||||
general: Schema.optional(AgentRef),
|
general: Schema.optional(AgentRef),
|
||||||
explore: Schema.optional(AgentRef),
|
explore: Schema.optional(AgentRef),
|
||||||
|
scout: Schema.optional(AgentRef),
|
||||||
// specialized
|
// specialized
|
||||||
title: Schema.optional(AgentRef),
|
title: Schema.optional(AgentRef),
|
||||||
summary: Schema.optional(AgentRef),
|
summary: Schema.optional(AgentRef),
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ const InputObject = Schema.StructWithRest(
|
||||||
webfetch: Schema.optional(Action),
|
webfetch: Schema.optional(Action),
|
||||||
websearch: Schema.optional(Action),
|
websearch: Schema.optional(Action),
|
||||||
codesearch: Schema.optional(Action),
|
codesearch: Schema.optional(Action),
|
||||||
|
repo_clone: Schema.optional(Rule),
|
||||||
|
repo_overview: Schema.optional(Rule),
|
||||||
lsp: Schema.optional(Rule),
|
lsp: Schema.optional(Rule),
|
||||||
doom_loop: Schema.optional(Action),
|
doom_loop: Schema.optional(Action),
|
||||||
skill: Schema.optional(Rule),
|
skill: Schema.optional(Rule),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export const Path = {
|
||||||
data,
|
data,
|
||||||
bin: path.join(cache, "bin"),
|
bin: path.join(cache, "bin"),
|
||||||
log: path.join(data, "log"),
|
log: path.join(data, "log"),
|
||||||
|
repos: path.join(data, "repos"),
|
||||||
cache,
|
cache,
|
||||||
config,
|
config,
|
||||||
state,
|
state,
|
||||||
|
|
@ -34,6 +35,7 @@ await Promise.all([
|
||||||
fs.mkdir(Path.state, { recursive: true }),
|
fs.mkdir(Path.state, { recursive: true }),
|
||||||
fs.mkdir(Path.log, { recursive: true }),
|
fs.mkdir(Path.log, { recursive: true }),
|
||||||
fs.mkdir(Path.bin, { recursive: true }),
|
fs.mkdir(Path.bin, { recursive: true }),
|
||||||
|
fs.mkdir(Path.repos, { recursive: true }),
|
||||||
])
|
])
|
||||||
|
|
||||||
const CACHE_VERSION = "21"
|
const CACHE_VERSION = "21"
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import { Provider } from "../provider"
|
||||||
import { ProviderID, type ModelID } from "../provider/schema"
|
import { ProviderID, type ModelID } from "../provider/schema"
|
||||||
import { WebSearchTool } from "./websearch"
|
import { WebSearchTool } from "./websearch"
|
||||||
import { CodeSearchTool } from "./codesearch"
|
import { CodeSearchTool } from "./codesearch"
|
||||||
|
import { RepoCloneTool } from "./repo_clone"
|
||||||
|
import { RepoOverviewTool } from "./repo_overview"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Log } from "@/util"
|
import { Log } from "@/util"
|
||||||
import { LspTool } from "./lsp"
|
import { LspTool } from "./lsp"
|
||||||
|
|
@ -43,6 +45,7 @@ import { Instruction } from "../session/instruction"
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
|
import { Git } from "@/git"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
|
|
||||||
|
|
@ -78,6 +81,7 @@ export const layer: Layer.Layer<
|
||||||
| Skill.Service
|
| Skill.Service
|
||||||
| Session.Service
|
| Session.Service
|
||||||
| Provider.Service
|
| Provider.Service
|
||||||
|
| Git.Service
|
||||||
| LSP.Service
|
| LSP.Service
|
||||||
| Instruction.Service
|
| Instruction.Service
|
||||||
| AppFileSystem.Service
|
| AppFileSystem.Service
|
||||||
|
|
@ -107,6 +111,8 @@ export const layer: Layer.Layer<
|
||||||
const websearch = yield* WebSearchTool
|
const websearch = yield* WebSearchTool
|
||||||
const bash = yield* BashTool
|
const bash = yield* BashTool
|
||||||
const codesearch = yield* CodeSearchTool
|
const codesearch = yield* CodeSearchTool
|
||||||
|
const repoClone = yield* RepoCloneTool
|
||||||
|
const repoOverview = yield* RepoOverviewTool
|
||||||
const globtool = yield* GlobTool
|
const globtool = yield* GlobTool
|
||||||
const writetool = yield* WriteTool
|
const writetool = yield* WriteTool
|
||||||
const edit = yield* EditTool
|
const edit = yield* EditTool
|
||||||
|
|
@ -189,6 +195,8 @@ export const layer: Layer.Layer<
|
||||||
todo: Tool.init(todo),
|
todo: Tool.init(todo),
|
||||||
search: Tool.init(websearch),
|
search: Tool.init(websearch),
|
||||||
code: Tool.init(codesearch),
|
code: Tool.init(codesearch),
|
||||||
|
repo_clone: Tool.init(repoClone),
|
||||||
|
repo_overview: Tool.init(repoOverview),
|
||||||
skill: Tool.init(skilltool),
|
skill: Tool.init(skilltool),
|
||||||
patch: Tool.init(patchtool),
|
patch: Tool.init(patchtool),
|
||||||
question: Tool.init(question),
|
question: Tool.init(question),
|
||||||
|
|
@ -212,6 +220,8 @@ export const layer: Layer.Layer<
|
||||||
tool.todo,
|
tool.todo,
|
||||||
tool.search,
|
tool.search,
|
||||||
tool.code,
|
tool.code,
|
||||||
|
tool.repo_clone,
|
||||||
|
tool.repo_overview,
|
||||||
tool.skill,
|
tool.skill,
|
||||||
tool.patch,
|
tool.patch,
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
|
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
|
||||||
|
|
@ -326,6 +336,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||||
Layer.provide(Agent.defaultLayer),
|
Layer.provide(Agent.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(LSP.defaultLayer),
|
Layer.provide(LSP.defaultLayer),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
|
|
||||||
142
packages/opencode/src/tool/repo_clone.ts
Normal file
142
packages/opencode/src/tool/repo_clone.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { Flock } from "@opencode-ai/shared/util/flock"
|
||||||
|
import { Git } from "@/git"
|
||||||
|
import DESCRIPTION from "./repo_clone.txt"
|
||||||
|
import * as Tool from "./tool"
|
||||||
|
import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository"
|
||||||
|
|
||||||
|
const parameters = z.object({
|
||||||
|
repository: z
|
||||||
|
.string()
|
||||||
|
.describe("Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand"),
|
||||||
|
refresh: z.boolean().optional().describe("When true, fetches the latest remote state into the managed cache"),
|
||||||
|
})
|
||||||
|
|
||||||
|
function statusForRepository(input: { reuse: boolean; refresh?: boolean }) {
|
||||||
|
if (!input.reuse) return "cloned" as const
|
||||||
|
if (input.refresh) return "refreshed" as const
|
||||||
|
return "cached" as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTarget(input: {
|
||||||
|
remoteHead: { code: number; stdout: string }
|
||||||
|
branch: { code: number; stdout: string }
|
||||||
|
}) {
|
||||||
|
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
|
||||||
|
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
|
||||||
|
}
|
||||||
|
if (input.branch.code === 0 && input.branch.stdout) {
|
||||||
|
return `origin/${input.branch.stdout}`
|
||||||
|
}
|
||||||
|
return "HEAD"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RepoCloneTool = Tool.define(
|
||||||
|
"repo_clone",
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const git = yield* Git.Service
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters,
|
||||||
|
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const reference = parseRepositoryReference(params.repository)
|
||||||
|
if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
|
||||||
|
|
||||||
|
const repository = reference.label
|
||||||
|
const remote = reference.remote
|
||||||
|
const localPath = repositoryCachePath(reference)
|
||||||
|
const cloneTarget = parseRepositoryReference(remote) ?? reference
|
||||||
|
|
||||||
|
yield* ctx.ask({
|
||||||
|
permission: "repo_clone",
|
||||||
|
patterns: [repository],
|
||||||
|
always: [repository],
|
||||||
|
metadata: {
|
||||||
|
repository,
|
||||||
|
remote,
|
||||||
|
path: localPath,
|
||||||
|
refresh: Boolean(params.refresh),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return yield* Effect.acquireUseRelease(
|
||||||
|
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
|
||||||
|
|
||||||
|
const exists = yield* fs.existsSafe(localPath)
|
||||||
|
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
|
||||||
|
const origin = hasGitDir
|
||||||
|
? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
|
||||||
|
: undefined
|
||||||
|
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
|
||||||
|
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
|
||||||
|
if (exists && !reuse) {
|
||||||
|
yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = statusForRepository({ reuse, refresh: params.refresh })
|
||||||
|
|
||||||
|
if (status === "cloned") {
|
||||||
|
const clone = yield* git.run(["clone", "--depth", "100", remote, localPath], { cwd: path.dirname(localPath) })
|
||||||
|
if (clone.exitCode !== 0) {
|
||||||
|
throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "refreshed") {
|
||||||
|
const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath })
|
||||||
|
if (fetch.exitCode !== 0) {
|
||||||
|
throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
|
||||||
|
const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
|
||||||
|
const target = resetTarget({
|
||||||
|
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
|
||||||
|
branch: { code: branch.exitCode, stdout: branch.text().trim() },
|
||||||
|
})
|
||||||
|
|
||||||
|
const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath })
|
||||||
|
if (reset.exitCode !== 0) {
|
||||||
|
throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath })
|
||||||
|
const branch = yield* git.branch(localPath)
|
||||||
|
const headText = head.exitCode === 0 ? head.text().trim() : undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: repository,
|
||||||
|
metadata: {
|
||||||
|
repository,
|
||||||
|
host: reference.host,
|
||||||
|
remote,
|
||||||
|
localPath,
|
||||||
|
status,
|
||||||
|
head: headText,
|
||||||
|
branch,
|
||||||
|
},
|
||||||
|
output: [
|
||||||
|
`Repository ready: ${repository}`,
|
||||||
|
`Status: ${status}`,
|
||||||
|
`Local path: ${localPath}`,
|
||||||
|
...(branch ? [`Branch: ${branch}`] : []),
|
||||||
|
...(headText ? [`HEAD: ${headText}`] : []),
|
||||||
|
].join("\n"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
|
||||||
|
)
|
||||||
|
}).pipe(Effect.orDie),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
5
packages/opencode/src/tool/repo_clone.txt
Normal file
5
packages/opencode/src/tool/repo_clone.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
- Clone or refresh a repository into OpenCode's managed cache under the data directory
|
||||||
|
- Accepts git URLs, forge host/path references, or GitHub owner/repo shorthand
|
||||||
|
- Returns the cached absolute local path so other tools can explore the cloned source
|
||||||
|
- Use this before Read, Glob, or Grep when the code you need lives outside the current workspace
|
||||||
|
- This tool is intended for dependency and documentation research workflows, not for modifying the user's workspace
|
||||||
238
packages/opencode/src/tool/repo_overview.ts
Normal file
238
packages/opencode/src/tool/repo_overview.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { Git } from "@/git"
|
||||||
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
|
import DESCRIPTION from "./repo_overview.txt"
|
||||||
|
import * as Tool from "./tool"
|
||||||
|
import { parseRepositoryReference, repositoryCachePath } from "@/util/repository"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
|
||||||
|
const parameters = z
|
||||||
|
.object({
|
||||||
|
repository: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Cached repository to inspect, as a git URL, host/path reference, or GitHub owner/repo shorthand"),
|
||||||
|
path: z.string().optional().describe("Directory path to inspect instead of a cached repository"),
|
||||||
|
depth: z.number().int().positive().max(6).optional().describe("Maximum structure depth to include. Defaults to 3."),
|
||||||
|
})
|
||||||
|
.refine((input) => Boolean(input.repository || input.path), {
|
||||||
|
message: "Either repository or path is required",
|
||||||
|
})
|
||||||
|
|
||||||
|
type Metadata = {
|
||||||
|
path: string
|
||||||
|
repository?: string
|
||||||
|
branch?: string
|
||||||
|
head?: string
|
||||||
|
package_manager?: string
|
||||||
|
ecosystems: string[]
|
||||||
|
dependency_files: string[]
|
||||||
|
entrypoints: string[]
|
||||||
|
depth: number
|
||||||
|
truncated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const IGNORED_DIRS = new Set([".git", "node_modules", "__pycache__", ".venv", "dist", "build", ".next", "target", "vendor"])
|
||||||
|
const STRUCTURE_LIMIT = 200
|
||||||
|
const DEPENDENCY_FILES = [
|
||||||
|
"package.json",
|
||||||
|
"package-lock.json",
|
||||||
|
"bun.lock",
|
||||||
|
"bun.lockb",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
"yarn.lock",
|
||||||
|
"requirements.txt",
|
||||||
|
"pyproject.toml",
|
||||||
|
"go.mod",
|
||||||
|
"Cargo.toml",
|
||||||
|
"Gemfile",
|
||||||
|
"build.gradle",
|
||||||
|
"build.gradle.kts",
|
||||||
|
"pom.xml",
|
||||||
|
"composer.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
function packageManager(files: Set<string>) {
|
||||||
|
if (files.has("bun.lock") || files.has("bun.lockb")) return "bun"
|
||||||
|
if (files.has("pnpm-lock.yaml")) return "pnpm"
|
||||||
|
if (files.has("yarn.lock")) return "yarn"
|
||||||
|
if (files.has("package-lock.json")) return "npm"
|
||||||
|
}
|
||||||
|
|
||||||
|
function ecosystems(files: Set<string>) {
|
||||||
|
return [
|
||||||
|
...(files.has("package.json") ? ["Node.js"] : []),
|
||||||
|
...(files.has("pyproject.toml") || files.has("requirements.txt") ? ["Python"] : []),
|
||||||
|
...(files.has("go.mod") ? ["Go"] : []),
|
||||||
|
...(files.has("Cargo.toml") ? ["Rust"] : []),
|
||||||
|
...(files.has("Gemfile") ? ["Ruby"] : []),
|
||||||
|
...(files.has("build.gradle") || files.has("build.gradle.kts") || files.has("pom.xml") ? ["Java/Kotlin"] : []),
|
||||||
|
...(files.has("composer.json") ? ["PHP"] : []),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonEntrypoints(files: Set<string>) {
|
||||||
|
return ["index.ts", "index.tsx", "index.js", "index.mjs", "main.ts", "main.js", "src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"].filter((file) => files.has(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RepoOverviewTool = Tool.define<typeof parameters, Metadata, AppFileSystem.Service | Git.Service>(
|
||||||
|
"repo_overview",
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const git = yield* Git.Service
|
||||||
|
|
||||||
|
const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* (params: z.infer<typeof parameters>) {
|
||||||
|
if (params.path) {
|
||||||
|
const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path)
|
||||||
|
return { path: full, repository: params.repository }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseRepositoryReference(params.repository!)
|
||||||
|
if (!parsed) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
|
||||||
|
|
||||||
|
const repository = parsed.label
|
||||||
|
return {
|
||||||
|
repository,
|
||||||
|
path: repositoryCachePath(parsed),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const structure = Effect.fn("RepoOverviewTool.structure")(function* (root: string, depth: number) {
|
||||||
|
let truncated = false
|
||||||
|
const lines: string[] = []
|
||||||
|
|
||||||
|
const visit: (dir: string, level: number) => Effect.Effect<void> = Effect.fnUntraced(function* (dir: string, level: number) {
|
||||||
|
if (level >= depth || lines.length >= STRUCTURE_LIMIT) {
|
||||||
|
truncated = truncated || lines.length >= STRUCTURE_LIMIT
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = yield* fs.readDirectoryEntries(dir).pipe(Effect.orElseSucceed(() => []))
|
||||||
|
const sorted = yield* Effect.forEach(
|
||||||
|
entries,
|
||||||
|
Effect.fnUntraced(function* (entry) {
|
||||||
|
if (IGNORED_DIRS.has(entry.name)) return undefined
|
||||||
|
const full = path.join(dir, entry.name)
|
||||||
|
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
if (!info) return undefined
|
||||||
|
return { name: entry.name, full, directory: info.type === "Directory" }
|
||||||
|
}),
|
||||||
|
{ concurrency: 16 },
|
||||||
|
).pipe(
|
||||||
|
Effect.map((items) =>
|
||||||
|
items
|
||||||
|
.filter((item): item is { name: string; full: string; directory: boolean } => Boolean(item))
|
||||||
|
.sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const entry of sorted) {
|
||||||
|
if (lines.length >= STRUCTURE_LIMIT) {
|
||||||
|
truncated = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`${" ".repeat(level)}${entry.name}${entry.directory ? "/" : ""}`)
|
||||||
|
if (entry.directory) yield* visit(entry.full, level + 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* visit(root, 0)
|
||||||
|
return { lines, truncated }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters,
|
||||||
|
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = yield* resolveTarget(params)
|
||||||
|
const depth = params.depth ?? 3
|
||||||
|
|
||||||
|
yield* assertExternalDirectoryEffect(ctx, target.path, { kind: "directory" })
|
||||||
|
yield* ctx.ask({
|
||||||
|
permission: "repo_overview",
|
||||||
|
patterns: [target.repository ?? target.path],
|
||||||
|
always: [target.repository ?? target.path],
|
||||||
|
metadata: {
|
||||||
|
repository: target.repository,
|
||||||
|
path: target.path,
|
||||||
|
depth,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const info = yield* fs.stat(target.path).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
if (!info) {
|
||||||
|
if (target.repository) throw new Error(`Repository is not cloned: ${target.repository}. Use repo_clone first.`)
|
||||||
|
throw new Error(`Directory not found: ${target.path}`)
|
||||||
|
}
|
||||||
|
if (info.type !== "Directory") throw new Error(`Path is not a directory: ${target.path}`)
|
||||||
|
|
||||||
|
const entries = yield* fs.readDirectoryEntries(target.path).pipe(Effect.orElseSucceed(() => []))
|
||||||
|
const topLevel = new Set(entries.map((entry) => entry.name))
|
||||||
|
const dependencyFiles = DEPENDENCY_FILES.filter((file) => topLevel.has(file))
|
||||||
|
const packageJson = topLevel.has("package.json")
|
||||||
|
? (yield* fs.readJson(path.join(target.path, "package.json")).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const entrypoints = [
|
||||||
|
...(typeof packageJson.main === "string" ? [`main: ${packageJson.main}`] : []),
|
||||||
|
...(typeof packageJson.module === "string" ? [`module: ${packageJson.module}`] : []),
|
||||||
|
...(typeof packageJson.types === "string" ? [`types: ${packageJson.types}`] : []),
|
||||||
|
...(typeof packageJson.bin === "string" ? [`bin: ${packageJson.bin}`] : []),
|
||||||
|
...(packageJson.bin && typeof packageJson.bin === "object" && !Array.isArray(packageJson.bin)
|
||||||
|
? Object.keys(packageJson.bin as Record<string, unknown>).map((name) => `bin: ${name}`)
|
||||||
|
: []),
|
||||||
|
...(packageJson.exports && typeof packageJson.exports === "object" && !Array.isArray(packageJson.exports)
|
||||||
|
? Object.keys(packageJson.exports as Record<string, unknown>).slice(0, 10).map((name) => `exports: ${name}`)
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
|
||||||
|
const common = commonEntrypoints(new Set([
|
||||||
|
...topLevel,
|
||||||
|
...entries
|
||||||
|
.filter((entry) => entry.name === "src")
|
||||||
|
.flatMap(() => ["src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"]),
|
||||||
|
]))
|
||||||
|
const structureResult = yield* structure(target.path, depth)
|
||||||
|
const branch = yield* git.branch(target.path)
|
||||||
|
const head = yield* git.run(["rev-parse", "HEAD"], { cwd: target.path })
|
||||||
|
const headText = head.exitCode === 0 ? head.text().trim() : undefined
|
||||||
|
|
||||||
|
const metadata: Metadata = {
|
||||||
|
path: target.path,
|
||||||
|
repository: target.repository,
|
||||||
|
branch,
|
||||||
|
head: headText,
|
||||||
|
package_manager: packageManager(topLevel),
|
||||||
|
ecosystems: ecosystems(topLevel),
|
||||||
|
dependency_files: dependencyFiles,
|
||||||
|
entrypoints: [...entrypoints, ...common.map((file) => `file: ${file}`)],
|
||||||
|
depth,
|
||||||
|
truncated: structureResult.truncated,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: target.repository ?? path.basename(target.path),
|
||||||
|
metadata,
|
||||||
|
output: [
|
||||||
|
`Path: ${target.path}`,
|
||||||
|
...(target.repository ? [`Repository: ${target.repository}`] : []),
|
||||||
|
...(branch ? [`Branch: ${branch}`] : []),
|
||||||
|
...(headText ? [`HEAD: ${headText}`] : []),
|
||||||
|
...(metadata.ecosystems.length ? [`Ecosystems: ${metadata.ecosystems.join(", ")}`] : []),
|
||||||
|
...(metadata.package_manager ? [`Package manager: ${metadata.package_manager}`] : []),
|
||||||
|
...(metadata.dependency_files.length ? [`Dependency files: ${metadata.dependency_files.join(", ")}`] : []),
|
||||||
|
...(metadata.entrypoints.length ? ["Likely entrypoints:", ...metadata.entrypoints.map((entry) => `- ${entry}`)] : []),
|
||||||
|
"Top-level structure:",
|
||||||
|
...structureResult.lines,
|
||||||
|
...(structureResult.truncated ? ["(Structure truncated)"] : []),
|
||||||
|
].join("\n"),
|
||||||
|
}
|
||||||
|
}).pipe(Effect.orDie),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
4
packages/opencode/src/tool/repo_overview.txt
Normal file
4
packages/opencode/src/tool/repo_overview.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
- Summarize the structure and likely entrypoints of a cloned repository or local directory
|
||||||
|
- Accepts either a cached repository reference or a directory path
|
||||||
|
- Reports detected ecosystems, dependency files, package manager, likely entrypoints, and a compact structure tree
|
||||||
|
- Use this after repo_clone to orient quickly before deeper Read, Glob, or Grep investigation
|
||||||
34
packages/opencode/src/util/github-remote.ts
Normal file
34
packages/opencode/src/util/github-remote.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
function normalize(input: string) {
|
||||||
|
return input.trim().replace(/^git\+/, "").replace(/#.*$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGitHubRemote(url: string): { owner: string; repo: string } | null {
|
||||||
|
const match = normalize(url).match(/^(?:(?:https?|ssh|git):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
|
||||||
|
if (!match) return null
|
||||||
|
return { owner: match[1], repo: match[2] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGitHubRepository(input: string): { owner: string; repo: string } | null {
|
||||||
|
const cleaned = normalize(input)
|
||||||
|
const remote = parseGitHubRemote(cleaned)
|
||||||
|
if (remote) return remote
|
||||||
|
|
||||||
|
const prefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/)
|
||||||
|
if (prefixed) {
|
||||||
|
return { owner: prefixed[1], repo: prefixed[2].replace(/\.git$/, "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = cleaned.match(/^([^/\s]+)\/([^/\s]+)$/)
|
||||||
|
if (!match) return null
|
||||||
|
return { owner: match[1], repo: match[2].replace(/\.git$/, "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function githubRepositoryURL(input: { owner: string; repo: string }) {
|
||||||
|
return `https://github.com/${input.owner}/${input.repo}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function githubCloneURL(input: { owner: string; repo: string }) {
|
||||||
|
const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||||
|
if (!base) return `https://github.com/${input.owner}/${input.repo}.git`
|
||||||
|
return new URL(`${input.owner}/${input.repo}.git`, base.endsWith("/") ? base : `${base}/`).href
|
||||||
|
}
|
||||||
97
packages/opencode/src/util/repository.ts
Normal file
97
packages/opencode/src/util/repository.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import path from "path"
|
||||||
|
import { Global } from "@/global"
|
||||||
|
|
||||||
|
export type Reference = {
|
||||||
|
host: string
|
||||||
|
path: string
|
||||||
|
segments: string[]
|
||||||
|
owner?: string
|
||||||
|
repo: string
|
||||||
|
remote: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(input: string) {
|
||||||
|
return input.trim().replace(/^git\+/, "").replace(/#.*$/, "").replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimGitSuffix(input: string) {
|
||||||
|
return input.replace(/\.git$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function parts(input: string) {
|
||||||
|
return input
|
||||||
|
.split("/")
|
||||||
|
.map((item) => trimGitSuffix(item.trim()))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostLike(input: string) {
|
||||||
|
return input.includes(".") || input.includes(":") || input === "localhost"
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSlash(input: string) {
|
||||||
|
return input.endsWith("/") ? input : `${input}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
function githubRemote(pathname: string) {
|
||||||
|
const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||||
|
if (!base) return `https://github.com/${pathname}.git`
|
||||||
|
return new URL(`${pathname}.git`, withSlash(base)).href
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(input: { host: string; segments: string[]; remote?: string }) {
|
||||||
|
const segments = input.segments.map(trimGitSuffix).filter(Boolean)
|
||||||
|
if (!segments.length) return null
|
||||||
|
const pathname = segments.join("/")
|
||||||
|
const repo = segments[segments.length - 1]
|
||||||
|
const host = input.host.toLowerCase()
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
path: pathname,
|
||||||
|
segments,
|
||||||
|
owner: segments.length === 2 ? segments[0] : undefined,
|
||||||
|
repo,
|
||||||
|
remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`),
|
||||||
|
label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`,
|
||||||
|
} satisfies Reference
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRepositoryReference(input: string) {
|
||||||
|
const cleaned = normalize(input)
|
||||||
|
if (!cleaned) return null
|
||||||
|
|
||||||
|
const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/)
|
||||||
|
if (githubPrefixed) return build({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] })
|
||||||
|
|
||||||
|
if (!cleaned.includes("://")) {
|
||||||
|
const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/)
|
||||||
|
if (scp) return build({ host: scp[1], segments: parts(scp[2]), remote: cleaned })
|
||||||
|
|
||||||
|
const direct = parts(cleaned)
|
||||||
|
if (direct.length >= 2 && hostLike(direct[0])) {
|
||||||
|
return build({ host: direct[0], segments: direct.slice(1) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direct.length === 2) {
|
||||||
|
return build({ host: "github.com", segments: direct })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(cleaned)
|
||||||
|
const pathname = parts(url.pathname)
|
||||||
|
const host = url.protocol === "file:" ? "file" : url.host
|
||||||
|
return build({ host, segments: pathname, remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned })
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repositoryCachePath(input: Reference) {
|
||||||
|
return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameRepositoryReference(left: Reference, right: Reference) {
|
||||||
|
return left.host === right.host && left.path === right.path
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import path from "path"
|
||||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
import { Global } from "../../src/global"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
|
|
||||||
// Helper to evaluate permission for a tool with wildcard pattern
|
// 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("plan")
|
||||||
expect(names).toContain("general")
|
expect(names).toContain("general")
|
||||||
expect(names).toContain("explore")
|
expect(names).toContain("explore")
|
||||||
|
expect(names).toContain("scout")
|
||||||
expect(names).toContain("compaction")
|
expect(names).toContain("compaction")
|
||||||
expect(names).toContain("title")
|
expect(names).toContain("title")
|
||||||
expect(names).toContain("summary")
|
expect(names).toContain("summary")
|
||||||
|
|
@ -49,6 +51,8 @@ test("build agent has correct default properties", async () => {
|
||||||
expect(build?.native).toBe(true)
|
expect(build?.native).toBe(true)
|
||||||
expect(evalPerm(build, "edit")).toBe("allow")
|
expect(evalPerm(build, "edit")).toBe("allow")
|
||||||
expect(evalPerm(build, "bash")).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 () => {
|
test("general agent denies todo tools", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,16 @@ test("parses ssh:// URL without .git suffix", () => {
|
||||||
expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
|
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", () => {
|
test("parses http URL", () => {
|
||||||
expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" })
|
expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" })
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { Permission } from "../../src/permission"
|
||||||
import { Plugin } from "../../src/plugin"
|
import { Plugin } from "../../src/plugin"
|
||||||
import { Provider as ProviderSvc } from "../../src/provider"
|
import { Provider as ProviderSvc } from "../../src/provider"
|
||||||
import { Env } from "../../src/env"
|
import { Env } from "../../src/env"
|
||||||
|
import { Git } from "../../src/git"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { Question } from "../../src/question"
|
import { Question } from "../../src/question"
|
||||||
import { Todo } from "../../src/session/todo"
|
import { Todo } from "../../src/session/todo"
|
||||||
|
|
@ -175,6 +176,7 @@ function makeHttp() {
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provideMerge(todo),
|
Layer.provideMerge(todo),
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { TestLLMServer } from "../lib/llm-server"
|
||||||
// Same layer setup as prompt-effect.test.ts
|
// Same layer setup as prompt-effect.test.ts
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||||
|
import { Git } from "../../src/git"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { Command } from "../../src/command"
|
import { Command } from "../../src/command"
|
||||||
import { Config } from "../../src/config"
|
import { Config } from "../../src/config"
|
||||||
|
|
@ -128,6 +129,7 @@ function makeHttp() {
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provideMerge(todo),
|
Layer.provideMerge(todo),
|
||||||
|
|
|
||||||
198
packages/opencode/test/tool/repo_clone.test.ts
Normal file
198
packages/opencode/test/tool/repo_clone.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "node:url"
|
||||||
|
import { Cause, Effect, Exit, Layer } from "effect"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
|
import { Git } from "../../src/git"
|
||||||
|
import { Global } from "../../src/global"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { MessageID, SessionID } from "../../src/session/schema"
|
||||||
|
import { Truncate } from "../../src/tool"
|
||||||
|
import { RepoCloneTool } from "../../src/tool/repo_clone"
|
||||||
|
import { provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Instance.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
sessionID: SessionID.make("ses_test"),
|
||||||
|
messageID: MessageID.make(""),
|
||||||
|
callID: "",
|
||||||
|
agent: "scout",
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
messages: [],
|
||||||
|
metadata: () => Effect.void,
|
||||||
|
ask: () => Effect.void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const it = testEffect(
|
||||||
|
Layer.mergeAll(
|
||||||
|
Agent.defaultLayer,
|
||||||
|
AppFileSystem.defaultLayer,
|
||||||
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
Git.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const init = Effect.fn("RepoCloneToolTest.init")(function* () {
|
||||||
|
const info = yield* RepoCloneTool
|
||||||
|
return yield* info.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
const git = Effect.fn("RepoCloneToolTest.git")(function* (cwd: string, args: string[]) {
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const proc = Bun.spawn(["git", ...args], {
|
||||||
|
cwd,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const [stdout, stderr, code] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
])
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
|
||||||
|
}
|
||||||
|
return stdout.trim()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||||
|
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
() => self,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
|
||||||
|
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
describe("tool.repo_clone", () => {
|
||||||
|
it.live("clones a repo into the managed cache and reuses it on subsequent calls", () =>
|
||||||
|
provideTmpdirInstance((_dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const source = yield* tmpdirScoped({ git: true })
|
||||||
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
|
const remoteDir = path.join(remoteRoot, "owner")
|
||||||
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
|
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
|
||||||
|
yield* git(source, ["add", "."])
|
||||||
|
yield* git(source, ["commit", "-m", "add readme"])
|
||||||
|
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
|
const tool = yield* init()
|
||||||
|
const cloned = yield* githubBase(
|
||||||
|
`file://${remoteRoot}/`,
|
||||||
|
tool.execute({ repository: "owner/repo" }, ctx),
|
||||||
|
)
|
||||||
|
const cached = yield* githubBase(
|
||||||
|
`file://${remoteRoot}/`,
|
||||||
|
tool.execute({ repository: "https://github.com/owner/repo.git" }, ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(cloned.metadata.status).toBe("cloned")
|
||||||
|
expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo"))
|
||||||
|
expect(cached.metadata.status).toBe("cached")
|
||||||
|
expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("refresh updates an existing cached clone", () =>
|
||||||
|
provideTmpdirInstance((_dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const source = yield* tmpdirScoped({ git: true })
|
||||||
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
|
const remoteDir = path.join(remoteRoot, "owner")
|
||||||
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
|
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
|
||||||
|
yield* git(source, ["add", "."])
|
||||||
|
yield* git(source, ["commit", "-m", "add readme"])
|
||||||
|
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
|
const branch = yield* git(source, ["branch", "--show-current"])
|
||||||
|
yield* git(source, ["remote", "add", "origin", remoteRepo])
|
||||||
|
yield* git(source, ["push", "-u", "origin", `${branch}:${branch}`])
|
||||||
|
|
||||||
|
const tool = yield* init()
|
||||||
|
const first = yield* githubBase(
|
||||||
|
`file://${remoteRoot}/`,
|
||||||
|
tool.execute({ repository: "owner/repo" }, ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n"))
|
||||||
|
yield* git(source, ["add", "."])
|
||||||
|
yield* git(source, ["commit", "-m", "update readme"])
|
||||||
|
yield* git(source, ["push", "origin", `${branch}:${branch}`])
|
||||||
|
|
||||||
|
const refreshed = yield* githubBase(
|
||||||
|
`file://${remoteRoot}/`,
|
||||||
|
tool.execute({ repository: "owner/repo", refresh: true }, ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(first.metadata.status).toBe("cloned")
|
||||||
|
expect(refreshed.metadata.status).toBe("refreshed")
|
||||||
|
expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("rejects invalid repository inputs", () =>
|
||||||
|
provideTmpdirInstance((_dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const tool = yield* init()
|
||||||
|
const result = yield* tool.execute({ repository: "not-a-repo" }, ctx).pipe(Effect.exit)
|
||||||
|
|
||||||
|
expect(Exit.isFailure(result)).toBe(true)
|
||||||
|
if (Exit.isFailure(result)) {
|
||||||
|
const error = Cause.squash(result.cause)
|
||||||
|
expect(error instanceof Error ? error.message : String(error)).toContain("git URL")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("clones generic git URLs into the managed cache", () =>
|
||||||
|
provideTmpdirInstance((_dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const source = yield* tmpdirScoped({ git: true })
|
||||||
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
|
const remoteDir = path.join(remoteRoot, "forge")
|
||||||
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
|
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
|
||||||
|
yield* git(source, ["add", "."])
|
||||||
|
yield* git(source, ["commit", "-m", "add readme"])
|
||||||
|
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
|
const tool = yield* init()
|
||||||
|
const result = yield* tool.execute({ repository: pathToFileURL(remoteRepo).href }, ctx)
|
||||||
|
|
||||||
|
expect(result.metadata.status).toBe("cloned")
|
||||||
|
expect(result.metadata.host).toBe("file")
|
||||||
|
expect(result.metadata.localPath.startsWith(path.join(Global.Path.repos, "file"))).toBe(true)
|
||||||
|
expect(result.metadata.localPath.endsWith(path.join("forge", "repo"))).toBe(true)
|
||||||
|
expect(yield* fs.readFileString(path.join(result.metadata.localPath, "README.md"))).toBe("v1\n")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
151
packages/opencode/test/tool/repo_overview.test.ts
Normal file
151
packages/opencode/test/tool/repo_overview.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import { Cause, Effect, Exit, Layer } from "effect"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
|
import { Git } from "../../src/git"
|
||||||
|
import { Global } from "../../src/global"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { MessageID, SessionID } from "../../src/session/schema"
|
||||||
|
import { Truncate } from "../../src/tool"
|
||||||
|
import { RepoOverviewTool } from "../../src/tool/repo_overview"
|
||||||
|
import { provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Instance.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
sessionID: SessionID.make("ses_test"),
|
||||||
|
messageID: MessageID.make(""),
|
||||||
|
callID: "",
|
||||||
|
agent: "scout",
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
messages: [],
|
||||||
|
metadata: () => Effect.void,
|
||||||
|
ask: () => Effect.void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const it = testEffect(
|
||||||
|
Layer.mergeAll(
|
||||||
|
Agent.defaultLayer,
|
||||||
|
AppFileSystem.defaultLayer,
|
||||||
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
Git.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const init = Effect.fn("RepoOverviewToolTest.init")(function* () {
|
||||||
|
const info = yield* RepoOverviewTool
|
||||||
|
return yield* info.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("tool.repo_overview", () => {
|
||||||
|
it.live("summarizes a local repository path", () =>
|
||||||
|
provideTmpdirInstance((_dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const repo = yield* tmpdirScoped({ git: true })
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
yield* fs.writeWithDirs(
|
||||||
|
path.join(repo, "package.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "example-repo",
|
||||||
|
main: "dist/index.js",
|
||||||
|
module: "dist/index.mjs",
|
||||||
|
types: "dist/index.d.ts",
|
||||||
|
exports: {
|
||||||
|
".": "./dist/index.js",
|
||||||
|
"./server": "./dist/server.js",
|
||||||
|
},
|
||||||
|
bin: {
|
||||||
|
example: "./bin/example.js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield* fs.writeWithDirs(path.join(repo, "bun.lock"), "")
|
||||||
|
yield* fs.writeWithDirs(path.join(repo, "README.md"), "# Example\n")
|
||||||
|
yield* fs.writeWithDirs(path.join(repo, "src", "index.ts"), "export const value = 1\n")
|
||||||
|
|
||||||
|
const tool = yield* init()
|
||||||
|
const result = yield* tool.execute({ path: repo }, ctx)
|
||||||
|
|
||||||
|
expect(result.metadata.path).toBe(repo)
|
||||||
|
expect(result.metadata.ecosystems).toContain("Node.js")
|
||||||
|
expect(result.metadata.package_manager).toBe("bun")
|
||||||
|
expect(result.metadata.dependency_files).toEqual(expect.arrayContaining(["package.json", "bun.lock"]))
|
||||||
|
expect(result.metadata.entrypoints).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"main: dist/index.js",
|
||||||
|
"module: dist/index.mjs",
|
||||||
|
"types: dist/index.d.ts",
|
||||||
|
"exports: .",
|
||||||
|
"exports: ./server",
|
||||||
|
"bin: example",
|
||||||
|
"file: src/index.ts",
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(result.output).toContain("Top-level structure:")
|
||||||
|
expect(result.output).toContain("src/")
|
||||||
|
expect(result.output).toContain("README.md")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("resolves a cached repository from repository shorthand", () =>
|
||||||
|
provideTmpdirInstance((_dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const cached = path.join(Global.Path.repos, "github.com", "owner", "repo")
|
||||||
|
yield* fs.writeWithDirs(path.join(cached, "package.json"), JSON.stringify({ name: "cached-repo" }, null, 2))
|
||||||
|
yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n")
|
||||||
|
|
||||||
|
const tool = yield* init()
|
||||||
|
const result = yield* tool.execute({ repository: "owner/repo" }, ctx)
|
||||||
|
|
||||||
|
expect(result.metadata.path).toBe(cached)
|
||||||
|
expect(result.metadata.repository).toBe("owner/repo")
|
||||||
|
expect(result.output).toContain("Repository: owner/repo")
|
||||||
|
expect(result.output).toContain(`Path: ${cached}`)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("fails clearly when a repository is not cloned", () =>
|
||||||
|
provideTmpdirInstance((_dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const tool = yield* init()
|
||||||
|
const result = yield* tool.execute({ repository: "missing/repo" }, ctx).pipe(Effect.exit)
|
||||||
|
|
||||||
|
expect(Exit.isFailure(result)).toBe(true)
|
||||||
|
if (Exit.isFailure(result)) {
|
||||||
|
const error = Cause.squash(result.cause)
|
||||||
|
expect(error instanceof Error ? error.message : String(error)).toContain("Use repo_clone first")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("resolves cached repositories from host/path references", () =>
|
||||||
|
provideTmpdirInstance((_dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const cached = path.join(Global.Path.repos, "gitlab.com", "group", "repo")
|
||||||
|
yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n")
|
||||||
|
|
||||||
|
const tool = yield* init()
|
||||||
|
const result = yield* tool.execute({ repository: "gitlab.com/group/repo" }, ctx)
|
||||||
|
|
||||||
|
expect(result.metadata.path).toBe(cached)
|
||||||
|
expect(result.metadata.repository).toBe("gitlab.com/group/repo")
|
||||||
|
expect(result.output).toContain("Repository: gitlab.com/group/repo")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue