opencode-repos 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.sisyphus/boulder.json +8 -0
- package/.sisyphus/notepads/opencode-repos/decisions.md +15 -0
- package/.sisyphus/notepads/opencode-repos/learnings.md +384 -0
- package/.sisyphus/plans/opencode-repos.md +987 -0
- package/.tmux-sessionizer +8 -0
- package/CLAUDE.md +111 -0
- package/README.md +395 -0
- package/bun.lock +119 -0
- package/index.ts +806 -0
- package/package.json +32 -0
- package/src/__tests__/git.test.ts +141 -0
- package/src/__tests__/manifest.test.ts +249 -0
- package/src/__tests__/setup.test.ts +5 -0
- package/src/agents/repo-explorer.ts +52 -0
- package/src/git.ts +90 -0
- package/src/manifest.ts +116 -0
- package/src/scanner.ts +126 -0
- package/tsconfig.json +16 -0
package/index.ts
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
import { tool } from "@opencode-ai/plugin"
|
|
3
|
+
import { $ } from "bun"
|
|
4
|
+
import { parseRepoSpec, buildGitUrl, cloneRepo, updateRepo, getRepoInfo } from "./src/git"
|
|
5
|
+
import { createRepoExplorerAgent } from "./src/agents/repo-explorer"
|
|
6
|
+
import {
|
|
7
|
+
loadManifest,
|
|
8
|
+
saveManifest,
|
|
9
|
+
withManifestLock,
|
|
10
|
+
type RepoEntry,
|
|
11
|
+
} from "./src/manifest"
|
|
12
|
+
import { scanLocalRepos, matchRemoteToSpec, findLocalRepoByName } from "./src/scanner"
|
|
13
|
+
import { homedir } from "node:os"
|
|
14
|
+
import { join } from "node:path"
|
|
15
|
+
import { existsSync } from "node:fs"
|
|
16
|
+
import { rm, readFile } from "node:fs/promises"
|
|
17
|
+
import { glob } from "glob"
|
|
18
|
+
|
|
19
|
+
interface Config {
|
|
20
|
+
localSearchPaths: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function loadConfig(): Promise<Config | null> {
|
|
24
|
+
const configPath = join(homedir(), ".config", "opencode", "opencode-repos.json")
|
|
25
|
+
|
|
26
|
+
if (!existsSync(configPath)) {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const content = await readFile(configPath, "utf-8")
|
|
32
|
+
return JSON.parse(content)
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CACHE_DIR = join(homedir(), ".cache", "opencode-repos")
|
|
39
|
+
|
|
40
|
+
export const OpencodeRepos: Plugin = async ({ client }) => {
|
|
41
|
+
return {
|
|
42
|
+
config: async (config) => {
|
|
43
|
+
const explorerAgent = createRepoExplorerAgent()
|
|
44
|
+
config.agent = {
|
|
45
|
+
...config.agent,
|
|
46
|
+
"repo-explorer": explorerAgent,
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
"experimental.session.compacting": async (_input, output) => {
|
|
51
|
+
output.context.push(`## External Repository Access
|
|
52
|
+
When user mentions another project or asks about external code:
|
|
53
|
+
1. Use \`repo_find\` to check if it exists locally or on GitHub
|
|
54
|
+
2. Tell user what you found before cloning
|
|
55
|
+
3. Only clone after user confirms or explicitly requests it`)
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
tool: {
|
|
59
|
+
repo_clone: tool({
|
|
60
|
+
description:
|
|
61
|
+
"Clone a repository to local cache or return path if already cached. Supports public and private (SSH) repos. Example: repo_clone({ repo: 'vercel/next.js' }) or repo_clone({ repo: 'vercel/next.js@canary', force: true })",
|
|
62
|
+
args: {
|
|
63
|
+
repo: tool.schema
|
|
64
|
+
.string()
|
|
65
|
+
.describe(
|
|
66
|
+
"Repository in format 'owner/repo' or 'owner/repo@branch'"
|
|
67
|
+
),
|
|
68
|
+
force: tool.schema
|
|
69
|
+
.boolean()
|
|
70
|
+
.optional()
|
|
71
|
+
.default(false)
|
|
72
|
+
.describe("Force re-clone even if cached"),
|
|
73
|
+
},
|
|
74
|
+
async execute(args) {
|
|
75
|
+
const spec = parseRepoSpec(args.repo)
|
|
76
|
+
const branch = spec.branch || "main"
|
|
77
|
+
const repoKey = `${spec.owner}/${spec.repo}@${branch}`
|
|
78
|
+
|
|
79
|
+
const result = await withManifestLock(async () => {
|
|
80
|
+
const manifest = await loadManifest()
|
|
81
|
+
|
|
82
|
+
const existingEntry = manifest.repos[repoKey]
|
|
83
|
+
if (existingEntry && !args.force) {
|
|
84
|
+
existingEntry.lastAccessed = new Date().toISOString()
|
|
85
|
+
await saveManifest(manifest)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
path: existingEntry.path,
|
|
89
|
+
status: "cached" as const,
|
|
90
|
+
alreadyExists: true,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const destPath = join(
|
|
95
|
+
CACHE_DIR,
|
|
96
|
+
spec.owner,
|
|
97
|
+
`${spec.repo}@${branch}`
|
|
98
|
+
)
|
|
99
|
+
const url = buildGitUrl(spec.owner, spec.repo)
|
|
100
|
+
|
|
101
|
+
if (args.force && existingEntry) {
|
|
102
|
+
try {
|
|
103
|
+
await rm(destPath, { recursive: true, force: true })
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await cloneRepo(url, destPath, { branch })
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const message =
|
|
111
|
+
error instanceof Error ? error.message : String(error)
|
|
112
|
+
throw new Error(`Failed to clone ${repoKey}: ${message}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const now = new Date().toISOString()
|
|
116
|
+
const entry: RepoEntry = {
|
|
117
|
+
type: "cached",
|
|
118
|
+
path: destPath,
|
|
119
|
+
clonedAt: now,
|
|
120
|
+
lastAccessed: now,
|
|
121
|
+
lastUpdated: now,
|
|
122
|
+
defaultBranch: branch,
|
|
123
|
+
shallow: true,
|
|
124
|
+
}
|
|
125
|
+
manifest.repos[repoKey] = entry
|
|
126
|
+
|
|
127
|
+
await saveManifest(manifest)
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
path: destPath,
|
|
131
|
+
status: "cloned" as const,
|
|
132
|
+
alreadyExists: false,
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const statusText = result.alreadyExists
|
|
137
|
+
? "Repository already cached"
|
|
138
|
+
: "Successfully cloned repository"
|
|
139
|
+
|
|
140
|
+
return `## ${statusText}
|
|
141
|
+
|
|
142
|
+
**Repository**: ${args.repo}
|
|
143
|
+
**Path**: ${result.path}
|
|
144
|
+
**Status**: ${result.status}
|
|
145
|
+
|
|
146
|
+
You can now use \`repo_read\` to access files from this repository.`
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
|
|
150
|
+
repo_read: tool({
|
|
151
|
+
description:
|
|
152
|
+
"Read files from a registered repository. Repo must be cloned first via repo_clone. Supports glob patterns for multiple files.",
|
|
153
|
+
args: {
|
|
154
|
+
repo: tool.schema
|
|
155
|
+
.string()
|
|
156
|
+
.describe(
|
|
157
|
+
"Repository in format 'owner/repo' or 'owner/repo@branch'"
|
|
158
|
+
),
|
|
159
|
+
path: tool.schema
|
|
160
|
+
.string()
|
|
161
|
+
.describe("File path within repo, supports glob patterns"),
|
|
162
|
+
maxLines: tool.schema
|
|
163
|
+
.number()
|
|
164
|
+
.optional()
|
|
165
|
+
.default(500)
|
|
166
|
+
.describe("Max lines per file to return"),
|
|
167
|
+
},
|
|
168
|
+
async execute(args) {
|
|
169
|
+
const spec = parseRepoSpec(args.repo)
|
|
170
|
+
const branch = spec.branch || "main"
|
|
171
|
+
const repoKey = `${spec.owner}/${spec.repo}@${branch}`
|
|
172
|
+
|
|
173
|
+
const manifest = await loadManifest()
|
|
174
|
+
const entry = manifest.repos[repoKey]
|
|
175
|
+
|
|
176
|
+
if (!entry) {
|
|
177
|
+
return `## Repository not found
|
|
178
|
+
|
|
179
|
+
Repository \`${args.repo}\` is not registered.
|
|
180
|
+
|
|
181
|
+
Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const repoPath = entry.path
|
|
185
|
+
const fullPath = join(repoPath, args.path)
|
|
186
|
+
|
|
187
|
+
let filePaths: string[] = []
|
|
188
|
+
|
|
189
|
+
if (args.path.includes("*") || args.path.includes("?")) {
|
|
190
|
+
filePaths = await glob(fullPath, { nodir: true })
|
|
191
|
+
} else {
|
|
192
|
+
filePaths = [fullPath]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (filePaths.length === 0) {
|
|
196
|
+
return `No files found matching path: ${args.path}`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let output = `## Files from ${args.repo}\n\n`
|
|
200
|
+
const maxLines = args.maxLines ?? 500
|
|
201
|
+
|
|
202
|
+
for (const filePath of filePaths) {
|
|
203
|
+
const relativePath = filePath.replace(repoPath + "/", "")
|
|
204
|
+
try {
|
|
205
|
+
const content = await readFile(filePath, "utf-8")
|
|
206
|
+
const lines = content.split("\n")
|
|
207
|
+
const truncated = lines.length > maxLines
|
|
208
|
+
const displayLines = truncated
|
|
209
|
+
? lines.slice(0, maxLines)
|
|
210
|
+
: lines
|
|
211
|
+
|
|
212
|
+
output += `### ${relativePath}\n\n`
|
|
213
|
+
output += "```\n"
|
|
214
|
+
output += displayLines.join("\n")
|
|
215
|
+
if (truncated) {
|
|
216
|
+
output += `\n[truncated at ${maxLines} lines, ${lines.length} total]\n`
|
|
217
|
+
}
|
|
218
|
+
output += "\n```\n\n"
|
|
219
|
+
} catch (error) {
|
|
220
|
+
output += `### ${relativePath}\n\n`
|
|
221
|
+
output += `Error reading file: ${error instanceof Error ? error.message : String(error)}\n\n`
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
await withManifestLock(async () => {
|
|
226
|
+
const updatedManifest = await loadManifest()
|
|
227
|
+
if (updatedManifest.repos[repoKey]) {
|
|
228
|
+
updatedManifest.repos[repoKey].lastAccessed =
|
|
229
|
+
new Date().toISOString()
|
|
230
|
+
await saveManifest(updatedManifest)
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
return output
|
|
235
|
+
},
|
|
236
|
+
}),
|
|
237
|
+
|
|
238
|
+
repo_list: tool({
|
|
239
|
+
description:
|
|
240
|
+
"List all registered repositories (cached and local). Shows metadata like type, branch, last accessed, and size.",
|
|
241
|
+
args: {
|
|
242
|
+
type: tool.schema
|
|
243
|
+
.enum(["all", "cached", "local"])
|
|
244
|
+
.optional()
|
|
245
|
+
.default("all")
|
|
246
|
+
.describe("Filter by repository type"),
|
|
247
|
+
},
|
|
248
|
+
async execute(args) {
|
|
249
|
+
const manifest = await loadManifest()
|
|
250
|
+
|
|
251
|
+
const allRepos = Object.entries(manifest.repos)
|
|
252
|
+
const filteredRepos = allRepos.filter(([_, entry]) => {
|
|
253
|
+
if (args.type === "all") return true
|
|
254
|
+
return entry.type === args.type
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
if (filteredRepos.length === 0) {
|
|
258
|
+
return "No repositories registered."
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let output = "## Registered Repositories\n\n"
|
|
262
|
+
output += "| Repo | Type | Branch | Last Accessed | Size |\n"
|
|
263
|
+
output += "|------|------|--------|---------------|------|\n"
|
|
264
|
+
|
|
265
|
+
for (const [repoKey, entry] of filteredRepos) {
|
|
266
|
+
const repoName = repoKey.substring(0, repoKey.lastIndexOf("@"))
|
|
267
|
+
const lastAccessed = new Date(entry.lastAccessed).toLocaleDateString()
|
|
268
|
+
const size = entry.sizeBytes
|
|
269
|
+
? `${Math.round(entry.sizeBytes / 1024 / 1024)}MB`
|
|
270
|
+
: "-"
|
|
271
|
+
|
|
272
|
+
output += `| ${repoName} | ${entry.type} | ${entry.defaultBranch} | ${lastAccessed} | ${size} |\n`
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const cachedCount = filteredRepos.filter(
|
|
276
|
+
([_, e]) => e.type === "cached"
|
|
277
|
+
).length
|
|
278
|
+
const localCount = filteredRepos.filter(
|
|
279
|
+
([_, e]) => e.type === "local"
|
|
280
|
+
).length
|
|
281
|
+
output += `\nTotal: ${filteredRepos.length} repos (${cachedCount} cached, ${localCount} local)`
|
|
282
|
+
|
|
283
|
+
return output
|
|
284
|
+
},
|
|
285
|
+
}),
|
|
286
|
+
|
|
287
|
+
repo_scan: tool({
|
|
288
|
+
description:
|
|
289
|
+
"Scan local filesystem for git repositories and register them. Configure search paths in ~/.config/opencode/opencode-repos.json or override with paths argument.",
|
|
290
|
+
args: {
|
|
291
|
+
paths: tool.schema
|
|
292
|
+
.array(tool.schema.string())
|
|
293
|
+
.optional()
|
|
294
|
+
.describe("Override search paths (default: from config)"),
|
|
295
|
+
},
|
|
296
|
+
async execute(args) {
|
|
297
|
+
let searchPaths: string[] | null = args.paths || null
|
|
298
|
+
|
|
299
|
+
if (!searchPaths) {
|
|
300
|
+
const config = await loadConfig()
|
|
301
|
+
searchPaths = config?.localSearchPaths || null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!searchPaths || searchPaths.length === 0) {
|
|
305
|
+
return `## No search paths configured
|
|
306
|
+
|
|
307
|
+
Create a config file at \`~/.config/opencode/opencode-repos.json\`:
|
|
308
|
+
|
|
309
|
+
\`\`\`json
|
|
310
|
+
{
|
|
311
|
+
"localSearchPaths": [
|
|
312
|
+
"~/projects",
|
|
313
|
+
"~/personal/projects",
|
|
314
|
+
"~/code"
|
|
315
|
+
]
|
|
316
|
+
}
|
|
317
|
+
\`\`\`
|
|
318
|
+
|
|
319
|
+
Or provide paths directly: \`repo_scan({ paths: ["~/projects"] })\``
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const foundRepos = await scanLocalRepos(searchPaths)
|
|
323
|
+
|
|
324
|
+
if (foundRepos.length === 0) {
|
|
325
|
+
return `## No repositories found
|
|
326
|
+
|
|
327
|
+
Searched ${searchPaths.length} path(s):
|
|
328
|
+
${searchPaths.map((p) => `- ${p}`).join("\n")}
|
|
329
|
+
|
|
330
|
+
No git repositories with remotes were found.`
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let newCount = 0
|
|
334
|
+
let existingCount = 0
|
|
335
|
+
|
|
336
|
+
await withManifestLock(async () => {
|
|
337
|
+
const manifest = await loadManifest()
|
|
338
|
+
|
|
339
|
+
for (const repo of foundRepos) {
|
|
340
|
+
const spec = matchRemoteToSpec(repo.remote)
|
|
341
|
+
if (!spec) continue
|
|
342
|
+
|
|
343
|
+
const branch = repo.branch || "main"
|
|
344
|
+
const repoKey = `${spec}@${branch}`
|
|
345
|
+
|
|
346
|
+
if (manifest.repos[repoKey]) {
|
|
347
|
+
existingCount++
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const now = new Date().toISOString()
|
|
352
|
+
manifest.repos[repoKey] = {
|
|
353
|
+
type: "local",
|
|
354
|
+
path: repo.path,
|
|
355
|
+
lastAccessed: now,
|
|
356
|
+
defaultBranch: branch,
|
|
357
|
+
shallow: false,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
manifest.localIndex[repo.remote] = repo.path
|
|
361
|
+
|
|
362
|
+
newCount++
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
await saveManifest(manifest)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
return `## Local Repository Scan Complete
|
|
369
|
+
|
|
370
|
+
**Found**: ${foundRepos.length} repositories in ${searchPaths.length} path(s)
|
|
371
|
+
**New**: ${newCount} repos registered
|
|
372
|
+
**Existing**: ${existingCount} repos already registered
|
|
373
|
+
|
|
374
|
+
${newCount > 0 ? "Use `repo_list()` to see all registered repositories." : ""}`
|
|
375
|
+
},
|
|
376
|
+
}),
|
|
377
|
+
|
|
378
|
+
repo_update: tool({
|
|
379
|
+
description:
|
|
380
|
+
"Update a cached repository to latest. For local repos, shows git status without modifying. Only cached repos (cloned via repo_clone) are updated.",
|
|
381
|
+
args: {
|
|
382
|
+
repo: tool.schema
|
|
383
|
+
.string()
|
|
384
|
+
.describe(
|
|
385
|
+
"Repository in format 'owner/repo' or 'owner/repo@branch'"
|
|
386
|
+
),
|
|
387
|
+
},
|
|
388
|
+
async execute(args) {
|
|
389
|
+
const spec = parseRepoSpec(args.repo)
|
|
390
|
+
const branch = spec.branch || "main"
|
|
391
|
+
const repoKey = `${spec.owner}/${spec.repo}@${branch}`
|
|
392
|
+
|
|
393
|
+
const manifest = await loadManifest()
|
|
394
|
+
const entry = manifest.repos[repoKey]
|
|
395
|
+
|
|
396
|
+
if (!entry) {
|
|
397
|
+
return `## Repository not found
|
|
398
|
+
|
|
399
|
+
Repository \`${args.repo}\` is not registered.
|
|
400
|
+
|
|
401
|
+
Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (entry.type === "local") {
|
|
405
|
+
try {
|
|
406
|
+
const status = await $`git -C ${entry.path} status --short`.text()
|
|
407
|
+
|
|
408
|
+
return `## Local Repository Status
|
|
409
|
+
|
|
410
|
+
**Repository**: ${args.repo}
|
|
411
|
+
**Path**: ${entry.path}
|
|
412
|
+
**Type**: Local (not modified by plugin)
|
|
413
|
+
|
|
414
|
+
\`\`\`
|
|
415
|
+
${status || "Working tree clean"}
|
|
416
|
+
\`\`\``
|
|
417
|
+
} catch (error) {
|
|
418
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
419
|
+
return `## Error getting status
|
|
420
|
+
|
|
421
|
+
Failed to get git status for ${args.repo}: ${message}`
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
await updateRepo(entry.path, branch)
|
|
427
|
+
|
|
428
|
+
const info = await getRepoInfo(entry.path)
|
|
429
|
+
|
|
430
|
+
await withManifestLock(async () => {
|
|
431
|
+
const updatedManifest = await loadManifest()
|
|
432
|
+
if (updatedManifest.repos[repoKey]) {
|
|
433
|
+
updatedManifest.repos[repoKey].lastUpdated = new Date().toISOString()
|
|
434
|
+
updatedManifest.repos[repoKey].lastAccessed = new Date().toISOString()
|
|
435
|
+
await saveManifest(updatedManifest)
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
return `## Repository Updated
|
|
440
|
+
|
|
441
|
+
**Repository**: ${args.repo}
|
|
442
|
+
**Path**: ${entry.path}
|
|
443
|
+
**Branch**: ${branch}
|
|
444
|
+
**Latest Commit**: ${info.commit.substring(0, 7)}
|
|
445
|
+
|
|
446
|
+
Repository has been updated to the latest commit.`
|
|
447
|
+
} catch (error) {
|
|
448
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
449
|
+
return `## Update Failed
|
|
450
|
+
|
|
451
|
+
Failed to update ${args.repo}: ${message}
|
|
452
|
+
|
|
453
|
+
The repository may be corrupted. Try \`repo_clone({ repo: "${args.repo}", force: true })\` to re-clone.`
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
}),
|
|
457
|
+
|
|
458
|
+
repo_remove: tool({
|
|
459
|
+
description:
|
|
460
|
+
"Remove a repository. Cached repos (cloned via repo_clone) are deleted from disk. Local repos are unregistered only (files preserved).",
|
|
461
|
+
args: {
|
|
462
|
+
repo: tool.schema
|
|
463
|
+
.string()
|
|
464
|
+
.describe(
|
|
465
|
+
"Repository in format 'owner/repo' or 'owner/repo@branch'"
|
|
466
|
+
),
|
|
467
|
+
confirm: tool.schema
|
|
468
|
+
.boolean()
|
|
469
|
+
.optional()
|
|
470
|
+
.default(false)
|
|
471
|
+
.describe("Confirm deletion for cached repos"),
|
|
472
|
+
},
|
|
473
|
+
async execute(args) {
|
|
474
|
+
const spec = parseRepoSpec(args.repo)
|
|
475
|
+
const branch = spec.branch || "main"
|
|
476
|
+
const repoKey = `${spec.owner}/${spec.repo}@${branch}`
|
|
477
|
+
|
|
478
|
+
const manifest = await loadManifest()
|
|
479
|
+
const entry = manifest.repos[repoKey]
|
|
480
|
+
|
|
481
|
+
if (!entry) {
|
|
482
|
+
return `## Repository not found
|
|
483
|
+
|
|
484
|
+
Repository \`${args.repo}\` is not registered.
|
|
485
|
+
|
|
486
|
+
Use \`repo_list()\` to see all registered repositories.`
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (entry.type === "local") {
|
|
490
|
+
await withManifestLock(async () => {
|
|
491
|
+
const updatedManifest = await loadManifest()
|
|
492
|
+
delete updatedManifest.repos[repoKey]
|
|
493
|
+
|
|
494
|
+
for (const [remote, path] of Object.entries(updatedManifest.localIndex)) {
|
|
495
|
+
if (path === entry.path) {
|
|
496
|
+
delete updatedManifest.localIndex[remote]
|
|
497
|
+
break
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
await saveManifest(updatedManifest)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
return `## Local Repository Unregistered
|
|
505
|
+
|
|
506
|
+
**Repository**: ${args.repo}
|
|
507
|
+
**Path**: ${entry.path}
|
|
508
|
+
|
|
509
|
+
The repository has been unregistered. Files are preserved at the path above.
|
|
510
|
+
|
|
511
|
+
To re-register, run \`repo_scan()\`.`
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!args.confirm) {
|
|
515
|
+
return `## Confirmation Required
|
|
516
|
+
|
|
517
|
+
**Repository**: ${args.repo}
|
|
518
|
+
**Path**: ${entry.path}
|
|
519
|
+
**Type**: Cached (cloned by plugin)
|
|
520
|
+
|
|
521
|
+
This will **permanently delete** the cached repository from disk.
|
|
522
|
+
|
|
523
|
+
To proceed: \`repo_remove({ repo: "${args.repo}", confirm: true })\`
|
|
524
|
+
|
|
525
|
+
To keep the repo but unregister it, manually delete it from \`~/.cache/opencode-repos/manifest.json\`.`
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
await rm(entry.path, { recursive: true, force: true })
|
|
530
|
+
|
|
531
|
+
await withManifestLock(async () => {
|
|
532
|
+
const updatedManifest = await loadManifest()
|
|
533
|
+
delete updatedManifest.repos[repoKey]
|
|
534
|
+
await saveManifest(updatedManifest)
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
return `## Cached Repository Deleted
|
|
538
|
+
|
|
539
|
+
**Repository**: ${args.repo}
|
|
540
|
+
**Path**: ${entry.path}
|
|
541
|
+
|
|
542
|
+
The repository has been permanently deleted from disk and unregistered from the cache.
|
|
543
|
+
|
|
544
|
+
To re-clone: \`repo_clone({ repo: "${args.repo}" })\``
|
|
545
|
+
} catch (error) {
|
|
546
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
await withManifestLock(async () => {
|
|
550
|
+
const updatedManifest = await loadManifest()
|
|
551
|
+
delete updatedManifest.repos[repoKey]
|
|
552
|
+
await saveManifest(updatedManifest)
|
|
553
|
+
})
|
|
554
|
+
} catch {}
|
|
555
|
+
|
|
556
|
+
return `## Deletion Failed
|
|
557
|
+
|
|
558
|
+
Failed to delete ${args.repo}: ${message}
|
|
559
|
+
|
|
560
|
+
The repository has been unregistered from the manifest. You may need to manually delete the directory at: ${entry.path}`
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
}),
|
|
564
|
+
|
|
565
|
+
repo_find: tool({
|
|
566
|
+
description:
|
|
567
|
+
"Search for a repository locally and on GitHub. Use this BEFORE cloning to check if a repo already exists locally or to find the correct GitHub repo. Returns location info without cloning.",
|
|
568
|
+
args: {
|
|
569
|
+
query: tool.schema
|
|
570
|
+
.string()
|
|
571
|
+
.describe(
|
|
572
|
+
"Repository name or owner/repo format. Examples: 'next.js', 'vercel/next.js', 'react'"
|
|
573
|
+
),
|
|
574
|
+
},
|
|
575
|
+
async execute(args) {
|
|
576
|
+
const query = args.query.trim()
|
|
577
|
+
const results: {
|
|
578
|
+
registered: Array<{ key: string; path: string; type: string }>
|
|
579
|
+
local: Array<{ path: string; spec: string; branch: string }>
|
|
580
|
+
github: Array<{ fullName: string; description: string; url: string }>
|
|
581
|
+
} = {
|
|
582
|
+
registered: [],
|
|
583
|
+
local: [],
|
|
584
|
+
github: [],
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const manifest = await loadManifest()
|
|
588
|
+
const queryLower = query.toLowerCase()
|
|
589
|
+
|
|
590
|
+
for (const [repoKey, entry] of Object.entries(manifest.repos)) {
|
|
591
|
+
if (repoKey.toLowerCase().includes(queryLower)) {
|
|
592
|
+
results.registered.push({
|
|
593
|
+
key: repoKey,
|
|
594
|
+
path: entry.path,
|
|
595
|
+
type: entry.type,
|
|
596
|
+
})
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const config = await loadConfig()
|
|
601
|
+
if (config?.localSearchPaths?.length) {
|
|
602
|
+
try {
|
|
603
|
+
const localResults = await findLocalRepoByName(
|
|
604
|
+
config.localSearchPaths,
|
|
605
|
+
query
|
|
606
|
+
)
|
|
607
|
+
for (const local of localResults) {
|
|
608
|
+
const alreadyRegistered = results.registered.some(
|
|
609
|
+
(r) => r.path === local.path
|
|
610
|
+
)
|
|
611
|
+
if (!alreadyRegistered) {
|
|
612
|
+
results.local.push({
|
|
613
|
+
path: local.path,
|
|
614
|
+
spec: local.spec,
|
|
615
|
+
branch: local.branch,
|
|
616
|
+
})
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
} catch {}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
try {
|
|
623
|
+
if (query.includes("/")) {
|
|
624
|
+
const repoCheck =
|
|
625
|
+
await $`gh repo view ${query} --json nameWithOwner,description,url 2>/dev/null`.text()
|
|
626
|
+
const repo = JSON.parse(repoCheck)
|
|
627
|
+
results.github.push({
|
|
628
|
+
fullName: repo.nameWithOwner,
|
|
629
|
+
description: repo.description || "",
|
|
630
|
+
url: repo.url,
|
|
631
|
+
})
|
|
632
|
+
} else {
|
|
633
|
+
const searchResult =
|
|
634
|
+
await $`gh search repos ${query} --limit 5 --json fullName,description,url 2>/dev/null`.text()
|
|
635
|
+
const repos = JSON.parse(searchResult)
|
|
636
|
+
for (const repo of repos) {
|
|
637
|
+
results.github.push({
|
|
638
|
+
fullName: repo.fullName,
|
|
639
|
+
description: repo.description || "",
|
|
640
|
+
url: repo.url,
|
|
641
|
+
})
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
} catch {}
|
|
645
|
+
|
|
646
|
+
let output = `## Repository Search: "${query}"\n\n`
|
|
647
|
+
|
|
648
|
+
if (results.registered.length > 0) {
|
|
649
|
+
output += `### Already Registered\n`
|
|
650
|
+
for (const r of results.registered) {
|
|
651
|
+
output += `- **${r.key}** (${r.type})\n Path: ${r.path}\n`
|
|
652
|
+
}
|
|
653
|
+
output += `\n`
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (results.local.length > 0) {
|
|
657
|
+
output += `### Found Locally (not registered)\n`
|
|
658
|
+
for (const r of results.local) {
|
|
659
|
+
output += `- **${r.spec}** @ ${r.branch}\n Path: ${r.path}\n`
|
|
660
|
+
}
|
|
661
|
+
output += `\nUse \`repo_scan()\` to register these.\n\n`
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (results.github.length > 0) {
|
|
665
|
+
output += `### Found on GitHub\n`
|
|
666
|
+
for (const r of results.github) {
|
|
667
|
+
const desc = r.description ? ` - ${r.description.slice(0, 60)}` : ""
|
|
668
|
+
output += `- **${r.fullName}**${desc}\n`
|
|
669
|
+
}
|
|
670
|
+
output += `\nUse \`repo_clone({ repo: "owner/repo" })\` to clone.\n`
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (
|
|
674
|
+
results.registered.length === 0 &&
|
|
675
|
+
results.local.length === 0 &&
|
|
676
|
+
results.github.length === 0
|
|
677
|
+
) {
|
|
678
|
+
output += `No repositories found matching "${query}".\n\n`
|
|
679
|
+
output += `Tips:\n`
|
|
680
|
+
output += `- Try a different search term\n`
|
|
681
|
+
output += `- Use owner/repo format for exact match\n`
|
|
682
|
+
output += `- Check if gh CLI is authenticated\n`
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return output
|
|
686
|
+
},
|
|
687
|
+
}),
|
|
688
|
+
|
|
689
|
+
repo_explore: tool({
|
|
690
|
+
description:
|
|
691
|
+
"Explore a repository to understand its codebase. Spawns a specialized exploration agent that analyzes the repo and answers your question. The agent will read source files, trace code paths, and explain architecture.",
|
|
692
|
+
args: {
|
|
693
|
+
repo: tool.schema
|
|
694
|
+
.string()
|
|
695
|
+
.describe(
|
|
696
|
+
"Repository in format 'owner/repo' or 'owner/repo@branch'"
|
|
697
|
+
),
|
|
698
|
+
question: tool.schema
|
|
699
|
+
.string()
|
|
700
|
+
.describe("What you want to understand about the codebase"),
|
|
701
|
+
},
|
|
702
|
+
async execute(args, ctx) {
|
|
703
|
+
const spec = parseRepoSpec(args.repo)
|
|
704
|
+
const branch = spec.branch || "main"
|
|
705
|
+
const repoKey = `${spec.owner}/${spec.repo}@${branch}`
|
|
706
|
+
|
|
707
|
+
let manifest = await loadManifest()
|
|
708
|
+
let repoPath: string
|
|
709
|
+
|
|
710
|
+
if (!manifest.repos[repoKey]) {
|
|
711
|
+
try {
|
|
712
|
+
repoPath = join(CACHE_DIR, spec.owner, `${spec.repo}@${branch}`)
|
|
713
|
+
const url = buildGitUrl(spec.owner, spec.repo)
|
|
714
|
+
|
|
715
|
+
await withManifestLock(async () => {
|
|
716
|
+
await cloneRepo(url, repoPath, { branch })
|
|
717
|
+
|
|
718
|
+
const now = new Date().toISOString()
|
|
719
|
+
const updatedManifest = await loadManifest()
|
|
720
|
+
updatedManifest.repos[repoKey] = {
|
|
721
|
+
type: "cached",
|
|
722
|
+
path: repoPath,
|
|
723
|
+
clonedAt: now,
|
|
724
|
+
lastAccessed: now,
|
|
725
|
+
lastUpdated: now,
|
|
726
|
+
defaultBranch: branch,
|
|
727
|
+
shallow: true,
|
|
728
|
+
}
|
|
729
|
+
await saveManifest(updatedManifest)
|
|
730
|
+
})
|
|
731
|
+
} catch (error) {
|
|
732
|
+
const message =
|
|
733
|
+
error instanceof Error ? error.message : String(error)
|
|
734
|
+
return `## Failed to clone repository
|
|
735
|
+
|
|
736
|
+
Failed to clone ${args.repo}: ${message}
|
|
737
|
+
|
|
738
|
+
Please check that the repository exists and you have access to it.`
|
|
739
|
+
}
|
|
740
|
+
} else {
|
|
741
|
+
repoPath = manifest.repos[repoKey].path
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const explorationPrompt = `Explore the codebase at ${repoPath} and answer the following question:
|
|
745
|
+
|
|
746
|
+
${args.question}
|
|
747
|
+
|
|
748
|
+
Working directory: ${repoPath}
|
|
749
|
+
|
|
750
|
+
You have access to all standard code exploration tools:
|
|
751
|
+
- read: Read files
|
|
752
|
+
- glob: Find files by pattern
|
|
753
|
+
- grep: Search for patterns
|
|
754
|
+
- bash: Run git commands if needed
|
|
755
|
+
|
|
756
|
+
Remember to:
|
|
757
|
+
- Start with high-level structure (README, package.json, main files)
|
|
758
|
+
- Cite specific files and line numbers
|
|
759
|
+
- Include relevant code snippets
|
|
760
|
+
- Explain how components interact
|
|
761
|
+
`
|
|
762
|
+
|
|
763
|
+
try {
|
|
764
|
+
const response = await client.session.prompt({
|
|
765
|
+
path: { id: ctx.sessionID },
|
|
766
|
+
body: {
|
|
767
|
+
agent: "repo-explorer",
|
|
768
|
+
parts: [{ type: "text", text: explorationPrompt }],
|
|
769
|
+
},
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
await withManifestLock(async () => {
|
|
773
|
+
const updatedManifest = await loadManifest()
|
|
774
|
+
if (updatedManifest.repos[repoKey]) {
|
|
775
|
+
updatedManifest.repos[repoKey].lastAccessed =
|
|
776
|
+
new Date().toISOString()
|
|
777
|
+
await saveManifest(updatedManifest)
|
|
778
|
+
}
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
if (response.error) {
|
|
782
|
+
return `## Exploration failed
|
|
783
|
+
|
|
784
|
+
Error from API: ${JSON.stringify(response.error)}`
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const parts = response.data?.parts || []
|
|
788
|
+
const textParts = parts.filter(p => p.type === "text")
|
|
789
|
+
const texts = textParts.map(p => "text" in p ? p.text : "").filter(Boolean)
|
|
790
|
+
return texts.join("\n\n") || "No response from exploration agent."
|
|
791
|
+
} catch (error) {
|
|
792
|
+
const message =
|
|
793
|
+
error instanceof Error ? error.message : String(error)
|
|
794
|
+
return `## Exploration failed
|
|
795
|
+
|
|
796
|
+
Failed to spawn exploration agent: ${message}
|
|
797
|
+
|
|
798
|
+
This may indicate an issue with the OpenCode session or agent registration.`
|
|
799
|
+
}
|
|
800
|
+
},
|
|
801
|
+
}),
|
|
802
|
+
},
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export default OpencodeRepos
|