opencode-repos 0.2.0 → 0.3.1
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/AGENTS.md +180 -0
- package/README.md +103 -3
- package/index.ts +1595 -159
- package/package.json +1 -1
- package/src/__tests__/git.test.ts +40 -4
- package/src/__tests__/manifest.test.ts +5 -5
- package/src/agents/repo-explorer.ts +2 -1
- package/src/git.ts +49 -7
- package/src/manifest.ts +22 -15
- package/.sisyphus/boulder.json +0 -8
- package/.sisyphus/notepads/opencode-repos/decisions.md +0 -15
- package/.sisyphus/notepads/opencode-repos/learnings.md +0 -384
- package/.sisyphus/plans/opencode-repos.md +0 -987
- package/.tmux-sessionizer +0 -8
package/index.ts
CHANGED
|
@@ -1,22 +1,54 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
2
|
import { tool } from "@opencode-ai/plugin"
|
|
3
3
|
import { $ } from "bun"
|
|
4
|
-
import { parseRepoSpec, buildGitUrl, cloneRepo, updateRepo, getRepoInfo } from "./src/git"
|
|
4
|
+
import { parseRepoSpec, buildGitUrl, cloneRepo, updateRepo, switchBranch, getRepoInfo } from "./src/git"
|
|
5
5
|
import { createRepoExplorerAgent } from "./src/agents/repo-explorer"
|
|
6
6
|
import {
|
|
7
7
|
loadManifest,
|
|
8
8
|
saveManifest,
|
|
9
9
|
withManifestLock,
|
|
10
|
+
setCacheDir,
|
|
10
11
|
type RepoEntry,
|
|
11
12
|
} from "./src/manifest"
|
|
12
13
|
import { scanLocalRepos, matchRemoteToSpec, findLocalRepoByName } from "./src/scanner"
|
|
13
|
-
import { homedir } from "node:os"
|
|
14
|
-
import { join } from "node:path"
|
|
14
|
+
import { homedir, tmpdir } from "node:os"
|
|
15
|
+
import { dirname, isAbsolute, join } from "node:path"
|
|
15
16
|
import { existsSync } from "node:fs"
|
|
16
|
-
import { rm, readFile } from "node:fs/promises"
|
|
17
|
+
import { appendFile, mkdir, rm, readFile } from "node:fs/promises"
|
|
17
18
|
|
|
18
19
|
interface Config {
|
|
19
|
-
localSearchPaths
|
|
20
|
+
localSearchPaths?: string[]
|
|
21
|
+
cleanupMaxAgeDays?: number
|
|
22
|
+
cacheDir?: string
|
|
23
|
+
useHttps?: boolean
|
|
24
|
+
autoSyncOnExplore?: boolean
|
|
25
|
+
autoSyncIntervalHours?: number
|
|
26
|
+
defaultBranch?: string
|
|
27
|
+
includeProjectParent?: boolean
|
|
28
|
+
debug?: boolean
|
|
29
|
+
repoExplorerModel?: string
|
|
30
|
+
debugLogPath?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULTS = {
|
|
34
|
+
cleanupMaxAgeDays: 30,
|
|
35
|
+
cacheDir: join(tmpdir(), "opencode-repos"),
|
|
36
|
+
useHttps: false,
|
|
37
|
+
autoSyncOnExplore: true,
|
|
38
|
+
autoSyncIntervalHours: 24,
|
|
39
|
+
defaultBranch: "main",
|
|
40
|
+
includeProjectParent: true,
|
|
41
|
+
debug: false,
|
|
42
|
+
repoExplorerModel: "opencode/grok-code",
|
|
43
|
+
debugLogPath: join(homedir(), ".cache", "opencode-repos", "debug.log"),
|
|
44
|
+
} as const
|
|
45
|
+
|
|
46
|
+
function parseModelString(modelString: string): { providerID: string; modelID: string } | undefined {
|
|
47
|
+
const parts = modelString.split("/")
|
|
48
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
return { providerID: parts[0], modelID: parts[1] }
|
|
20
52
|
}
|
|
21
53
|
|
|
22
54
|
async function loadConfig(): Promise<Config | null> {
|
|
@@ -34,12 +66,865 @@ async function loadConfig(): Promise<Config | null> {
|
|
|
34
66
|
}
|
|
35
67
|
}
|
|
36
68
|
|
|
37
|
-
const CACHE_DIR = join(homedir(), ".cache", "opencode-repos")
|
|
38
69
|
|
|
39
|
-
|
|
70
|
+
|
|
71
|
+
async function runCleanup(maxAgeDays: number): Promise<void> {
|
|
72
|
+
const cutoffMs = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000)
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const manifest = await loadManifest()
|
|
76
|
+
const staleKeys: string[] = []
|
|
77
|
+
|
|
78
|
+
for (const [repoKey, entry] of Object.entries(manifest.repos)) {
|
|
79
|
+
if (entry.type !== "cached") continue
|
|
80
|
+
|
|
81
|
+
const lastAccessedMs = new Date(entry.lastAccessed).getTime()
|
|
82
|
+
if (lastAccessedMs < cutoffMs) {
|
|
83
|
+
staleKeys.push(repoKey)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (staleKeys.length === 0) return
|
|
88
|
+
|
|
89
|
+
await withManifestLock(async () => {
|
|
90
|
+
const updatedManifest = await loadManifest()
|
|
91
|
+
|
|
92
|
+
for (const key of staleKeys) {
|
|
93
|
+
const entry = updatedManifest.repos[key]
|
|
94
|
+
if (!entry || entry.type !== "cached") continue
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await rm(entry.path, { recursive: true, force: true })
|
|
98
|
+
delete updatedManifest.repos[key]
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await saveManifest(updatedManifest)
|
|
103
|
+
})
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveSearchPaths(
|
|
108
|
+
configuredPaths: string[],
|
|
109
|
+
includeProjectParent: boolean,
|
|
110
|
+
projectDirectory?: string
|
|
111
|
+
): string[] {
|
|
112
|
+
const resolved = [...configuredPaths]
|
|
113
|
+
|
|
114
|
+
if (includeProjectParent && projectDirectory) {
|
|
115
|
+
const parentDir = dirname(projectDirectory)
|
|
116
|
+
if (!resolved.includes(parentDir)) {
|
|
117
|
+
resolved.push(parentDir)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return resolved
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function shouldSyncRepo(lastUpdated: string | undefined, intervalHours: number): boolean {
|
|
125
|
+
if (!lastUpdated) return true
|
|
126
|
+
|
|
127
|
+
const lastUpdatedMs = new Date(lastUpdated).getTime()
|
|
128
|
+
if (Number.isNaN(lastUpdatedMs)) return true
|
|
129
|
+
|
|
130
|
+
const intervalMs = intervalHours * 60 * 60 * 1000
|
|
131
|
+
return Date.now() - lastUpdatedMs >= intervalMs
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function expandHomePath(input: string): string {
|
|
135
|
+
if (input.startsWith("~/")) {
|
|
136
|
+
return join(homedir(), input.slice(2))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return input
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function resolveLocalPathQuery(
|
|
143
|
+
query: string,
|
|
144
|
+
defaultBranch: string
|
|
145
|
+
): Promise<{ candidate: RepoCandidate | null; error?: string }> {
|
|
146
|
+
const expanded = expandHomePath(query.trim())
|
|
147
|
+
|
|
148
|
+
if (!isAbsolute(expanded)) {
|
|
149
|
+
return { candidate: null }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!existsSync(expanded)) {
|
|
153
|
+
return { candidate: null, error: `Path does not exist: ${expanded}` }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!existsSync(join(expanded, ".git"))) {
|
|
157
|
+
return { candidate: null, error: `No .git directory found at: ${expanded}` }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const [remote, branch] = await Promise.all([
|
|
162
|
+
$`git -C ${expanded} remote get-url origin`.text(),
|
|
163
|
+
$`git -C ${expanded} branch --show-current`.text(),
|
|
164
|
+
])
|
|
165
|
+
|
|
166
|
+
if (!remote.trim()) {
|
|
167
|
+
return {
|
|
168
|
+
candidate: null,
|
|
169
|
+
error: "Local repository has no origin remote. Add one to use repo_query.",
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const spec = matchRemoteToSpec(remote.trim())
|
|
174
|
+
if (!spec) {
|
|
175
|
+
return {
|
|
176
|
+
candidate: null,
|
|
177
|
+
error: "Origin remote is not a supported GitHub URL.",
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
candidate: {
|
|
183
|
+
key: spec,
|
|
184
|
+
source: "local",
|
|
185
|
+
path: expanded,
|
|
186
|
+
branch: branch.trim() || defaultBranch,
|
|
187
|
+
remote: remote.trim(),
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
192
|
+
return {
|
|
193
|
+
candidate: null,
|
|
194
|
+
error: `Failed to read local repository metadata: ${message}`,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function registerLocalRepo(
|
|
200
|
+
repoKey: string,
|
|
201
|
+
path: string,
|
|
202
|
+
branch: string,
|
|
203
|
+
remote: string
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
await withManifestLock(async () => {
|
|
206
|
+
const manifest = await loadManifest()
|
|
207
|
+
if (manifest.repos[repoKey]) return
|
|
208
|
+
|
|
209
|
+
const now = new Date().toISOString()
|
|
210
|
+
manifest.repos[repoKey] = {
|
|
211
|
+
type: "local",
|
|
212
|
+
path,
|
|
213
|
+
lastAccessed: now,
|
|
214
|
+
currentBranch: branch,
|
|
215
|
+
shallow: false,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
manifest.localIndex[remote] = path
|
|
219
|
+
await saveManifest(manifest)
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function resolveCandidates(
|
|
224
|
+
query: string,
|
|
225
|
+
searchPaths: string[],
|
|
226
|
+
manifest: Awaited<ReturnType<typeof loadManifest>>,
|
|
227
|
+
defaultBranch: string,
|
|
228
|
+
allowGithub: boolean
|
|
229
|
+
): Promise<{
|
|
230
|
+
candidates: RepoCandidate[]
|
|
231
|
+
exactRepoKey: string | null
|
|
232
|
+
branchOverride: string | null
|
|
233
|
+
}> {
|
|
234
|
+
const trimmed = query.trim()
|
|
235
|
+
let exactRepoKey: string | null = null
|
|
236
|
+
let branchOverride: string | null = null
|
|
237
|
+
|
|
238
|
+
if (trimmed.includes("/")) {
|
|
239
|
+
try {
|
|
240
|
+
const spec = parseRepoSpec(trimmed)
|
|
241
|
+
exactRepoKey = `${spec.owner}/${spec.repo}`
|
|
242
|
+
branchOverride = spec.branch ?? null
|
|
243
|
+
} catch {
|
|
244
|
+
exactRepoKey = null
|
|
245
|
+
branchOverride = null
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const queryLower = trimmed.toLowerCase()
|
|
250
|
+
const candidates: RepoCandidate[] = []
|
|
251
|
+
|
|
252
|
+
for (const [repoKey, entry] of Object.entries(manifest.repos)) {
|
|
253
|
+
if (exactRepoKey) {
|
|
254
|
+
if (repoKey.toLowerCase() !== exactRepoKey.toLowerCase()) continue
|
|
255
|
+
candidates.push({
|
|
256
|
+
key: repoKey,
|
|
257
|
+
source: "registered",
|
|
258
|
+
path: entry.path,
|
|
259
|
+
branch: entry.currentBranch,
|
|
260
|
+
})
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (repoKey.toLowerCase().includes(queryLower)) {
|
|
265
|
+
candidates.push({
|
|
266
|
+
key: repoKey,
|
|
267
|
+
source: "registered",
|
|
268
|
+
path: entry.path,
|
|
269
|
+
branch: entry.currentBranch,
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (searchPaths.length > 0) {
|
|
275
|
+
try {
|
|
276
|
+
const localResults = await findLocalRepoByName(searchPaths, trimmed)
|
|
277
|
+
for (const local of localResults) {
|
|
278
|
+
if (exactRepoKey && local.spec.toLowerCase() !== exactRepoKey.toLowerCase()) {
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
candidates.push({
|
|
283
|
+
key: local.spec,
|
|
284
|
+
source: "local",
|
|
285
|
+
path: local.path,
|
|
286
|
+
branch: local.branch || defaultBranch,
|
|
287
|
+
remote: local.remote,
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
} catch {}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (allowGithub) {
|
|
294
|
+
try {
|
|
295
|
+
if (exactRepoKey) {
|
|
296
|
+
const repoCheck =
|
|
297
|
+
await $`gh repo view ${exactRepoKey} --json nameWithOwner,description,url 2>/dev/null`.text()
|
|
298
|
+
const repo = JSON.parse(repoCheck)
|
|
299
|
+
candidates.push({
|
|
300
|
+
key: repo.nameWithOwner,
|
|
301
|
+
source: "github",
|
|
302
|
+
description: repo.description || "",
|
|
303
|
+
url: repo.url,
|
|
304
|
+
branch: branchOverride ?? defaultBranch,
|
|
305
|
+
})
|
|
306
|
+
} else {
|
|
307
|
+
const searchResult =
|
|
308
|
+
await $`gh search repos ${trimmed} --limit 5 --json fullName,description,url 2>/dev/null`.text()
|
|
309
|
+
const repos = JSON.parse(searchResult)
|
|
310
|
+
for (const repo of repos) {
|
|
311
|
+
candidates.push({
|
|
312
|
+
key: repo.fullName,
|
|
313
|
+
source: "github",
|
|
314
|
+
description: repo.description || "",
|
|
315
|
+
url: repo.url,
|
|
316
|
+
branch: defaultBranch,
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch {}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
candidates: uniqueCandidates(candidates),
|
|
325
|
+
exactRepoKey,
|
|
326
|
+
branchOverride,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function ensureRepoAvailable(
|
|
331
|
+
repoKey: string,
|
|
332
|
+
branch: string,
|
|
333
|
+
cacheDir: string,
|
|
334
|
+
useHttps: boolean,
|
|
335
|
+
autoSyncOnExplore: boolean,
|
|
336
|
+
autoSyncIntervalHours: number
|
|
337
|
+
): Promise<{
|
|
338
|
+
repoPath: string
|
|
339
|
+
branch: string
|
|
340
|
+
type: "cached" | "local"
|
|
341
|
+
}> {
|
|
342
|
+
let manifest = await loadManifest()
|
|
343
|
+
const entry = manifest.repos[repoKey]
|
|
344
|
+
|
|
345
|
+
if (!entry) {
|
|
346
|
+
const [owner, repo] = repoKey.split("/")
|
|
347
|
+
const repoPath = join(cacheDir, owner, repo)
|
|
348
|
+
const url = buildGitUrl(owner, repo, useHttps)
|
|
349
|
+
|
|
350
|
+
let actualBranch = branch
|
|
351
|
+
try {
|
|
352
|
+
await withManifestLock(async () => {
|
|
353
|
+
const cloneResult = await cloneRepo(url, repoPath, { branch })
|
|
354
|
+
actualBranch = cloneResult.branch
|
|
355
|
+
|
|
356
|
+
const now = new Date().toISOString()
|
|
357
|
+
const updatedManifest = await loadManifest()
|
|
358
|
+
updatedManifest.repos[repoKey] = {
|
|
359
|
+
type: "cached",
|
|
360
|
+
path: repoPath,
|
|
361
|
+
clonedAt: now,
|
|
362
|
+
lastAccessed: now,
|
|
363
|
+
lastUpdated: now,
|
|
364
|
+
currentBranch: actualBranch,
|
|
365
|
+
shallow: true,
|
|
366
|
+
}
|
|
367
|
+
await saveManifest(updatedManifest)
|
|
368
|
+
})
|
|
369
|
+
} catch (error) {
|
|
370
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
371
|
+
throw new Error(`Clone failed for ${repoKey}@${branch}: ${message}`)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
repoPath,
|
|
376
|
+
branch: actualBranch,
|
|
377
|
+
type: "cached",
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const repoPath = entry.path
|
|
382
|
+
|
|
383
|
+
if (entry.type === "cached") {
|
|
384
|
+
try {
|
|
385
|
+
if (entry.currentBranch !== branch) {
|
|
386
|
+
await switchBranch(repoPath, branch)
|
|
387
|
+
await withManifestLock(async () => {
|
|
388
|
+
const updatedManifest = await loadManifest()
|
|
389
|
+
if (updatedManifest.repos[repoKey]) {
|
|
390
|
+
updatedManifest.repos[repoKey].currentBranch = branch
|
|
391
|
+
updatedManifest.repos[repoKey].lastUpdated = new Date().toISOString()
|
|
392
|
+
await saveManifest(updatedManifest)
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
} else if (autoSyncOnExplore && shouldSyncRepo(entry.lastUpdated, autoSyncIntervalHours)) {
|
|
396
|
+
await updateRepo(repoPath)
|
|
397
|
+
await withManifestLock(async () => {
|
|
398
|
+
const updatedManifest = await loadManifest()
|
|
399
|
+
if (updatedManifest.repos[repoKey]) {
|
|
400
|
+
updatedManifest.repos[repoKey].lastUpdated = new Date().toISOString()
|
|
401
|
+
await saveManifest(updatedManifest)
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
} catch (error) {
|
|
406
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
407
|
+
throw new Error(`Update failed for ${repoKey}@${branch}: ${message}`)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
repoPath,
|
|
413
|
+
branch,
|
|
414
|
+
type: entry.type,
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function runRepoExplorer(
|
|
419
|
+
client: RepoClient,
|
|
420
|
+
ctx: RepoToolContext,
|
|
421
|
+
repoKey: string,
|
|
422
|
+
repoPath: string,
|
|
423
|
+
question: string,
|
|
424
|
+
model?: string,
|
|
425
|
+
parentDirectory?: string
|
|
426
|
+
): Promise<string> {
|
|
427
|
+
let sessionID: string | undefined
|
|
428
|
+
|
|
429
|
+
// Validate that repo-explorer agent is available
|
|
430
|
+
try {
|
|
431
|
+
const agentsResult = await client.app.agents()
|
|
432
|
+
const agents = agentsResult.data ?? []
|
|
433
|
+
const agentNames = agents.map((a) => a.name)
|
|
434
|
+
|
|
435
|
+
if (debugLogger) {
|
|
436
|
+
debugLogger("repo_explore", [
|
|
437
|
+
`repoKey: ${repoKey}`,
|
|
438
|
+
`availableAgents: ${agentNames.join(", ") || "none"}`,
|
|
439
|
+
`repoExplorerExists: ${agentNames.includes("repo-explorer")}`,
|
|
440
|
+
])
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!agentNames.includes("repo-explorer")) {
|
|
444
|
+
return `## Exploration failed
|
|
445
|
+
|
|
446
|
+
Repository: ${repoKey}
|
|
447
|
+
Path: ${repoPath}
|
|
448
|
+
|
|
449
|
+
The repo-explorer agent is not available. Available agents: ${agentNames.join(", ") || "none"}
|
|
450
|
+
|
|
451
|
+
This may indicate the opencode-repos plugin is not properly loaded.`
|
|
452
|
+
}
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (debugLogger) {
|
|
455
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
456
|
+
debugLogger("repo_explore", [
|
|
457
|
+
`repoKey: ${repoKey}`,
|
|
458
|
+
`agentValidationError: ${message}`,
|
|
459
|
+
])
|
|
460
|
+
}
|
|
461
|
+
// Continue anyway - the session.prompt will fail with a clearer error if agent doesn't exist
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Use parent directory for session creation (like oh-my-opencode does)
|
|
465
|
+
// The repoPath will be passed in the prompt context instead
|
|
466
|
+
const sessionDirectory = parentDirectory ?? repoPath
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
const createResult = await client.session.create({
|
|
470
|
+
body: {
|
|
471
|
+
parentID: ctx.sessionID,
|
|
472
|
+
title: `Repo explorer: ${repoKey}`,
|
|
473
|
+
},
|
|
474
|
+
query: {
|
|
475
|
+
directory: sessionDirectory,
|
|
476
|
+
},
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
sessionID = createResult.data?.id
|
|
480
|
+
} catch (error) {
|
|
481
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
482
|
+
return `## Exploration failed
|
|
483
|
+
|
|
484
|
+
Repository: ${repoKey}
|
|
485
|
+
Path: ${repoPath}
|
|
486
|
+
|
|
487
|
+
Failed to create a subagent session: ${message}`
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (!sessionID) {
|
|
491
|
+
return `## Exploration failed
|
|
492
|
+
|
|
493
|
+
Repository: ${repoKey}
|
|
494
|
+
Path: ${repoPath}
|
|
495
|
+
|
|
496
|
+
Failed to create a subagent session (no session ID returned).`
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const explorationPrompt = `Index the codebase and answer the following question:
|
|
500
|
+
|
|
501
|
+
${question}
|
|
502
|
+
|
|
503
|
+
Working directory: ${repoPath}
|
|
504
|
+
|
|
505
|
+
You have access to all standard code exploration tools:
|
|
506
|
+
- read: Read files
|
|
507
|
+
- glob: Find files by pattern
|
|
508
|
+
- grep: Search for patterns
|
|
509
|
+
- bash: Run git commands if needed
|
|
510
|
+
|
|
511
|
+
Remember to:
|
|
512
|
+
- Start with high-level structure (README, package.json, main files)
|
|
513
|
+
- Cite specific files and line numbers
|
|
514
|
+
- Include relevant code snippets
|
|
515
|
+
- Explain how components interact
|
|
516
|
+
`
|
|
517
|
+
|
|
518
|
+
await Bun.sleep(150)
|
|
519
|
+
|
|
520
|
+
let promptAttempt = 0
|
|
521
|
+
const promptMaxAttempts = 3
|
|
522
|
+
|
|
523
|
+
const parsedModel = model ? parseModelString(model) : undefined
|
|
524
|
+
|
|
525
|
+
while (promptAttempt < promptMaxAttempts) {
|
|
526
|
+
try {
|
|
527
|
+
if (debugLogger) {
|
|
528
|
+
debugLogger("repo_explore", [
|
|
529
|
+
`repoKey: ${repoKey}`,
|
|
530
|
+
`sessionID: ${sessionID}`,
|
|
531
|
+
`promptSending: attempt ${promptAttempt + 1}`,
|
|
532
|
+
`model: ${parsedModel ? `${parsedModel.providerID}/${parsedModel.modelID}` : "default"}`,
|
|
533
|
+
])
|
|
534
|
+
}
|
|
535
|
+
await client.session.prompt({
|
|
536
|
+
path: { id: sessionID },
|
|
537
|
+
body: {
|
|
538
|
+
agent: "repo-explorer",
|
|
539
|
+
tools: {
|
|
540
|
+
task: false,
|
|
541
|
+
delegate_task: false,
|
|
542
|
+
},
|
|
543
|
+
parts: [{ type: "text", text: explorationPrompt }],
|
|
544
|
+
...(parsedModel ? { model: parsedModel } : {}),
|
|
545
|
+
},
|
|
546
|
+
})
|
|
547
|
+
if (debugLogger) {
|
|
548
|
+
debugLogger("repo_explore", [
|
|
549
|
+
`repoKey: ${repoKey}`,
|
|
550
|
+
`sessionID: ${sessionID}`,
|
|
551
|
+
`promptSent: success`,
|
|
552
|
+
])
|
|
553
|
+
}
|
|
554
|
+
break
|
|
555
|
+
} catch (error) {
|
|
556
|
+
promptAttempt += 1
|
|
557
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
558
|
+
if (debugLogger) {
|
|
559
|
+
debugLogger("repo_explore", [
|
|
560
|
+
`repoKey: ${repoKey}`,
|
|
561
|
+
`sessionID: ${sessionID}`,
|
|
562
|
+
`promptError: ${message}`,
|
|
563
|
+
`promptAttempt: ${promptAttempt}`,
|
|
564
|
+
])
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (promptAttempt >= promptMaxAttempts) {
|
|
568
|
+
return `## Exploration failed
|
|
569
|
+
|
|
570
|
+
Repository: ${repoKey}
|
|
571
|
+
Session: ${sessionID}
|
|
572
|
+
Path: ${repoPath}
|
|
573
|
+
|
|
574
|
+
Failed to run exploration agent: ${message}`
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
await Bun.sleep(300 * promptAttempt)
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const pollStart = Date.now()
|
|
582
|
+
const maxPollMs = 5 * 60 * 1000
|
|
583
|
+
const pollIntervalMs = 500
|
|
584
|
+
const minStabilityTimeMs = 10000
|
|
585
|
+
const stabilityPollsRequired = 3
|
|
586
|
+
let lastMsgCount = 0
|
|
587
|
+
let stablePolls = 0
|
|
588
|
+
let pollCount = 0
|
|
589
|
+
|
|
590
|
+
if (debugLogger) {
|
|
591
|
+
debugLogger("repo_explore", [
|
|
592
|
+
`repoKey: ${repoKey}`,
|
|
593
|
+
`sessionID: ${sessionID}`,
|
|
594
|
+
`pollStart: starting poll loop`,
|
|
595
|
+
])
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
while (Date.now() - pollStart < maxPollMs) {
|
|
599
|
+
await Bun.sleep(pollIntervalMs)
|
|
600
|
+
pollCount++
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
const statusResult = await client.session.status()
|
|
604
|
+
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
|
605
|
+
const sessionStatus = allStatuses[sessionID]
|
|
606
|
+
|
|
607
|
+
if (pollCount % 10 === 0 && debugLogger) {
|
|
608
|
+
debugLogger("repo_explore", [
|
|
609
|
+
`repoKey: ${repoKey}`,
|
|
610
|
+
`sessionID: ${sessionID}`,
|
|
611
|
+
`pollCount: ${pollCount}`,
|
|
612
|
+
`elapsed: ${Math.floor((Date.now() - pollStart) / 1000)}s`,
|
|
613
|
+
`sessionStatus: ${sessionStatus?.type ?? "not_in_status"}`,
|
|
614
|
+
`stablePolls: ${stablePolls}`,
|
|
615
|
+
`lastMsgCount: ${lastMsgCount}`,
|
|
616
|
+
])
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (sessionStatus && sessionStatus.type !== "idle") {
|
|
620
|
+
stablePolls = 0
|
|
621
|
+
lastMsgCount = 0
|
|
622
|
+
continue
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const elapsed = Date.now() - pollStart
|
|
626
|
+
if (elapsed < minStabilityTimeMs) {
|
|
627
|
+
continue
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const messagesResult = await client.session.messages({ path: { id: sessionID } })
|
|
631
|
+
const messages = messagesResult.data ?? []
|
|
632
|
+
const currentMsgCount = messages.length
|
|
633
|
+
|
|
634
|
+
if (currentMsgCount === lastMsgCount) {
|
|
635
|
+
stablePolls += 1
|
|
636
|
+
if (stablePolls >= stabilityPollsRequired) {
|
|
637
|
+
if (debugLogger) {
|
|
638
|
+
debugLogger("repo_explore", [
|
|
639
|
+
`repoKey: ${repoKey}`,
|
|
640
|
+
`sessionID: ${sessionID}`,
|
|
641
|
+
`pollComplete: messages stable`,
|
|
642
|
+
`currentMsgCount: ${currentMsgCount}`,
|
|
643
|
+
])
|
|
644
|
+
}
|
|
645
|
+
break
|
|
646
|
+
}
|
|
647
|
+
} else {
|
|
648
|
+
stablePolls = 0
|
|
649
|
+
lastMsgCount = currentMsgCount
|
|
650
|
+
}
|
|
651
|
+
} catch (error) {
|
|
652
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
653
|
+
if (debugLogger) {
|
|
654
|
+
debugLogger("repo_explore", [
|
|
655
|
+
`repoKey: ${repoKey}`,
|
|
656
|
+
`sessionID: ${sessionID}`,
|
|
657
|
+
`pollError: ${message}`,
|
|
658
|
+
])
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (Date.now() - pollStart >= maxPollMs) {
|
|
664
|
+
return `## Exploration failed
|
|
665
|
+
|
|
666
|
+
Repository: ${repoKey}
|
|
667
|
+
Session: ${sessionID}
|
|
668
|
+
Path: ${repoPath}
|
|
669
|
+
|
|
670
|
+
Timed out waiting for subagent output.`
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const messagesResult = await client.session.messages({ path: { id: sessionID } })
|
|
674
|
+
const messages = messagesResult.data ?? []
|
|
675
|
+
const extracted: string[] = []
|
|
676
|
+
|
|
677
|
+
for (const message of messages) {
|
|
678
|
+
const parts = message.parts ?? []
|
|
679
|
+
for (const part of parts) {
|
|
680
|
+
if (
|
|
681
|
+
typeof part === "object" &&
|
|
682
|
+
part &&
|
|
683
|
+
"type" in part &&
|
|
684
|
+
(part as { type: string }).type === "text" &&
|
|
685
|
+
"text" in part
|
|
686
|
+
) {
|
|
687
|
+
const textValue = (part as { text?: string }).text
|
|
688
|
+
if (textValue) extracted.push(textValue)
|
|
689
|
+
} else if (
|
|
690
|
+
typeof part === "object" &&
|
|
691
|
+
part &&
|
|
692
|
+
"type" in part &&
|
|
693
|
+
(part as { type: string }).type === "tool_result"
|
|
694
|
+
) {
|
|
695
|
+
const toolResult = part as { content?: unknown }
|
|
696
|
+
if (typeof toolResult.content === "string") {
|
|
697
|
+
extracted.push(toolResult.content)
|
|
698
|
+
} else if (Array.isArray(toolResult.content)) {
|
|
699
|
+
for (const block of toolResult.content) {
|
|
700
|
+
if (block && typeof block === "object" && "text" in block) {
|
|
701
|
+
const blockText = (block as { text?: string }).text
|
|
702
|
+
if (blockText) extracted.push(blockText)
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const responseText = extracted.filter(Boolean).join("\n\n")
|
|
711
|
+
return responseText || "No response from exploration agent."
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
interface RepoToolContext {
|
|
715
|
+
sessionID: string
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
interface PermissionContext {
|
|
719
|
+
ask: (input: {
|
|
720
|
+
permission: "external_directory"
|
|
721
|
+
patterns: string[]
|
|
722
|
+
always: string[]
|
|
723
|
+
metadata: Record<string, string>
|
|
724
|
+
}) => Promise<void>
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
interface RepoClient {
|
|
728
|
+
session: {
|
|
729
|
+
create: (input: {
|
|
730
|
+
body: { parentID?: string; title?: string }
|
|
731
|
+
query?: { directory?: string }
|
|
732
|
+
}) => Promise<{ data?: { id?: string } }>
|
|
733
|
+
get: (input: { path: { id: string } }) => Promise<{ data?: { id?: string; directory?: string } }>
|
|
734
|
+
status: () => Promise<{ data?: Record<string, { type: string }> }>
|
|
735
|
+
messages: (input: { path: { id: string } }) => Promise<{ data?: Array<{ parts?: unknown[] }> }>
|
|
736
|
+
prompt: (input: {
|
|
737
|
+
path: { id: string }
|
|
738
|
+
body: {
|
|
739
|
+
agent: string
|
|
740
|
+
parts: Array<{ type: "text"; text: string }>
|
|
741
|
+
tools?: Record<string, boolean>
|
|
742
|
+
model?: { providerID: string; modelID: string }
|
|
743
|
+
}
|
|
744
|
+
}) => Promise<{
|
|
745
|
+
error?: unknown
|
|
746
|
+
data?: { parts?: Array<{ type: string; text?: string }> }
|
|
747
|
+
}>
|
|
748
|
+
}
|
|
749
|
+
app: {
|
|
750
|
+
agents: () => Promise<{ data?: Array<{ name: string; mode?: string }> }>
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
type RepoSource = "registered" | "local" | "github"
|
|
755
|
+
|
|
756
|
+
interface RepoCandidate {
|
|
757
|
+
key: string
|
|
758
|
+
source: RepoSource
|
|
759
|
+
path?: string
|
|
760
|
+
branch?: string
|
|
761
|
+
description?: string
|
|
762
|
+
url?: string
|
|
763
|
+
remote?: string
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
interface RepoQueryDebugInfo {
|
|
767
|
+
query: string
|
|
768
|
+
allowGithub: boolean
|
|
769
|
+
localSearchPaths: string[]
|
|
770
|
+
candidates: RepoCandidate[]
|
|
771
|
+
selectedTargets: Array<{ repoKey: string; branch: string }>
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function uniqueCandidates(candidates: RepoCandidate[]): RepoCandidate[] {
|
|
775
|
+
const map = new Map<string, RepoCandidate>()
|
|
776
|
+
for (const candidate of candidates) {
|
|
777
|
+
if (!map.has(candidate.key)) {
|
|
778
|
+
map.set(candidate.key, candidate)
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return Array.from(map.values())
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function touchRepoAccess(repoKey: string): Promise<void> {
|
|
785
|
+
await withManifestLock(async () => {
|
|
786
|
+
const updatedManifest = await loadManifest()
|
|
787
|
+
if (updatedManifest.repos[repoKey]) {
|
|
788
|
+
updatedManifest.repos[repoKey].lastAccessed = new Date().toISOString()
|
|
789
|
+
await saveManifest(updatedManifest)
|
|
790
|
+
}
|
|
791
|
+
})
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function formatRepoQueryDebug(info: RepoQueryDebugInfo): string {
|
|
795
|
+
let output = "## Debug\n\n"
|
|
796
|
+
output += `Query: ${info.query}\n`
|
|
797
|
+
output += `GitHub search enabled: ${info.allowGithub}\n`
|
|
798
|
+
output += `Local search paths: ${info.localSearchPaths.length}\n`
|
|
799
|
+
for (const path of info.localSearchPaths) {
|
|
800
|
+
output += `- ${path}\n`
|
|
801
|
+
}
|
|
802
|
+
output += "\nCandidates:\n"
|
|
803
|
+
if (info.candidates.length === 0) {
|
|
804
|
+
output += "- none\n"
|
|
805
|
+
} else {
|
|
806
|
+
for (const candidate of info.candidates) {
|
|
807
|
+
output += `- ${candidate.key} (${candidate.source})\n`
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
output += "\nSelected targets:\n"
|
|
811
|
+
if (info.selectedTargets.length === 0) {
|
|
812
|
+
output += "- none\n"
|
|
813
|
+
} else {
|
|
814
|
+
for (const target of info.selectedTargets) {
|
|
815
|
+
output += `- ${target.repoKey} @ ${target.branch}\n`
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return output
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
type DebugLogger = (toolName: string, lines: string[]) => void
|
|
822
|
+
|
|
823
|
+
let debugLogger: DebugLogger | null = null
|
|
824
|
+
|
|
825
|
+
function appendDebug(
|
|
826
|
+
output: string,
|
|
827
|
+
toolName: string,
|
|
828
|
+
lines: string[],
|
|
829
|
+
enabled: boolean
|
|
830
|
+
): string {
|
|
831
|
+
if (!enabled) return output
|
|
832
|
+
|
|
833
|
+
if (debugLogger) {
|
|
834
|
+
debugLogger(toolName, lines)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
let section = `\n\n## Debug\n\nTool: ${toolName}\n`
|
|
838
|
+
for (const line of lines) {
|
|
839
|
+
section += `- ${line}\n`
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return `${output}${section}`
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function createDebugLogger(path: string, enabled: boolean): DebugLogger | null {
|
|
846
|
+
if (!enabled) return null
|
|
847
|
+
|
|
848
|
+
return (toolName, lines) => {
|
|
849
|
+
const timestamp = new Date().toISOString()
|
|
850
|
+
const payload = [
|
|
851
|
+
`[${timestamp}] ${toolName}`,
|
|
852
|
+
...lines.map((line) => `- ${line}`),
|
|
853
|
+
"",
|
|
854
|
+
].join("\n")
|
|
855
|
+
|
|
856
|
+
void appendFile(path, payload)
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function logRepoQueryDebug(info: RepoQueryDebugInfo): void {
|
|
861
|
+
if (!debugLogger) return
|
|
862
|
+
|
|
863
|
+
const lines: string[] = [
|
|
864
|
+
`query: ${info.query}`,
|
|
865
|
+
`allowGithub: ${info.allowGithub}`,
|
|
866
|
+
`localSearchPaths: ${info.localSearchPaths.length}`,
|
|
867
|
+
`candidates: ${info.candidates.map((c) => c.key).join(", ") || "none"}`,
|
|
868
|
+
`selected: ${info.selectedTargets.map((t) => t.repoKey).join(", ") || "none"}`,
|
|
869
|
+
]
|
|
870
|
+
|
|
871
|
+
debugLogger("repo_query", lines)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function requestExternalDirectoryAccess(
|
|
875
|
+
ctx: PermissionContext,
|
|
876
|
+
targetPath: string
|
|
877
|
+
): Promise<void> {
|
|
878
|
+
const parentDir = dirname(targetPath)
|
|
879
|
+
const glob = join(parentDir, "*")
|
|
880
|
+
await ctx.ask({
|
|
881
|
+
permission: "external_directory",
|
|
882
|
+
patterns: [glob],
|
|
883
|
+
always: [glob],
|
|
884
|
+
metadata: {
|
|
885
|
+
filepath: targetPath,
|
|
886
|
+
parentDir,
|
|
887
|
+
},
|
|
888
|
+
})
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export const OpencodeRepos: Plugin = async ({ client, directory }) => {
|
|
892
|
+
const userConfig = await loadConfig()
|
|
893
|
+
|
|
894
|
+
const cacheDir = userConfig?.cacheDir ?? DEFAULTS.cacheDir
|
|
895
|
+
const useHttps = userConfig?.useHttps ?? DEFAULTS.useHttps
|
|
896
|
+
const autoSyncOnExplore = userConfig?.autoSyncOnExplore ?? DEFAULTS.autoSyncOnExplore
|
|
897
|
+
const autoSyncIntervalHours =
|
|
898
|
+
userConfig?.autoSyncIntervalHours ?? DEFAULTS.autoSyncIntervalHours
|
|
899
|
+
const defaultBranch = userConfig?.defaultBranch ?? DEFAULTS.defaultBranch
|
|
900
|
+
const cleanupMaxAgeDays = userConfig?.cleanupMaxAgeDays ?? DEFAULTS.cleanupMaxAgeDays
|
|
901
|
+
const includeProjectParent =
|
|
902
|
+
userConfig?.includeProjectParent ?? DEFAULTS.includeProjectParent
|
|
903
|
+
const debugEnabled = userConfig?.debug ?? DEFAULTS.debug
|
|
904
|
+
const repoExplorerModel =
|
|
905
|
+
userConfig?.repoExplorerModel ?? DEFAULTS.repoExplorerModel
|
|
906
|
+
const debugLogPath = expandHomePath(
|
|
907
|
+
userConfig?.debugLogPath ?? DEFAULTS.debugLogPath
|
|
908
|
+
)
|
|
909
|
+
const localSearchPaths = resolveSearchPaths(
|
|
910
|
+
userConfig?.localSearchPaths ?? [],
|
|
911
|
+
includeProjectParent,
|
|
912
|
+
directory
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
setCacheDir(cacheDir)
|
|
916
|
+
|
|
917
|
+
if (debugEnabled) {
|
|
918
|
+
await mkdir(dirname(debugLogPath), { recursive: true })
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
debugLogger = createDebugLogger(debugLogPath, debugEnabled)
|
|
922
|
+
|
|
923
|
+
runCleanup(cleanupMaxAgeDays)
|
|
924
|
+
|
|
40
925
|
return {
|
|
41
926
|
config: async (config) => {
|
|
42
|
-
const explorerAgent = createRepoExplorerAgent()
|
|
927
|
+
const explorerAgent = createRepoExplorerAgent(repoExplorerModel)
|
|
43
928
|
config.agent = {
|
|
44
929
|
...config.agent,
|
|
45
930
|
"repo-explorer": explorerAgent,
|
|
@@ -57,7 +942,7 @@ When user mentions another project or asks about external code:
|
|
|
57
942
|
tool: {
|
|
58
943
|
repo_clone: tool({
|
|
59
944
|
description:
|
|
60
|
-
"Clone a repository to local cache or return path if already cached. Supports public and private
|
|
945
|
+
"Clone a repository to local cache or return path if already cached. Supports public and private repos. Example: repo_clone({ repo: 'vercel/next.js' }) or repo_clone({ repo: 'vercel/next.js@canary', force: true })",
|
|
61
946
|
args: {
|
|
62
947
|
repo: tool.schema
|
|
63
948
|
.string()
|
|
@@ -72,30 +957,32 @@ When user mentions another project or asks about external code:
|
|
|
72
957
|
},
|
|
73
958
|
async execute(args) {
|
|
74
959
|
const spec = parseRepoSpec(args.repo)
|
|
75
|
-
const branch = spec.branch ||
|
|
76
|
-
const repoKey = `${spec.owner}/${spec.repo}
|
|
960
|
+
const branch = spec.branch || defaultBranch
|
|
961
|
+
const repoKey = `${spec.owner}/${spec.repo}`
|
|
77
962
|
|
|
78
963
|
const result = await withManifestLock(async () => {
|
|
79
964
|
const manifest = await loadManifest()
|
|
80
|
-
|
|
81
965
|
const existingEntry = manifest.repos[repoKey]
|
|
966
|
+
const destPath = join(cacheDir, spec.owner, spec.repo)
|
|
967
|
+
|
|
82
968
|
if (existingEntry && !args.force) {
|
|
969
|
+
if (existingEntry.currentBranch !== branch) {
|
|
970
|
+
await switchBranch(existingEntry.path, branch)
|
|
971
|
+
existingEntry.currentBranch = branch
|
|
972
|
+
existingEntry.lastUpdated = new Date().toISOString()
|
|
973
|
+
}
|
|
83
974
|
existingEntry.lastAccessed = new Date().toISOString()
|
|
84
975
|
await saveManifest(manifest)
|
|
85
976
|
|
|
86
977
|
return {
|
|
87
978
|
path: existingEntry.path,
|
|
979
|
+
branch,
|
|
88
980
|
status: "cached" as const,
|
|
89
981
|
alreadyExists: true,
|
|
90
982
|
}
|
|
91
983
|
}
|
|
92
984
|
|
|
93
|
-
const
|
|
94
|
-
CACHE_DIR,
|
|
95
|
-
spec.owner,
|
|
96
|
-
`${spec.repo}@${branch}`
|
|
97
|
-
)
|
|
98
|
-
const url = buildGitUrl(spec.owner, spec.repo)
|
|
985
|
+
const url = buildGitUrl(spec.owner, spec.repo, useHttps)
|
|
99
986
|
|
|
100
987
|
if (args.force && existingEntry) {
|
|
101
988
|
try {
|
|
@@ -103,8 +990,10 @@ When user mentions another project or asks about external code:
|
|
|
103
990
|
} catch {}
|
|
104
991
|
}
|
|
105
992
|
|
|
993
|
+
let actualBranch = branch
|
|
106
994
|
try {
|
|
107
|
-
await cloneRepo(url, destPath, { branch })
|
|
995
|
+
const cloneResult = await cloneRepo(url, destPath, { branch })
|
|
996
|
+
actualBranch = cloneResult.branch
|
|
108
997
|
} catch (error) {
|
|
109
998
|
const message =
|
|
110
999
|
error instanceof Error ? error.message : String(error)
|
|
@@ -118,7 +1007,7 @@ When user mentions another project or asks about external code:
|
|
|
118
1007
|
clonedAt: now,
|
|
119
1008
|
lastAccessed: now,
|
|
120
1009
|
lastUpdated: now,
|
|
121
|
-
|
|
1010
|
+
currentBranch: actualBranch,
|
|
122
1011
|
shallow: true,
|
|
123
1012
|
}
|
|
124
1013
|
manifest.repos[repoKey] = entry
|
|
@@ -127,6 +1016,7 @@ When user mentions another project or asks about external code:
|
|
|
127
1016
|
|
|
128
1017
|
return {
|
|
129
1018
|
path: destPath,
|
|
1019
|
+
branch: actualBranch,
|
|
130
1020
|
status: "cloned" as const,
|
|
131
1021
|
alreadyExists: false,
|
|
132
1022
|
}
|
|
@@ -136,13 +1026,30 @@ When user mentions another project or asks about external code:
|
|
|
136
1026
|
? "Repository already cached"
|
|
137
1027
|
: "Successfully cloned repository"
|
|
138
1028
|
|
|
139
|
-
|
|
1029
|
+
let output = `## ${statusText}
|
|
140
1030
|
|
|
141
|
-
**Repository**: ${
|
|
1031
|
+
**Repository**: ${repoKey}
|
|
1032
|
+
**Branch**: ${result.branch}
|
|
142
1033
|
**Path**: ${result.path}
|
|
143
1034
|
**Status**: ${result.status}
|
|
144
1035
|
|
|
145
1036
|
You can now use \`repo_read\` to access files from this repository.`
|
|
1037
|
+
|
|
1038
|
+
output = appendDebug(
|
|
1039
|
+
output,
|
|
1040
|
+
"repo_clone",
|
|
1041
|
+
[
|
|
1042
|
+
`repoKey: ${repoKey}`,
|
|
1043
|
+
`branch: ${result.branch}`,
|
|
1044
|
+
`path: ${result.path}`,
|
|
1045
|
+
`cacheDir: ${cacheDir}`,
|
|
1046
|
+
`useHttps: ${useHttps}`,
|
|
1047
|
+
`force: ${args.force}`,
|
|
1048
|
+
],
|
|
1049
|
+
debugEnabled
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
return output
|
|
146
1053
|
},
|
|
147
1054
|
}),
|
|
148
1055
|
|
|
@@ -166,37 +1073,62 @@ You can now use \`repo_read\` to access files from this repository.`
|
|
|
166
1073
|
},
|
|
167
1074
|
async execute(args) {
|
|
168
1075
|
const spec = parseRepoSpec(args.repo)
|
|
169
|
-
const branch = spec.branch ||
|
|
170
|
-
const repoKey = `${spec.owner}/${spec.repo}
|
|
1076
|
+
const branch = spec.branch || defaultBranch
|
|
1077
|
+
const repoKey = `${spec.owner}/${spec.repo}`
|
|
171
1078
|
|
|
172
1079
|
const manifest = await loadManifest()
|
|
173
1080
|
const entry = manifest.repos[repoKey]
|
|
174
1081
|
|
|
175
1082
|
if (!entry) {
|
|
176
|
-
|
|
1083
|
+
let output = `## Repository not found
|
|
177
1084
|
|
|
178
|
-
Repository \`${
|
|
1085
|
+
Repository \`${spec.owner}/${spec.repo}\` is not registered.
|
|
179
1086
|
|
|
180
1087
|
Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
|
|
1088
|
+
output = appendDebug(
|
|
1089
|
+
output,
|
|
1090
|
+
"repo_read",
|
|
1091
|
+
[`repoKey: ${repoKey}`, `branch: ${branch}`],
|
|
1092
|
+
debugEnabled
|
|
1093
|
+
)
|
|
1094
|
+
return output
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (entry.type === "cached" && entry.currentBranch !== branch) {
|
|
1098
|
+
await switchBranch(entry.path, branch)
|
|
1099
|
+
await withManifestLock(async () => {
|
|
1100
|
+
const updatedManifest = await loadManifest()
|
|
1101
|
+
if (updatedManifest.repos[repoKey]) {
|
|
1102
|
+
updatedManifest.repos[repoKey].currentBranch = branch
|
|
1103
|
+
updatedManifest.repos[repoKey].lastUpdated = new Date().toISOString()
|
|
1104
|
+
await saveManifest(updatedManifest)
|
|
1105
|
+
}
|
|
1106
|
+
})
|
|
181
1107
|
}
|
|
182
1108
|
|
|
183
1109
|
const repoPath = entry.path
|
|
184
1110
|
const fullPath = join(repoPath, args.path)
|
|
185
1111
|
|
|
186
|
-
|
|
1112
|
+
let filePaths: string[] = []
|
|
187
1113
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
1114
|
+
if (args.path.includes("*") || args.path.includes("?")) {
|
|
1115
|
+
const fdResult = await $`fd -t f -g ${args.path} ${repoPath}`.text()
|
|
1116
|
+
filePaths = fdResult.split("\n").filter(Boolean)
|
|
1117
|
+
} else {
|
|
1118
|
+
filePaths = [fullPath]
|
|
1119
|
+
}
|
|
194
1120
|
|
|
195
1121
|
if (filePaths.length === 0) {
|
|
196
|
-
|
|
1122
|
+
const output = appendDebug(
|
|
1123
|
+
`No files found matching path: ${args.path}`,
|
|
1124
|
+
"repo_read",
|
|
1125
|
+
[`repoKey: ${repoKey}`, `branch: ${branch}`],
|
|
1126
|
+
debugEnabled
|
|
1127
|
+
)
|
|
1128
|
+
return output
|
|
197
1129
|
}
|
|
198
1130
|
|
|
199
|
-
let output = `## Files from ${
|
|
1131
|
+
let output = `## Files from ${repoKey} @ ${branch}\n\n`
|
|
200
1132
|
const maxLines = args.maxLines ?? 500
|
|
201
1133
|
|
|
202
1134
|
for (const filePath of filePaths) {
|
|
@@ -231,13 +1163,24 @@ Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
|
|
|
231
1163
|
}
|
|
232
1164
|
})
|
|
233
1165
|
|
|
1166
|
+
output = appendDebug(
|
|
1167
|
+
output,
|
|
1168
|
+
"repo_read",
|
|
1169
|
+
[
|
|
1170
|
+
`repoKey: ${repoKey}`,
|
|
1171
|
+
`branch: ${branch}`,
|
|
1172
|
+
`files: ${filePaths.length}`,
|
|
1173
|
+
],
|
|
1174
|
+
debugEnabled
|
|
1175
|
+
)
|
|
1176
|
+
|
|
234
1177
|
return output
|
|
235
1178
|
},
|
|
236
1179
|
}),
|
|
237
1180
|
|
|
238
1181
|
repo_list: tool({
|
|
239
1182
|
description:
|
|
240
|
-
"List all registered repositories (cached and local). Shows metadata like type, branch,
|
|
1183
|
+
"List all registered repositories (cached and local). Shows metadata like type, current branch, freshness (for cached), and size.",
|
|
241
1184
|
args: {
|
|
242
1185
|
type: tool.schema
|
|
243
1186
|
.enum(["all", "cached", "local"])
|
|
@@ -255,21 +1198,32 @@ Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
|
|
|
255
1198
|
})
|
|
256
1199
|
|
|
257
1200
|
if (filteredRepos.length === 0) {
|
|
258
|
-
return
|
|
1201
|
+
return appendDebug(
|
|
1202
|
+
"No repositories registered.",
|
|
1203
|
+
"repo_list",
|
|
1204
|
+
[`type: ${args.type}`],
|
|
1205
|
+
debugEnabled
|
|
1206
|
+
)
|
|
259
1207
|
}
|
|
260
1208
|
|
|
261
1209
|
let output = "## Registered Repositories\n\n"
|
|
262
|
-
output += "| Repo | Type | Branch | Last
|
|
263
|
-
output += "
|
|
1210
|
+
output += "| Repo | Type | Branch | Last Updated | Size |\n"
|
|
1211
|
+
output += "|------|------|--------|--------------|------|\n"
|
|
264
1212
|
|
|
265
1213
|
for (const [repoKey, entry] of filteredRepos) {
|
|
266
|
-
const repoName = repoKey.substring(0, repoKey.lastIndexOf("@"))
|
|
267
|
-
const lastAccessed = new Date(entry.lastAccessed).toLocaleDateString()
|
|
268
1214
|
const size = entry.sizeBytes
|
|
269
1215
|
? `${Math.round(entry.sizeBytes / 1024 / 1024)}MB`
|
|
270
1216
|
: "-"
|
|
271
1217
|
|
|
272
|
-
|
|
1218
|
+
let freshness = "-"
|
|
1219
|
+
if (entry.type === "cached" && entry.lastUpdated) {
|
|
1220
|
+
const daysSinceUpdate = Math.floor(
|
|
1221
|
+
(Date.now() - new Date(entry.lastUpdated).getTime()) / (1000 * 60 * 60 * 24)
|
|
1222
|
+
)
|
|
1223
|
+
freshness = daysSinceUpdate === 0 ? "today" : `${daysSinceUpdate}d ago`
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
output += `| ${repoKey} | ${entry.type} | ${entry.currentBranch} | ${freshness} | ${size} |\n`
|
|
273
1227
|
}
|
|
274
1228
|
|
|
275
1229
|
const cachedCount = filteredRepos.filter(
|
|
@@ -280,7 +1234,12 @@ Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
|
|
|
280
1234
|
).length
|
|
281
1235
|
output += `\nTotal: ${filteredRepos.length} repos (${cachedCount} cached, ${localCount} local)`
|
|
282
1236
|
|
|
283
|
-
return
|
|
1237
|
+
return appendDebug(
|
|
1238
|
+
output,
|
|
1239
|
+
"repo_list",
|
|
1240
|
+
[`type: ${args.type}`, `total: ${filteredRepos.length}`],
|
|
1241
|
+
debugEnabled
|
|
1242
|
+
)
|
|
284
1243
|
},
|
|
285
1244
|
}),
|
|
286
1245
|
|
|
@@ -294,15 +1253,10 @@ Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
|
|
|
294
1253
|
.describe("Override search paths (default: from config)"),
|
|
295
1254
|
},
|
|
296
1255
|
async execute(args) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (!searchPaths) {
|
|
300
|
-
const config = await loadConfig()
|
|
301
|
-
searchPaths = config?.localSearchPaths || null
|
|
302
|
-
}
|
|
1256
|
+
const searchPaths = args.paths ?? (localSearchPaths.length > 0 ? localSearchPaths : null)
|
|
303
1257
|
|
|
304
1258
|
if (!searchPaths || searchPaths.length === 0) {
|
|
305
|
-
|
|
1259
|
+
let output = `## No search paths configured
|
|
306
1260
|
|
|
307
1261
|
Create a config file at \`~/.config/opencode/opencode-repos.json\`:
|
|
308
1262
|
|
|
@@ -317,17 +1271,31 @@ Create a config file at \`~/.config/opencode/opencode-repos.json\`:
|
|
|
317
1271
|
\`\`\`
|
|
318
1272
|
|
|
319
1273
|
Or provide paths directly: \`repo_scan({ paths: ["~/projects"] })\``
|
|
1274
|
+
output = appendDebug(
|
|
1275
|
+
output,
|
|
1276
|
+
"repo_scan",
|
|
1277
|
+
["searchPaths: none"],
|
|
1278
|
+
debugEnabled
|
|
1279
|
+
)
|
|
1280
|
+
return output
|
|
320
1281
|
}
|
|
321
1282
|
|
|
322
1283
|
const foundRepos = await scanLocalRepos(searchPaths)
|
|
323
1284
|
|
|
324
1285
|
if (foundRepos.length === 0) {
|
|
325
|
-
|
|
1286
|
+
let output = `## No repositories found
|
|
326
1287
|
|
|
327
1288
|
Searched ${searchPaths.length} path(s):
|
|
328
1289
|
${searchPaths.map((p) => `- ${p}`).join("\n")}
|
|
329
1290
|
|
|
330
1291
|
No git repositories with remotes were found.`
|
|
1292
|
+
output = appendDebug(
|
|
1293
|
+
output,
|
|
1294
|
+
"repo_scan",
|
|
1295
|
+
[`searchPaths: ${searchPaths.length}`],
|
|
1296
|
+
debugEnabled
|
|
1297
|
+
)
|
|
1298
|
+
return output
|
|
331
1299
|
}
|
|
332
1300
|
|
|
333
1301
|
let newCount = 0
|
|
@@ -340,8 +1308,8 @@ No git repositories with remotes were found.`
|
|
|
340
1308
|
const spec = matchRemoteToSpec(repo.remote)
|
|
341
1309
|
if (!spec) continue
|
|
342
1310
|
|
|
343
|
-
const branch = repo.branch ||
|
|
344
|
-
const repoKey =
|
|
1311
|
+
const branch = repo.branch || defaultBranch
|
|
1312
|
+
const repoKey = spec
|
|
345
1313
|
|
|
346
1314
|
if (manifest.repos[repoKey]) {
|
|
347
1315
|
existingCount++
|
|
@@ -353,7 +1321,7 @@ No git repositories with remotes were found.`
|
|
|
353
1321
|
type: "local",
|
|
354
1322
|
path: repo.path,
|
|
355
1323
|
lastAccessed: now,
|
|
356
|
-
|
|
1324
|
+
currentBranch: branch,
|
|
357
1325
|
shallow: false,
|
|
358
1326
|
}
|
|
359
1327
|
|
|
@@ -365,19 +1333,30 @@ No git repositories with remotes were found.`
|
|
|
365
1333
|
await saveManifest(manifest)
|
|
366
1334
|
})
|
|
367
1335
|
|
|
368
|
-
|
|
1336
|
+
let output = `## Local Repository Scan Complete
|
|
369
1337
|
|
|
370
1338
|
**Found**: ${foundRepos.length} repositories in ${searchPaths.length} path(s)
|
|
371
1339
|
**New**: ${newCount} repos registered
|
|
372
1340
|
**Existing**: ${existingCount} repos already registered
|
|
373
1341
|
|
|
374
1342
|
${newCount > 0 ? "Use `repo_list()` to see all registered repositories." : ""}`
|
|
1343
|
+
output = appendDebug(
|
|
1344
|
+
output,
|
|
1345
|
+
"repo_scan",
|
|
1346
|
+
[
|
|
1347
|
+
`searchPaths: ${searchPaths.length}`,
|
|
1348
|
+
`found: ${foundRepos.length}`,
|
|
1349
|
+
`new: ${newCount}`,
|
|
1350
|
+
],
|
|
1351
|
+
debugEnabled
|
|
1352
|
+
)
|
|
1353
|
+
return output
|
|
375
1354
|
},
|
|
376
1355
|
}),
|
|
377
1356
|
|
|
378
1357
|
repo_update: tool({
|
|
379
1358
|
description:
|
|
380
|
-
"Update a cached repository to latest. For local repos, shows git status without modifying.
|
|
1359
|
+
"Update a cached repository to latest. Optionally switch to a different branch first. For local repos, shows git status without modifying.",
|
|
381
1360
|
args: {
|
|
382
1361
|
repo: tool.schema
|
|
383
1362
|
.string()
|
|
@@ -387,18 +1366,25 @@ ${newCount > 0 ? "Use `repo_list()` to see all registered repositories." : ""}`
|
|
|
387
1366
|
},
|
|
388
1367
|
async execute(args) {
|
|
389
1368
|
const spec = parseRepoSpec(args.repo)
|
|
390
|
-
const
|
|
391
|
-
const repoKey = `${spec.owner}/${spec.repo}
|
|
1369
|
+
const requestedBranch = spec.branch
|
|
1370
|
+
const repoKey = `${spec.owner}/${spec.repo}`
|
|
392
1371
|
|
|
393
1372
|
const manifest = await loadManifest()
|
|
394
1373
|
const entry = manifest.repos[repoKey]
|
|
395
1374
|
|
|
396
1375
|
if (!entry) {
|
|
397
|
-
|
|
1376
|
+
let output = `## Repository not found
|
|
398
1377
|
|
|
399
|
-
Repository \`${
|
|
1378
|
+
Repository \`${repoKey}\` is not registered.
|
|
400
1379
|
|
|
401
1380
|
Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
|
|
1381
|
+
output = appendDebug(
|
|
1382
|
+
output,
|
|
1383
|
+
"repo_update",
|
|
1384
|
+
[`repoKey: ${repoKey}`],
|
|
1385
|
+
debugEnabled
|
|
1386
|
+
)
|
|
1387
|
+
return output
|
|
402
1388
|
}
|
|
403
1389
|
|
|
404
1390
|
if (entry.type === "local") {
|
|
@@ -407,8 +1393,9 @@ Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
|
|
|
407
1393
|
|
|
408
1394
|
return `## Local Repository Status
|
|
409
1395
|
|
|
410
|
-
**Repository**: ${
|
|
1396
|
+
**Repository**: ${repoKey}
|
|
411
1397
|
**Path**: ${entry.path}
|
|
1398
|
+
**Branch**: ${entry.currentBranch}
|
|
412
1399
|
**Type**: Local (not modified by plugin)
|
|
413
1400
|
|
|
414
1401
|
\`\`\`
|
|
@@ -416,41 +1403,69 @@ ${status || "Working tree clean"}
|
|
|
416
1403
|
\`\`\``
|
|
417
1404
|
} catch (error) {
|
|
418
1405
|
const message = error instanceof Error ? error.message : String(error)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
Failed to get git status for ${
|
|
1406
|
+
let output = `## Error getting status
|
|
1407
|
+
|
|
1408
|
+
Failed to get git status for ${repoKey}: ${message}`
|
|
1409
|
+
output = appendDebug(
|
|
1410
|
+
output,
|
|
1411
|
+
"repo_update",
|
|
1412
|
+
[`repoKey: ${repoKey}`, `path: ${entry.path}`],
|
|
1413
|
+
debugEnabled
|
|
1414
|
+
)
|
|
1415
|
+
return output
|
|
422
1416
|
}
|
|
423
1417
|
}
|
|
424
1418
|
|
|
425
1419
|
try {
|
|
426
|
-
|
|
1420
|
+
const targetBranch = requestedBranch || entry.currentBranch
|
|
1421
|
+
|
|
1422
|
+
if (targetBranch !== entry.currentBranch) {
|
|
1423
|
+
await switchBranch(entry.path, targetBranch)
|
|
1424
|
+
} else {
|
|
1425
|
+
await updateRepo(entry.path)
|
|
1426
|
+
}
|
|
427
1427
|
|
|
428
1428
|
const info = await getRepoInfo(entry.path)
|
|
429
1429
|
|
|
430
1430
|
await withManifestLock(async () => {
|
|
431
1431
|
const updatedManifest = await loadManifest()
|
|
432
1432
|
if (updatedManifest.repos[repoKey]) {
|
|
1433
|
+
updatedManifest.repos[repoKey].currentBranch = targetBranch
|
|
433
1434
|
updatedManifest.repos[repoKey].lastUpdated = new Date().toISOString()
|
|
434
1435
|
updatedManifest.repos[repoKey].lastAccessed = new Date().toISOString()
|
|
435
1436
|
await saveManifest(updatedManifest)
|
|
436
1437
|
}
|
|
437
1438
|
})
|
|
438
1439
|
|
|
439
|
-
|
|
1440
|
+
let output = `## Repository Updated
|
|
440
1441
|
|
|
441
|
-
**Repository**: ${
|
|
1442
|
+
**Repository**: ${repoKey}
|
|
442
1443
|
**Path**: ${entry.path}
|
|
443
|
-
**Branch**: ${
|
|
1444
|
+
**Branch**: ${targetBranch}
|
|
444
1445
|
**Latest Commit**: ${info.commit.substring(0, 7)}
|
|
445
1446
|
|
|
446
1447
|
Repository has been updated to the latest commit.`
|
|
1448
|
+
output = appendDebug(
|
|
1449
|
+
output,
|
|
1450
|
+
"repo_update",
|
|
1451
|
+
[`repoKey: ${repoKey}`, `branch: ${targetBranch}`],
|
|
1452
|
+
debugEnabled
|
|
1453
|
+
)
|
|
1454
|
+
return output
|
|
447
1455
|
} catch (error) {
|
|
448
1456
|
const message = error instanceof Error ? error.message : String(error)
|
|
449
|
-
|
|
1457
|
+
let output = `## Update Failed
|
|
450
1458
|
|
|
451
|
-
Failed to update ${
|
|
1459
|
+
Failed to update ${repoKey}: ${message}
|
|
452
1460
|
|
|
453
1461
|
The repository may be corrupted. Try \`repo_clone({ repo: "${args.repo}", force: true })\` to re-clone.`
|
|
1462
|
+
output = appendDebug(
|
|
1463
|
+
output,
|
|
1464
|
+
"repo_update",
|
|
1465
|
+
[`repoKey: ${repoKey}`],
|
|
1466
|
+
debugEnabled
|
|
1467
|
+
)
|
|
1468
|
+
return output
|
|
454
1469
|
}
|
|
455
1470
|
},
|
|
456
1471
|
}),
|
|
@@ -462,7 +1477,7 @@ The repository may be corrupted. Try \`repo_clone({ repo: "${args.repo}", force:
|
|
|
462
1477
|
repo: tool.schema
|
|
463
1478
|
.string()
|
|
464
1479
|
.describe(
|
|
465
|
-
"Repository in format 'owner/repo'
|
|
1480
|
+
"Repository in format 'owner/repo'"
|
|
466
1481
|
),
|
|
467
1482
|
confirm: tool.schema
|
|
468
1483
|
.boolean()
|
|
@@ -472,18 +1487,24 @@ The repository may be corrupted. Try \`repo_clone({ repo: "${args.repo}", force:
|
|
|
472
1487
|
},
|
|
473
1488
|
async execute(args) {
|
|
474
1489
|
const spec = parseRepoSpec(args.repo)
|
|
475
|
-
const
|
|
476
|
-
const repoKey = `${spec.owner}/${spec.repo}@${branch}`
|
|
1490
|
+
const repoKey = `${spec.owner}/${spec.repo}`
|
|
477
1491
|
|
|
478
1492
|
const manifest = await loadManifest()
|
|
479
1493
|
const entry = manifest.repos[repoKey]
|
|
480
1494
|
|
|
481
1495
|
if (!entry) {
|
|
482
|
-
|
|
1496
|
+
let output = `## Repository not found
|
|
483
1497
|
|
|
484
|
-
Repository \`${
|
|
1498
|
+
Repository \`${repoKey}\` is not registered.
|
|
485
1499
|
|
|
486
1500
|
Use \`repo_list()\` to see all registered repositories.`
|
|
1501
|
+
output = appendDebug(
|
|
1502
|
+
output,
|
|
1503
|
+
"repo_remove",
|
|
1504
|
+
[`repoKey: ${repoKey}`],
|
|
1505
|
+
debugEnabled
|
|
1506
|
+
)
|
|
1507
|
+
return output
|
|
487
1508
|
}
|
|
488
1509
|
|
|
489
1510
|
if (entry.type === "local") {
|
|
@@ -501,28 +1522,42 @@ Use \`repo_list()\` to see all registered repositories.`
|
|
|
501
1522
|
await saveManifest(updatedManifest)
|
|
502
1523
|
})
|
|
503
1524
|
|
|
504
|
-
|
|
1525
|
+
let output = `## Local Repository Unregistered
|
|
505
1526
|
|
|
506
|
-
**Repository**: ${
|
|
1527
|
+
**Repository**: ${repoKey}
|
|
507
1528
|
**Path**: ${entry.path}
|
|
508
1529
|
|
|
509
1530
|
The repository has been unregistered. Files are preserved at the path above.
|
|
510
1531
|
|
|
511
1532
|
To re-register, run \`repo_scan()\`.`
|
|
1533
|
+
output = appendDebug(
|
|
1534
|
+
output,
|
|
1535
|
+
"repo_remove",
|
|
1536
|
+
[`repoKey: ${repoKey}`, `path: ${entry.path}`],
|
|
1537
|
+
debugEnabled
|
|
1538
|
+
)
|
|
1539
|
+
return output
|
|
512
1540
|
}
|
|
513
1541
|
|
|
514
1542
|
if (!args.confirm) {
|
|
515
|
-
|
|
1543
|
+
let output = `## Confirmation Required
|
|
516
1544
|
|
|
517
|
-
**Repository**: ${
|
|
1545
|
+
**Repository**: ${repoKey}
|
|
518
1546
|
**Path**: ${entry.path}
|
|
519
1547
|
**Type**: Cached (cloned by plugin)
|
|
520
1548
|
|
|
521
1549
|
This will **permanently delete** the cached repository from disk.
|
|
522
1550
|
|
|
523
|
-
To proceed: \`repo_remove({ repo: "${
|
|
1551
|
+
To proceed: \`repo_remove({ repo: "${repoKey}", confirm: true })\`
|
|
524
1552
|
|
|
525
1553
|
To keep the repo but unregister it, manually delete it from \`~/.cache/opencode-repos/manifest.json\`.`
|
|
1554
|
+
output = appendDebug(
|
|
1555
|
+
output,
|
|
1556
|
+
"repo_remove",
|
|
1557
|
+
[`repoKey: ${repoKey}`, `path: ${entry.path}`],
|
|
1558
|
+
debugEnabled
|
|
1559
|
+
)
|
|
1560
|
+
return output
|
|
526
1561
|
}
|
|
527
1562
|
|
|
528
1563
|
try {
|
|
@@ -534,14 +1569,21 @@ To keep the repo but unregister it, manually delete it from \`~/.cache/opencode-
|
|
|
534
1569
|
await saveManifest(updatedManifest)
|
|
535
1570
|
})
|
|
536
1571
|
|
|
537
|
-
|
|
1572
|
+
let output = `## Cached Repository Deleted
|
|
538
1573
|
|
|
539
|
-
**Repository**: ${
|
|
1574
|
+
**Repository**: ${repoKey}
|
|
540
1575
|
**Path**: ${entry.path}
|
|
541
1576
|
|
|
542
1577
|
The repository has been permanently deleted from disk and unregistered from the cache.
|
|
543
1578
|
|
|
544
|
-
To re-clone: \`repo_clone({ repo: "${
|
|
1579
|
+
To re-clone: \`repo_clone({ repo: "${repoKey}" })\``
|
|
1580
|
+
output = appendDebug(
|
|
1581
|
+
output,
|
|
1582
|
+
"repo_remove",
|
|
1583
|
+
[`repoKey: ${repoKey}`, `path: ${entry.path}`],
|
|
1584
|
+
debugEnabled
|
|
1585
|
+
)
|
|
1586
|
+
return output
|
|
545
1587
|
} catch (error) {
|
|
546
1588
|
const message = error instanceof Error ? error.message : String(error)
|
|
547
1589
|
|
|
@@ -553,11 +1595,18 @@ To re-clone: \`repo_clone({ repo: "${args.repo}" })\``
|
|
|
553
1595
|
})
|
|
554
1596
|
} catch {}
|
|
555
1597
|
|
|
556
|
-
|
|
1598
|
+
let output = `## Deletion Failed
|
|
557
1599
|
|
|
558
|
-
Failed to delete ${
|
|
1600
|
+
Failed to delete ${repoKey}: ${message}
|
|
559
1601
|
|
|
560
1602
|
The repository has been unregistered from the manifest. You may need to manually delete the directory at: ${entry.path}`
|
|
1603
|
+
output = appendDebug(
|
|
1604
|
+
output,
|
|
1605
|
+
"repo_remove",
|
|
1606
|
+
[`repoKey: ${repoKey}`, `path: ${entry.path}`],
|
|
1607
|
+
debugEnabled
|
|
1608
|
+
)
|
|
1609
|
+
return output
|
|
561
1610
|
}
|
|
562
1611
|
},
|
|
563
1612
|
}),
|
|
@@ -597,11 +1646,10 @@ The repository has been unregistered from the manifest. You may need to manually
|
|
|
597
1646
|
}
|
|
598
1647
|
}
|
|
599
1648
|
|
|
600
|
-
|
|
601
|
-
if (config?.localSearchPaths?.length) {
|
|
1649
|
+
if (localSearchPaths.length > 0) {
|
|
602
1650
|
try {
|
|
603
1651
|
const localResults = await findLocalRepoByName(
|
|
604
|
-
|
|
1652
|
+
localSearchPaths,
|
|
605
1653
|
query
|
|
606
1654
|
)
|
|
607
1655
|
for (const local of localResults) {
|
|
@@ -682,120 +1730,508 @@ The repository has been unregistered from the manifest. You may need to manually
|
|
|
682
1730
|
output += `- Check if gh CLI is authenticated\n`
|
|
683
1731
|
}
|
|
684
1732
|
|
|
1733
|
+
output = appendDebug(
|
|
1734
|
+
output,
|
|
1735
|
+
"repo_find",
|
|
1736
|
+
[`query: ${query}`, `localSearchPaths: ${localSearchPaths.length}`],
|
|
1737
|
+
debugEnabled
|
|
1738
|
+
)
|
|
685
1739
|
return output
|
|
686
1740
|
},
|
|
687
1741
|
}),
|
|
688
1742
|
|
|
689
|
-
|
|
1743
|
+
repo_pick_dir: tool({
|
|
690
1744
|
description:
|
|
691
|
-
"
|
|
1745
|
+
"Open a native folder picker and return the selected path. Call this immediately after the user asks for a local repo (avoid delayed popups if the user is away).",
|
|
692
1746
|
args: {
|
|
693
|
-
|
|
1747
|
+
prompt: tool.schema
|
|
694
1748
|
.string()
|
|
1749
|
+
.optional()
|
|
1750
|
+
.describe("Prompt text shown in the picker dialog"),
|
|
1751
|
+
},
|
|
1752
|
+
async execute(args) {
|
|
1753
|
+
const promptText = args.prompt ?? "Select a repository folder"
|
|
1754
|
+
const platform = process.platform
|
|
1755
|
+
|
|
1756
|
+
if (platform === "darwin") {
|
|
1757
|
+
const safePrompt = promptText.replace(/"/g, "\\\"")
|
|
1758
|
+
const script = `POSIX path of (choose folder with prompt "${safePrompt}")`
|
|
1759
|
+
const result = await $`osascript -e ${script}`.text()
|
|
1760
|
+
const selected = result.trim()
|
|
1761
|
+
|
|
1762
|
+
if (!selected) {
|
|
1763
|
+
return appendDebug(
|
|
1764
|
+
"No folder selected.",
|
|
1765
|
+
"repo_pick_dir",
|
|
1766
|
+
[`platform: ${platform}`],
|
|
1767
|
+
debugEnabled
|
|
1768
|
+
)
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
return appendDebug(
|
|
1772
|
+
`## Folder selected\n\n${selected}`,
|
|
1773
|
+
"repo_pick_dir",
|
|
1774
|
+
[`platform: ${platform}`],
|
|
1775
|
+
debugEnabled
|
|
1776
|
+
)
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
if (platform === "win32") {
|
|
1780
|
+
const safePrompt = promptText.replace(/'/g, "''")
|
|
1781
|
+
const command = `[void][System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms');` +
|
|
1782
|
+
`$dialog = New-Object System.Windows.Forms.FolderBrowserDialog;` +
|
|
1783
|
+
`$dialog.Description='${safePrompt}';` +
|
|
1784
|
+
`if ($dialog.ShowDialog() -eq 'OK') { $dialog.SelectedPath }`
|
|
1785
|
+
|
|
1786
|
+
const result = await $`powershell -NoProfile -Command ${command}`.text()
|
|
1787
|
+
const selected = result.trim()
|
|
1788
|
+
|
|
1789
|
+
if (!selected) {
|
|
1790
|
+
return appendDebug(
|
|
1791
|
+
"No folder selected.",
|
|
1792
|
+
"repo_pick_dir",
|
|
1793
|
+
[`platform: ${platform}`],
|
|
1794
|
+
debugEnabled
|
|
1795
|
+
)
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
return appendDebug(
|
|
1799
|
+
`## Folder selected\n\n${selected}`,
|
|
1800
|
+
"repo_pick_dir",
|
|
1801
|
+
[`platform: ${platform}`],
|
|
1802
|
+
debugEnabled
|
|
1803
|
+
)
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
return appendDebug(
|
|
1807
|
+
"Folder picker is not supported on this platform.",
|
|
1808
|
+
"repo_pick_dir",
|
|
1809
|
+
[`platform: ${platform}`],
|
|
1810
|
+
debugEnabled
|
|
1811
|
+
)
|
|
1812
|
+
},
|
|
1813
|
+
}),
|
|
1814
|
+
|
|
1815
|
+
repo_query: tool({
|
|
1816
|
+
description:
|
|
1817
|
+
"Resolve a repository automatically and explore it with a subagent. Accepts local paths or owner/repo. Picks an exact match when possible, otherwise asks you to disambiguate. Can run multiple repos when specified.",
|
|
1818
|
+
args: {
|
|
1819
|
+
query: tool.schema
|
|
1820
|
+
.string()
|
|
1821
|
+
.optional()
|
|
695
1822
|
.describe(
|
|
696
|
-
"Repository
|
|
1823
|
+
"Repository name, owner/repo, or absolute local path. Examples: 'next.js', 'vercel/next.js', '/Users/me/projects/app'"
|
|
697
1824
|
),
|
|
1825
|
+
repos: tool.schema
|
|
1826
|
+
.array(tool.schema.string())
|
|
1827
|
+
.optional()
|
|
1828
|
+
.describe("Explicit repositories to explore (owner/repo or owner/repo@branch)"),
|
|
698
1829
|
question: tool.schema
|
|
699
1830
|
.string()
|
|
700
1831
|
.describe("What you want to understand about the codebase"),
|
|
701
1832
|
},
|
|
702
1833
|
async execute(args, ctx) {
|
|
703
|
-
const
|
|
704
|
-
const branch = spec.branch || "main"
|
|
705
|
-
const repoKey = `${spec.owner}/${spec.repo}@${branch}`
|
|
1834
|
+
const explicitRepos = args.repos?.filter(Boolean) ?? []
|
|
706
1835
|
|
|
707
|
-
|
|
708
|
-
|
|
1836
|
+
if (explicitRepos.length === 0 && !args.query) {
|
|
1837
|
+
return `## Missing repository query
|
|
709
1838
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
repoPath = join(CACHE_DIR, spec.owner, `${spec.repo}@${branch}`)
|
|
713
|
-
const url = buildGitUrl(spec.owner, spec.repo)
|
|
1839
|
+
Provide either \`query\` or a non-empty \`repos\` list.`
|
|
1840
|
+
}
|
|
714
1841
|
|
|
715
|
-
|
|
716
|
-
|
|
1842
|
+
const targets: Array<{
|
|
1843
|
+
repoKey: string
|
|
1844
|
+
branch: string
|
|
1845
|
+
source?: RepoSource
|
|
1846
|
+
remote?: string
|
|
1847
|
+
path?: string
|
|
1848
|
+
}> = []
|
|
1849
|
+
|
|
1850
|
+
const debugInfo: RepoQueryDebugInfo = {
|
|
1851
|
+
query: args.query ?? explicitRepos.join(", "),
|
|
1852
|
+
allowGithub: false,
|
|
1853
|
+
localSearchPaths,
|
|
1854
|
+
candidates: [],
|
|
1855
|
+
selectedTargets: [],
|
|
1856
|
+
}
|
|
717
1857
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1858
|
+
if (explicitRepos.length > 0) {
|
|
1859
|
+
for (const repo of explicitRepos) {
|
|
1860
|
+
try {
|
|
1861
|
+
const spec = parseRepoSpec(repo)
|
|
1862
|
+
targets.push({
|
|
1863
|
+
repoKey: `${spec.owner}/${spec.repo}`,
|
|
1864
|
+
branch: spec.branch || defaultBranch,
|
|
1865
|
+
})
|
|
1866
|
+
debugInfo.selectedTargets.push({
|
|
1867
|
+
repoKey: `${spec.owner}/${spec.repo}`,
|
|
1868
|
+
branch: spec.branch || defaultBranch,
|
|
1869
|
+
})
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
const message =
|
|
1872
|
+
error instanceof Error ? error.message : String(error)
|
|
1873
|
+
return `## Invalid repository
|
|
1874
|
+
|
|
1875
|
+
Failed to parse \`${repo}\`: ${message}`
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
} else {
|
|
1879
|
+
const localPathResult = await resolveLocalPathQuery(
|
|
1880
|
+
args.query!,
|
|
1881
|
+
defaultBranch
|
|
1882
|
+
)
|
|
1883
|
+
|
|
1884
|
+
if (localPathResult.error) {
|
|
1885
|
+
let output = `## Local path error\n\n${localPathResult.error}`
|
|
1886
|
+
if (debugEnabled) {
|
|
1887
|
+
logRepoQueryDebug(debugInfo)
|
|
1888
|
+
output += `\n\n${formatRepoQueryDebug(debugInfo)}`
|
|
1889
|
+
}
|
|
1890
|
+
return output
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
if (localPathResult.candidate) {
|
|
1894
|
+
const candidate = localPathResult.candidate
|
|
1895
|
+
targets.push({
|
|
1896
|
+
repoKey: candidate.key,
|
|
1897
|
+
branch: candidate.branch ?? defaultBranch,
|
|
1898
|
+
source: candidate.source,
|
|
1899
|
+
remote: candidate.remote,
|
|
1900
|
+
path: candidate.path,
|
|
730
1901
|
})
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1902
|
+
debugInfo.candidates = [candidate]
|
|
1903
|
+
debugInfo.selectedTargets.push({
|
|
1904
|
+
repoKey: candidate.key,
|
|
1905
|
+
branch: candidate.branch ?? defaultBranch,
|
|
1906
|
+
})
|
|
1907
|
+
}
|
|
735
1908
|
|
|
736
|
-
|
|
1909
|
+
if (targets.length > 0) {
|
|
1910
|
+
for (const target of targets) {
|
|
1911
|
+
if (target.source === "local" && target.remote && target.path) {
|
|
1912
|
+
await registerLocalRepo(
|
|
1913
|
+
target.repoKey,
|
|
1914
|
+
target.path,
|
|
1915
|
+
target.branch,
|
|
1916
|
+
target.remote
|
|
1917
|
+
)
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
737
1921
|
|
|
738
|
-
|
|
1922
|
+
if (targets.length > 0) {
|
|
1923
|
+
const results: Array<{ repoKey: string; response: string }> = []
|
|
1924
|
+
|
|
1925
|
+
for (const target of targets) {
|
|
1926
|
+
try {
|
|
1927
|
+
const resolved = await ensureRepoAvailable(
|
|
1928
|
+
target.repoKey,
|
|
1929
|
+
target.branch,
|
|
1930
|
+
cacheDir,
|
|
1931
|
+
useHttps,
|
|
1932
|
+
autoSyncOnExplore,
|
|
1933
|
+
autoSyncIntervalHours
|
|
1934
|
+
)
|
|
1935
|
+
|
|
1936
|
+
await requestExternalDirectoryAccess(
|
|
1937
|
+
ctx as PermissionContext,
|
|
1938
|
+
resolved.repoPath
|
|
1939
|
+
)
|
|
1940
|
+
|
|
1941
|
+
const response = await runRepoExplorer(
|
|
1942
|
+
client as RepoClient,
|
|
1943
|
+
ctx,
|
|
1944
|
+
target.repoKey,
|
|
1945
|
+
resolved.repoPath,
|
|
1946
|
+
args.question,
|
|
1947
|
+
repoExplorerModel,
|
|
1948
|
+
directory
|
|
1949
|
+
)
|
|
1950
|
+
|
|
1951
|
+
await touchRepoAccess(target.repoKey)
|
|
1952
|
+
|
|
1953
|
+
results.push({
|
|
1954
|
+
repoKey: target.repoKey,
|
|
1955
|
+
response,
|
|
1956
|
+
})
|
|
1957
|
+
} catch (error) {
|
|
1958
|
+
const message =
|
|
1959
|
+
error instanceof Error ? error.message : String(error)
|
|
1960
|
+
results.push({
|
|
1961
|
+
repoKey: target.repoKey,
|
|
1962
|
+
response: `## Exploration failed\n\n${message}`,
|
|
1963
|
+
})
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
if (results.length === 1) {
|
|
1968
|
+
if (debugEnabled) {
|
|
1969
|
+
logRepoQueryDebug(debugInfo)
|
|
1970
|
+
return `${results[0].response}\n\n${formatRepoQueryDebug(debugInfo)}`
|
|
1971
|
+
}
|
|
1972
|
+
return results[0].response
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
let output = "## Repository Exploration Results\n\n"
|
|
1976
|
+
for (const result of results) {
|
|
1977
|
+
output += `### ${result.repoKey}\n\n${result.response}\n\n`
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
if (debugEnabled) {
|
|
1981
|
+
logRepoQueryDebug(debugInfo)
|
|
1982
|
+
output += `${formatRepoQueryDebug(debugInfo)}\n`
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
return output
|
|
739
1986
|
}
|
|
740
|
-
} else {
|
|
741
|
-
repoPath = manifest.repos[repoKey].path
|
|
742
|
-
}
|
|
743
1987
|
|
|
744
|
-
|
|
1988
|
+
const manifest = await loadManifest()
|
|
1989
|
+
let allowGithub = false
|
|
745
1990
|
|
|
746
|
-
|
|
1991
|
+
if (args.query?.includes("/")) {
|
|
1992
|
+
try {
|
|
1993
|
+
parseRepoSpec(args.query)
|
|
1994
|
+
allowGithub = true
|
|
1995
|
+
} catch {
|
|
1996
|
+
allowGithub = false
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
747
1999
|
|
|
748
|
-
|
|
2000
|
+
debugInfo.allowGithub = allowGithub
|
|
749
2001
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
2002
|
+
const { candidates, exactRepoKey, branchOverride } = await resolveCandidates(
|
|
2003
|
+
args.query!,
|
|
2004
|
+
localSearchPaths,
|
|
2005
|
+
manifest,
|
|
2006
|
+
defaultBranch,
|
|
2007
|
+
allowGithub
|
|
2008
|
+
)
|
|
755
2009
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
2010
|
+
debugInfo.candidates = candidates
|
|
2011
|
+
|
|
2012
|
+
if (candidates.length === 0) {
|
|
2013
|
+
if (!allowGithub) {
|
|
2014
|
+
let output = `## No local repositories matched
|
|
2015
|
+
|
|
2016
|
+
No repositories matched \`${args.query}\` in the local registry.
|
|
2017
|
+
|
|
2018
|
+
Provide a local path or configure search paths:
|
|
2019
|
+
|
|
2020
|
+
- Use \`repo_pick_dir()\` to select a folder (GUI required)
|
|
2021
|
+
- Or set \`localSearchPaths\` in ~/.config/opencode/opencode-repos.json
|
|
2022
|
+
- Or pass an explicit repo in \`repos\` (owner/repo)
|
|
761
2023
|
`
|
|
2024
|
+
if (debugEnabled) {
|
|
2025
|
+
logRepoQueryDebug(debugInfo)
|
|
2026
|
+
output += `\n${formatRepoQueryDebug(debugInfo)}`
|
|
2027
|
+
}
|
|
2028
|
+
return output
|
|
2029
|
+
}
|
|
2030
|
+
let output = `## No repositories found
|
|
762
2031
|
|
|
763
|
-
|
|
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
|
-
})
|
|
2032
|
+
No repositories matched \`${args.query}\`.
|
|
771
2033
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
2034
|
+
Try:
|
|
2035
|
+
- Using owner/repo format for exact matches
|
|
2036
|
+
- Running \`repo_find({ query: "${args.query}" })\` to see available options`
|
|
2037
|
+
if (debugEnabled) {
|
|
2038
|
+
logRepoQueryDebug(debugInfo)
|
|
2039
|
+
output += `\n${formatRepoQueryDebug(debugInfo)}`
|
|
778
2040
|
}
|
|
2041
|
+
return output
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
if (candidates.length > 1 && !exactRepoKey) {
|
|
2045
|
+
let output = `## Multiple repositories matched "${args.query}"
|
|
2046
|
+
|
|
2047
|
+
Be more specific or pass an explicit list with \`repos\`.
|
|
2048
|
+
|
|
2049
|
+
`
|
|
2050
|
+
for (const candidate of candidates) {
|
|
2051
|
+
const sourceLabel = candidate.source === "registered" ? "registered" : candidate.source
|
|
2052
|
+
const description = candidate.description ? ` - ${candidate.description.slice(0, 80)}` : ""
|
|
2053
|
+
output += `- ${candidate.key} (${sourceLabel})${description}\n`
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
if (debugEnabled) {
|
|
2057
|
+
logRepoQueryDebug(debugInfo)
|
|
2058
|
+
output += `\n${formatRepoQueryDebug(debugInfo)}`
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
return output
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
const selected = candidates[0]
|
|
2065
|
+
const branch = branchOverride ?? selected.branch ?? defaultBranch
|
|
2066
|
+
|
|
2067
|
+
targets.push({
|
|
2068
|
+
repoKey: selected.key,
|
|
2069
|
+
branch,
|
|
2070
|
+
source: selected.source,
|
|
2071
|
+
remote: selected.remote,
|
|
2072
|
+
path: selected.path,
|
|
2073
|
+
})
|
|
2074
|
+
|
|
2075
|
+
debugInfo.selectedTargets.push({
|
|
2076
|
+
repoKey: selected.key,
|
|
2077
|
+
branch,
|
|
779
2078
|
})
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
for (const target of targets) {
|
|
2082
|
+
if (target.source === "local" && target.remote && target.path) {
|
|
2083
|
+
await registerLocalRepo(
|
|
2084
|
+
target.repoKey,
|
|
2085
|
+
target.path,
|
|
2086
|
+
target.branch,
|
|
2087
|
+
target.remote
|
|
2088
|
+
)
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
const results: Array<{ repoKey: string; response: string }> = []
|
|
2093
|
+
|
|
2094
|
+
for (const target of targets) {
|
|
2095
|
+
try {
|
|
2096
|
+
const resolved = await ensureRepoAvailable(
|
|
2097
|
+
target.repoKey,
|
|
2098
|
+
target.branch,
|
|
2099
|
+
cacheDir,
|
|
2100
|
+
useHttps,
|
|
2101
|
+
autoSyncOnExplore,
|
|
2102
|
+
autoSyncIntervalHours
|
|
2103
|
+
)
|
|
2104
|
+
|
|
2105
|
+
await requestExternalDirectoryAccess(
|
|
2106
|
+
ctx as PermissionContext,
|
|
2107
|
+
resolved.repoPath
|
|
2108
|
+
)
|
|
2109
|
+
|
|
2110
|
+
const response = await runRepoExplorer(
|
|
2111
|
+
client as RepoClient,
|
|
2112
|
+
ctx,
|
|
2113
|
+
target.repoKey,
|
|
2114
|
+
resolved.repoPath,
|
|
2115
|
+
args.question,
|
|
2116
|
+
repoExplorerModel,
|
|
2117
|
+
directory
|
|
2118
|
+
)
|
|
780
2119
|
|
|
781
|
-
|
|
782
|
-
return `## Exploration failed
|
|
2120
|
+
await touchRepoAccess(target.repoKey)
|
|
783
2121
|
|
|
784
|
-
|
|
2122
|
+
results.push({
|
|
2123
|
+
repoKey: target.repoKey,
|
|
2124
|
+
response,
|
|
2125
|
+
})
|
|
2126
|
+
} catch (error) {
|
|
2127
|
+
const message =
|
|
2128
|
+
error instanceof Error ? error.message : String(error)
|
|
2129
|
+
results.push({
|
|
2130
|
+
repoKey: target.repoKey,
|
|
2131
|
+
response: `## Exploration failed\n\n${message}`,
|
|
2132
|
+
})
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
if (results.length === 1) {
|
|
2137
|
+
if (debugEnabled) {
|
|
2138
|
+
logRepoQueryDebug(debugInfo)
|
|
2139
|
+
return `${results[0].response}\n\n${formatRepoQueryDebug(debugInfo)}`
|
|
785
2140
|
}
|
|
2141
|
+
return results[0].response
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
let output = "## Repository Exploration Results\n\n"
|
|
2145
|
+
for (const result of results) {
|
|
2146
|
+
output += `### ${result.repoKey}\n\n${result.response}\n\n`
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
if (debugEnabled) {
|
|
2150
|
+
logRepoQueryDebug(debugInfo)
|
|
2151
|
+
output += `${formatRepoQueryDebug(debugInfo)}\n`
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
return output
|
|
2155
|
+
},
|
|
2156
|
+
}),
|
|
2157
|
+
|
|
2158
|
+
repo_explore: tool({
|
|
2159
|
+
description:
|
|
2160
|
+
"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.",
|
|
2161
|
+
args: {
|
|
2162
|
+
repo: tool.schema
|
|
2163
|
+
.string()
|
|
2164
|
+
.describe(
|
|
2165
|
+
"Repository in format 'owner/repo' or 'owner/repo@branch'"
|
|
2166
|
+
),
|
|
2167
|
+
question: tool.schema
|
|
2168
|
+
.string()
|
|
2169
|
+
.describe("What you want to understand about the codebase"),
|
|
2170
|
+
},
|
|
2171
|
+
async execute(args, ctx) {
|
|
2172
|
+
const spec = parseRepoSpec(args.repo)
|
|
2173
|
+
const branch = spec.branch || defaultBranch
|
|
2174
|
+
const repoKey = `${spec.owner}/${spec.repo}`
|
|
2175
|
+
let repoPath: string
|
|
2176
|
+
|
|
2177
|
+
try {
|
|
2178
|
+
const resolved = await ensureRepoAvailable(
|
|
2179
|
+
repoKey,
|
|
2180
|
+
branch,
|
|
2181
|
+
cacheDir,
|
|
2182
|
+
useHttps,
|
|
2183
|
+
autoSyncOnExplore,
|
|
2184
|
+
autoSyncIntervalHours
|
|
2185
|
+
)
|
|
2186
|
+
repoPath = resolved.repoPath
|
|
2187
|
+
await requestExternalDirectoryAccess(ctx as PermissionContext, repoPath)
|
|
2188
|
+
} catch (error) {
|
|
2189
|
+
const message =
|
|
2190
|
+
error instanceof Error ? error.message : String(error)
|
|
2191
|
+
const output = `## Failed to prepare repository
|
|
786
2192
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
return
|
|
2193
|
+
Failed to prepare ${args.repo}: ${message}
|
|
2194
|
+
|
|
2195
|
+
Please check that the repository exists and you have access to it.`
|
|
2196
|
+
return appendDebug(
|
|
2197
|
+
output,
|
|
2198
|
+
"repo_explore",
|
|
2199
|
+
[`repoKey: ${repoKey}`, `branch: ${branch}`],
|
|
2200
|
+
debugEnabled
|
|
2201
|
+
)
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
try {
|
|
2205
|
+
const response = await runRepoExplorer(
|
|
2206
|
+
client as RepoClient,
|
|
2207
|
+
ctx,
|
|
2208
|
+
repoKey,
|
|
2209
|
+
repoPath,
|
|
2210
|
+
args.question,
|
|
2211
|
+
repoExplorerModel,
|
|
2212
|
+
directory
|
|
2213
|
+
)
|
|
2214
|
+
await touchRepoAccess(repoKey)
|
|
2215
|
+
return appendDebug(
|
|
2216
|
+
response,
|
|
2217
|
+
"repo_explore",
|
|
2218
|
+
[`repoKey: ${repoKey}`, `branch: ${branch}`, `path: ${repoPath}`],
|
|
2219
|
+
debugEnabled
|
|
2220
|
+
)
|
|
791
2221
|
} catch (error) {
|
|
792
2222
|
const message =
|
|
793
2223
|
error instanceof Error ? error.message : String(error)
|
|
794
|
-
|
|
2224
|
+
const output = `## Exploration failed
|
|
795
2225
|
|
|
796
2226
|
Failed to spawn exploration agent: ${message}
|
|
797
2227
|
|
|
798
2228
|
This may indicate an issue with the OpenCode session or agent registration.`
|
|
2229
|
+
return appendDebug(
|
|
2230
|
+
output,
|
|
2231
|
+
"repo_explore",
|
|
2232
|
+
[`repoKey: ${repoKey}`, `branch: ${branch}`],
|
|
2233
|
+
debugEnabled
|
|
2234
|
+
)
|
|
799
2235
|
}
|
|
800
2236
|
},
|
|
801
2237
|
}),
|