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