opencode-repo-local 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # opencode-repo-local
2
+
3
+ OpenCode plugin that ensures a repository exists locally (clone or update) and returns an absolute path so you can use OpenCode built-in tools directly.
4
+
5
+ ## What it does
6
+
7
+ - Exposes one custom tool: `repo_ensure_local`
8
+ - Clones missing repositories into a deterministic local root
9
+ - Updates existing repositories with safe, non-destructive defaults
10
+ - Returns structured output including `local_path` for immediate `Read` / `Glob` / `Grep` / `Bash` usage
11
+
12
+ ## Installation
13
+
14
+ ### From npm
15
+
16
+ Add the package to your OpenCode config:
17
+
18
+ ```json
19
+ {
20
+ "$schema": "https://opencode.ai/config.json",
21
+ "plugin": ["opencode-repo-local"]
22
+ }
23
+ ```
24
+
25
+ ### Local development
26
+
27
+ 1. Install dependencies:
28
+
29
+ ```bash
30
+ bun install
31
+ ```
32
+
33
+ 2. For local OpenCode testing in this repo, use the included local plugin wiring:
34
+
35
+ - `.opencode/plugins/repo-local-plugin.ts` imports `src/index.ts` directly for fast iteration (no build step required)
36
+ - `.opencode/package.json` installs plugin runtime dependencies for OpenCode
37
+
38
+ 3. For a publishable artifact check, run `bun run build` before release.
39
+
40
+ 4. Or publish and install via npm for regular usage.
41
+
42
+ ## Tool: `repo_ensure_local`
43
+
44
+ Arguments:
45
+
46
+ - `repo` (required): repository reference in one of these forms:
47
+ - `https://host/owner/repo(.git)`
48
+ - `git@host:owner/repo.git` (when `allow_ssh` is true)
49
+ - `host/owner/repo`
50
+ - `owner/repo` (GitHub shorthand)
51
+ - `ref` (optional): branch/tag/SHA to checkout after clone/fetch
52
+ - `clone_root` (optional): absolute path override for clone root
53
+ - `depth` (optional): shallow clone depth
54
+ - `update_mode` (optional): `ff-only` (default), `fetch-only`, `reset-clean`
55
+ - `allow_ssh` (optional): allow `git@host:owner/repo.git` URLs
56
+
57
+ Output fields:
58
+
59
+ - `status`: `cloned` | `updated` | `already-current` | `fetched`
60
+ - `repo_url`
61
+ - `local_path`
62
+ - `current_ref`
63
+ - `default_branch`
64
+ - `head_sha`
65
+ - `comparison_ref`
66
+ - `remote_head_sha`
67
+ - `ahead_by`
68
+ - `behind_by`
69
+ - `freshness`: `current` | `stale` | `ahead` | `diverged` | `unknown`
70
+ - `actions`
71
+ - `instructions`
72
+
73
+ Freshness semantics:
74
+
75
+ - Default `update_mode=ff-only` is the recommended one-call path for agents: it updates when safe and returns freshness metadata.
76
+ - Use `update_mode=fetch-only` when you explicitly want non-mutating freshness/version checks.
77
+
78
+ ## Environment variables
79
+
80
+ - `OPENCODE_REPO_CLONE_ROOT`: default clone root (fallback is `~/.opencode/repos`)
81
+ - `OPENCODE_REPO_ALLOW_SSH=true`: default SSH URL allowance
82
+ - `OPENCODE_REPO_TELEMETRY_PATH`: optional telemetry JSONL path override
83
+
84
+ ## Telemetry
85
+
86
+ - `repo_ensure_local` writes invocation telemetry on every run.
87
+ - Default file: `~/.local/share/opencode/plugins/opencode-repo-local/telemetry.jsonl`
88
+ - Event fields include `repo_input`, `canonical_repo_url`, `status`, `local_path`, and error metadata.
89
+
90
+ ## OpenCode permissions
91
+
92
+ - This repo includes `opencode.json` with:
93
+ - `permission.external_directory["~/.opencode/repos/**"] = "allow"`
94
+ - This lets OpenCode built-in tools access cloned repos under `~/.opencode/repos` without repeated approval prompts.
95
+ - Recommended for users of this plugin: add the same permission rule to your own global or project OpenCode config.
96
+
97
+ ## Local OpenCode smoke test
98
+
99
+ Use this to validate the plugin in a real OpenCode session.
100
+
101
+ 1. Confirm OpenCode loads the local plugin shim:
102
+
103
+ ```bash
104
+ opencode debug config
105
+ ```
106
+
107
+ Verify plugin list includes `.opencode/plugins/repo-local-plugin.ts`.
108
+
109
+ 2. Forced tool smoke test (deterministic):
110
+
111
+ ```bash
112
+ opencode run "You must call repo_ensure_local first. Use repo='Aureatus/opencode-repo-local' and update_mode='fetch-only'. Then report only: status, repo_url, local_path, head_sha."
113
+ ```
114
+
115
+ 3. Natural-intent smoke test (agent should choose the tool):
116
+
117
+ ```bash
118
+ opencode run "Please inspect ghoulr/opencode-websearch-cited, find the custom tool it exports, and report the file path where it is defined."
119
+ ```
120
+
121
+ Important:
122
+
123
+ - Run the natural-intent test against a repo that is not your current workspace.
124
+ - If you reference the current repo, OpenCode may correctly skip `repo_ensure_local` because local files are already available.
125
+
126
+ Expected behavior:
127
+
128
+ - OpenCode chooses `repo_ensure_local` for external repo references.
129
+ - Output includes a valid `local_path` under `~/.opencode/repos/...`.
130
+ - Follow-up inspection uses built-in tools (`Read`, `Glob`, `Grep`, `Bash`) against that local path.
131
+
132
+ ## Safety behavior
133
+
134
+ - Rejects malformed/unsupported repo URLs
135
+ - Prevents clone path escape outside clone root
136
+ - Validates existing clone remote against requested repository
137
+ - Avoids destructive sync by default (`ff-only`)
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ bun install
143
+ bun run fix
144
+ bun run check
145
+ bun run lint
146
+ bun run typecheck
147
+ bun run test
148
+ bun run test:integration
149
+ bun run test:e2e
150
+ bun run build
151
+ ```
152
+
153
+ ## Releasing
154
+
155
+ - This project uses tag-driven publishing to npm via GitHub Actions.
156
+ - Use release helper scripts:
157
+ - `bun run release:verify`
158
+ - `bun run release:patch|minor|major`
159
+ - `bun run release:beta:first` / `bun run release:beta:next`
160
+ - Push version commit and tag with `git push origin main --follow-tags`.
161
+ - Full runbook: see `RELEASING.md`.
162
+
163
+ Integration script notes:
164
+
165
+ - Default run validates multiple allowed input formats against `Aureatus/opencode-repo-local`.
166
+ - Override to a single repo input: `bun run test:integration -- https://github.com/OWNER/REPO.git`
167
+ - Keep clone directory for inspection: `OPENCODE_REPO_INTEGRATION_KEEP=true bun run test:integration`
168
+ - Set custom clone root: `OPENCODE_REPO_INTEGRATION_ROOT=/abs/path bun run test:integration`
169
+
170
+ E2E script notes:
171
+
172
+ - `bun run test:e2e` runs real `opencode run` prompts and asserts tool usage via telemetry.
173
+ - It validates all supported repo input formats across two required targets:
174
+ - `Aureatus/opencode-repo-local`
175
+ - `ghoulr/opencode-websearch-cited`
176
+ - It checks each target's formats resolve to one normalized local path.
177
+ - Keep temporary artifacts for inspection: `OPENCODE_REPO_E2E_KEEP=true bun run test:e2e`
178
+ - The test is valid because each run uses:
179
+ - a fresh temporary clone root (`/tmp/opencode-repo-e2e-.../clones`), not the current workspace,
180
+ - a run-specific telemetry file,
181
+ - real OpenCode prompts that must trigger `repo_ensure_local`.
182
+ - This verifies end-to-end behavior (tool invocation, clone/update, and normalized path resolution) without depending on local workspace files.
183
+
184
+ ## Git hooks
185
+
186
+ - This repo uses Husky for pre-commit and pre-push hooks.
187
+ - Full local check command: `bun run check` (runs no-ignore guard, `lint`, `typecheck`, `test`, `build`, and `test:integration` in parallel where possible).
188
+ - Build command: `bun run build` (`tsdown --fail-on-warn`, warnings fail the build).
189
+ - Lint command: `bun run lint` (Ultracite/Biome with `--error-on-warnings`).
190
+ - Fix command: `bun run fix` (Ultracite safe + unsafe fixes, then no-ignore guard).
191
+ - Pre-commit command: `bun run check`.
192
+ - Pre-push command: `bun run check`.
193
+ - Hooks are installed by running `bun install` via the `prepare` script.
@@ -0,0 +1,7 @@
1
+ import { Plugin } from "@opencode-ai/plugin";
2
+
3
+ //#region src/index.d.ts
4
+ declare const RepoLocalPlugin: Plugin;
5
+ //#endregion
6
+ export { RepoLocalPlugin, RepoLocalPlugin as default };
7
+ //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs ADDED
@@ -0,0 +1,543 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { spawn } from "node:child_process";
3
+ import { appendFile, mkdir, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+
7
+ //#region src/lib/errors.ts
8
+ var RepoPluginError = class extends Error {
9
+ code;
10
+ details;
11
+ constructor(code, message, details) {
12
+ super(message);
13
+ this.name = "RepoPluginError";
14
+ this.code = code;
15
+ this.details = details;
16
+ }
17
+ };
18
+ function toRepoPluginError(error) {
19
+ if (error instanceof RepoPluginError) return error;
20
+ if (error instanceof Error) return new RepoPluginError("INTERNAL_ERROR", error.message);
21
+ return new RepoPluginError("INTERNAL_ERROR", "Unknown failure while handling repository operation");
22
+ }
23
+
24
+ //#endregion
25
+ //#region src/lib/git.ts
26
+ const DEFAULT_TIMEOUT_MS = 12e4;
27
+ const WHITESPACE_PATTERN = /\s+/;
28
+ function runGitRaw(args, options = {}) {
29
+ const cwd = options.cwd;
30
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
31
+ return new Promise((resolve, reject) => {
32
+ const processRef = spawn("git", args, {
33
+ cwd,
34
+ stdio: [
35
+ "ignore",
36
+ "pipe",
37
+ "pipe"
38
+ ]
39
+ });
40
+ let stdout = "";
41
+ let stderr = "";
42
+ let timeoutId;
43
+ if (timeoutMs > 0) timeoutId = setTimeout(() => {
44
+ processRef.kill("SIGTERM");
45
+ }, timeoutMs);
46
+ processRef.stdout.on("data", (chunk) => {
47
+ stdout += chunk.toString();
48
+ });
49
+ processRef.stderr.on("data", (chunk) => {
50
+ stderr += chunk.toString();
51
+ });
52
+ processRef.on("error", (error) => {
53
+ if (timeoutId) clearTimeout(timeoutId);
54
+ if (error.code === "ENOENT") {
55
+ reject(new RepoPluginError("GIT_NOT_FOUND", "git binary not found on PATH"));
56
+ return;
57
+ }
58
+ reject(new RepoPluginError("GIT_FAILURE", "Failed to start git command", String(error)));
59
+ });
60
+ processRef.on("close", (exitCode) => {
61
+ if (timeoutId) clearTimeout(timeoutId);
62
+ resolve({
63
+ stdout: stdout.trim(),
64
+ stderr: stderr.trim(),
65
+ exitCode: exitCode ?? 1
66
+ });
67
+ });
68
+ });
69
+ }
70
+ async function runGit(args, options = {}) {
71
+ const result = await runGitRaw(args, options);
72
+ if (result.exitCode !== 0) throw new RepoPluginError("GIT_FAILURE", "git command failed", [`git ${args.join(" ")}`, result.stderr || result.stdout].filter(Boolean).join("\n"));
73
+ return result.stdout;
74
+ }
75
+ async function ensureGitAvailable() {
76
+ await runGit(["--version"]);
77
+ }
78
+ async function directoryExists(target) {
79
+ try {
80
+ return (await stat(target)).isDirectory();
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+ async function isGitRepository(cwd) {
86
+ const result = await runGitRaw(["rev-parse", "--is-inside-work-tree"], { cwd });
87
+ return result.exitCode === 0 && result.stdout === "true";
88
+ }
89
+ async function cloneRepo(repoUrl, targetPath, depth) {
90
+ await mkdir(path.dirname(targetPath), { recursive: true });
91
+ const args = [
92
+ "clone",
93
+ "--origin",
94
+ "origin"
95
+ ];
96
+ if (depth !== void 0) args.push("--depth", String(depth));
97
+ args.push(repoUrl, targetPath);
98
+ await runGit(args);
99
+ }
100
+ async function fetchOrigin(cwd) {
101
+ await runGit([
102
+ "fetch",
103
+ "--prune",
104
+ "origin"
105
+ ], { cwd });
106
+ }
107
+ async function checkoutRef(cwd, ref) {
108
+ await runGit(["checkout", ref], { cwd });
109
+ }
110
+ async function pullFfOnlyForBranch(cwd, branch) {
111
+ await runGit([
112
+ "pull",
113
+ "--ff-only",
114
+ "--prune",
115
+ "origin",
116
+ branch
117
+ ], { cwd });
118
+ }
119
+ async function hardResetToOriginBranch(cwd, branch) {
120
+ await runGit([
121
+ "reset",
122
+ "--hard",
123
+ `origin/${branch}`
124
+ ], { cwd });
125
+ await runGit(["clean", "-fd"], { cwd });
126
+ }
127
+ function getHeadSha(cwd) {
128
+ return runGit(["rev-parse", "HEAD"], { cwd });
129
+ }
130
+ function getCurrentRef(cwd) {
131
+ return runGit([
132
+ "rev-parse",
133
+ "--abbrev-ref",
134
+ "HEAD"
135
+ ], { cwd });
136
+ }
137
+ async function getDefaultBranch(cwd) {
138
+ const result = await runGitRaw([
139
+ "symbolic-ref",
140
+ "--short",
141
+ "refs/remotes/origin/HEAD"
142
+ ], { cwd });
143
+ if (result.exitCode !== 0 || !result.stdout) return null;
144
+ return result.stdout.startsWith("origin/") ? result.stdout.slice(7) : result.stdout;
145
+ }
146
+ function getOriginUrl(cwd) {
147
+ return runGit([
148
+ "remote",
149
+ "get-url",
150
+ "origin"
151
+ ], { cwd });
152
+ }
153
+ async function isWorktreeDirty(cwd) {
154
+ return (await runGit(["status", "--porcelain"], { cwd })).length > 0;
155
+ }
156
+ async function getUpstreamRef(cwd) {
157
+ const result = await runGitRaw([
158
+ "rev-parse",
159
+ "--abbrev-ref",
160
+ "--symbolic-full-name",
161
+ "@{upstream}"
162
+ ], { cwd });
163
+ if (result.exitCode !== 0 || !result.stdout) return null;
164
+ return result.stdout;
165
+ }
166
+ async function getRefSha(cwd, ref) {
167
+ const result = await runGitRaw(["rev-parse", ref], { cwd });
168
+ if (result.exitCode !== 0 || !result.stdout) return null;
169
+ return result.stdout;
170
+ }
171
+ async function getAheadBehindCounts(cwd, localRef, remoteRef) {
172
+ const result = await runGitRaw([
173
+ "rev-list",
174
+ "--left-right",
175
+ "--count",
176
+ `${localRef}...${remoteRef}`
177
+ ], { cwd });
178
+ if (result.exitCode !== 0 || !result.stdout) return null;
179
+ const [leftCountRaw, rightCountRaw] = result.stdout.split(WHITESPACE_PATTERN);
180
+ const aheadBy = Number.parseInt(leftCountRaw ?? "", 10);
181
+ const behindBy = Number.parseInt(rightCountRaw ?? "", 10);
182
+ if (Number.isNaN(aheadBy) || Number.isNaN(behindBy)) return null;
183
+ return {
184
+ aheadBy,
185
+ behindBy
186
+ };
187
+ }
188
+
189
+ //#endregion
190
+ //#region src/lib/paths.ts
191
+ const DEFAULT_CLONE_ROOT = "~/.opencode/repos";
192
+ function expandHome(input) {
193
+ if (input === "~") return os.homedir();
194
+ if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2));
195
+ return input;
196
+ }
197
+ function sanitizeSegment(segment) {
198
+ const sanitized = segment.replace(/[^A-Za-z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
199
+ if (!sanitized) throw new RepoPluginError("INVALID_URL", `Could not derive a safe path segment from: ${segment}`);
200
+ return sanitized;
201
+ }
202
+ function ensureWithinRoot(root, target) {
203
+ const resolvedRoot = path.resolve(root);
204
+ const resolvedTarget = path.resolve(target);
205
+ const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;
206
+ if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(prefix)) throw new RepoPluginError("PATH_VIOLATION", "Resolved repository path escaped clone root");
207
+ }
208
+ async function resolveCloneRoot(override) {
209
+ const fromEnv = process.env.OPENCODE_REPO_CLONE_ROOT?.trim();
210
+ const expanded = expandHome(override?.trim() || fromEnv || DEFAULT_CLONE_ROOT);
211
+ if (override?.trim() && !path.isAbsolute(expanded)) throw new RepoPluginError("INVALID_CLONE_ROOT", "clone_root must be an absolute path");
212
+ const root = path.resolve(expanded);
213
+ await mkdir(root, { recursive: true });
214
+ return root;
215
+ }
216
+ function buildRepoPath(cloneRoot, repo) {
217
+ const safeSegments = repo.pathSegments.map(sanitizeSegment);
218
+ const target = path.resolve(cloneRoot, repo.host, ...safeSegments);
219
+ ensureWithinRoot(cloneRoot, target);
220
+ return target;
221
+ }
222
+
223
+ //#endregion
224
+ //#region src/lib/telemetry.ts
225
+ const TELEMETRY_RELATIVE_PATH = ".local/share/opencode/plugins/opencode-repo-local/telemetry.jsonl";
226
+ function defaultTelemetryPath() {
227
+ return path.join(os.homedir(), TELEMETRY_RELATIVE_PATH);
228
+ }
229
+ function resolveTelemetryPath() {
230
+ const fromEnv = process.env.OPENCODE_REPO_TELEMETRY_PATH?.trim();
231
+ if (fromEnv) return path.resolve(fromEnv);
232
+ return defaultTelemetryPath();
233
+ }
234
+ async function appendEvent(event) {
235
+ const telemetryPath = resolveTelemetryPath();
236
+ await mkdir(path.dirname(telemetryPath), { recursive: true });
237
+ await appendFile(telemetryPath, `${JSON.stringify(event)}\n`, "utf8");
238
+ }
239
+ async function logRepoEnsureSuccess(args, result) {
240
+ const event = {
241
+ event: "repo_ensure_local",
242
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
243
+ ok: true,
244
+ repo_input: args.repo,
245
+ canonical_repo_url: result.repo_url,
246
+ local_path: result.local_path,
247
+ status: result.status,
248
+ freshness: result.freshness,
249
+ ahead_by: result.ahead_by,
250
+ behind_by: result.behind_by,
251
+ update_mode: args.update_mode ?? "ff-only",
252
+ ref: args.ref ?? null,
253
+ error_code: null,
254
+ error_message: null
255
+ };
256
+ try {
257
+ await appendEvent(event);
258
+ } catch {}
259
+ }
260
+ async function logRepoEnsureFailure(args, error) {
261
+ const parsedError = toRepoPluginError(error);
262
+ const event = {
263
+ event: "repo_ensure_local",
264
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
265
+ ok: false,
266
+ repo_input: args.repo,
267
+ canonical_repo_url: null,
268
+ local_path: null,
269
+ status: null,
270
+ freshness: null,
271
+ ahead_by: null,
272
+ behind_by: null,
273
+ update_mode: args.update_mode ?? "ff-only",
274
+ ref: args.ref ?? null,
275
+ error_code: parsedError.code,
276
+ error_message: parsedError.message
277
+ };
278
+ try {
279
+ await appendEvent(event);
280
+ } catch {}
281
+ }
282
+
283
+ //#endregion
284
+ //#region src/lib/url.ts
285
+ const SSH_PATTERN = /^git@([^:/]+):(.+)$/;
286
+ const GIT_SUFFIX_PATTERN = /\.git$/i;
287
+ const HTTP_OR_HTTPS_PATTERN = /^https?:\/\//i;
288
+ const GITHUB_WEB_MARKERS = new Set([
289
+ "tree",
290
+ "blob",
291
+ "commit",
292
+ "pull",
293
+ "issues",
294
+ "actions",
295
+ "releases",
296
+ "wiki"
297
+ ]);
298
+ function normalizePathSegments(host, segments) {
299
+ if (host.toLowerCase() !== "github.com") return segments;
300
+ if (segments.length >= 3 && GITHUB_WEB_MARKERS.has(segments[2])) return segments.slice(0, 2);
301
+ return segments;
302
+ }
303
+ function splitPathSegments(input) {
304
+ const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
305
+ if (!trimmed) return [];
306
+ const segments = trimmed.split("/").filter(Boolean);
307
+ if (segments.length === 0) return [];
308
+ const lastIndex = segments.length - 1;
309
+ segments[lastIndex] = segments[lastIndex].replace(GIT_SUFFIX_PATTERN, "");
310
+ return segments;
311
+ }
312
+ function validateSegments(segments) {
313
+ if (segments.length < 2) throw new RepoPluginError("INVALID_URL", "Repository URL must include owner and repository name");
314
+ for (const segment of segments) if (!segment || segment === "." || segment === "..") throw new RepoPluginError("INVALID_URL", "Repository URL contains an invalid path segment");
315
+ }
316
+ function makeRepoKey(host, segments) {
317
+ return `${host.toLowerCase()}/${segments.join("/").toLowerCase()}`;
318
+ }
319
+ function buildParsed(raw, host, segmentsInput, protocol) {
320
+ const segments = normalizePathSegments(host, Array.isArray(segmentsInput) ? segmentsInput : splitPathSegments(segmentsInput));
321
+ validateSegments(segments);
322
+ const normalizedHost = host.toLowerCase();
323
+ const canonicalPath = segments.join("/");
324
+ return {
325
+ raw,
326
+ host: normalizedHost,
327
+ pathSegments: segments,
328
+ canonicalUrl: protocol === "https" ? `https://${normalizedHost}/${canonicalPath}.git` : `git@${normalizedHost}:${canonicalPath}.git`,
329
+ key: makeRepoKey(normalizedHost, segments)
330
+ };
331
+ }
332
+ function parseHttpLikeUrl(raw) {
333
+ const url = new URL(raw);
334
+ if (url.protocol !== "https:") throw new RepoPluginError("INVALID_URL", "Repository URL must use https:// format");
335
+ return buildParsed(raw, url.hostname, splitPathSegments(url.pathname), "https");
336
+ }
337
+ function parseHostWithPath(raw) {
338
+ if (raw.includes("://") || raw.startsWith("git@")) return null;
339
+ const firstSlash = raw.indexOf("/");
340
+ if (firstSlash <= 0) return null;
341
+ const host = raw.slice(0, firstSlash).trim();
342
+ const pathValue = raw.slice(firstSlash + 1).trim();
343
+ if (!(host && pathValue)) return null;
344
+ if (!(host.includes(".") || host.includes(":"))) return null;
345
+ return buildParsed(raw, host, splitPathSegments(pathValue), "https");
346
+ }
347
+ function parseGitHubShorthand(raw) {
348
+ if (raw.includes("://") || raw.startsWith("git@")) return null;
349
+ const segments = splitPathSegments(raw);
350
+ if (segments.length < 2) return null;
351
+ return buildParsed(raw, "github.com", segments, "https");
352
+ }
353
+ function invalidUrlErrorMessage(allowSsh) {
354
+ if (allowSsh) return "Repository must be one of: https://host/owner/repo(.git), git@host:owner/repo.git, host/owner/repo, or owner/repo (GitHub shorthand)";
355
+ return "Repository must be one of: https://host/owner/repo(.git), host/owner/repo, or owner/repo (GitHub shorthand)";
356
+ }
357
+ function parseRepoUrl(repo, allowSsh) {
358
+ const raw = repo.trim();
359
+ if (!raw) throw new RepoPluginError("INVALID_URL", "Repository URL is required");
360
+ if (HTTP_OR_HTTPS_PATTERN.test(raw)) return parseHttpLikeUrl(raw);
361
+ const hostWithPathParsed = parseHostWithPath(raw);
362
+ if (hostWithPathParsed) return hostWithPathParsed;
363
+ const gitHubShorthandParsed = parseGitHubShorthand(raw);
364
+ if (gitHubShorthandParsed) return gitHubShorthandParsed;
365
+ if (allowSsh) {
366
+ const match = raw.match(SSH_PATTERN);
367
+ if (match) {
368
+ const host = match[1];
369
+ return buildParsed(raw, host, splitPathSegments(match[2] ?? ""), "ssh");
370
+ }
371
+ if (raw.startsWith("ssh://")) {
372
+ const url = new URL(raw);
373
+ if (url.protocol === "ssh:") return buildParsed(raw, url.hostname, splitPathSegments(url.pathname), "ssh");
374
+ }
375
+ }
376
+ throw new RepoPluginError("INVALID_URL", invalidUrlErrorMessage(allowSsh));
377
+ }
378
+
379
+ //#endregion
380
+ //#region src/tools/repo-ensure-local.ts
381
+ const UPDATE_MODES = new Set([
382
+ "ff-only",
383
+ "fetch-only",
384
+ "reset-clean"
385
+ ]);
386
+ const REPO_TOOL_ARGS = {
387
+ repo: tool.schema.string().describe("Remote repository reference to prepare locally. Use this FIRST when a user references a GitHub/remote repo outside the current workspace and the agent needs grounded code inspection. Accepted forms include owner/repo, host/owner/repo, and https URLs."),
388
+ ref: tool.schema.string().optional().describe("Optional branch/tag/sha to checkout after clone/fetch."),
389
+ clone_root: tool.schema.string().optional().describe("Optional absolute clone root path override. Useful in tests and CI for isolated temporary clone directories."),
390
+ depth: tool.schema.number().int().positive().optional().describe("Optional shallow clone depth."),
391
+ update_mode: tool.schema.string().optional().describe("Update policy: ff-only (default), fetch-only, or reset-clean."),
392
+ allow_ssh: tool.schema.boolean().optional().describe("Allow git@host:owner/repo.git URLs. Defaults to false unless OPENCODE_REPO_ALLOW_SSH=true.")
393
+ };
394
+ const ALLOWED_KEYS = new Set(Object.keys(REPO_TOOL_ARGS));
395
+ function normalizeUpdateMode(value) {
396
+ const mode = (value ?? "ff-only").trim();
397
+ if (!UPDATE_MODES.has(mode)) throw new RepoPluginError("INVALID_UPDATE_MODE", `Unsupported update_mode: ${mode}`);
398
+ return mode;
399
+ }
400
+ function formatFailure(error) {
401
+ const parsed = toRepoPluginError(error);
402
+ const detailSuffix = parsed.details ? `\n${parsed.details}` : "";
403
+ throw new Error(`[${parsed.code}] ${parsed.message}${detailSuffix}`);
404
+ }
405
+ function toResultText(result) {
406
+ return JSON.stringify(result, null, 2);
407
+ }
408
+ function assertKnownArgs(args) {
409
+ const extraKeys = Object.keys(args ?? {}).filter((key) => !ALLOWED_KEYS.has(key));
410
+ if (extraKeys.length > 0) throw new RepoPluginError("INVALID_ARGS", `Unknown arguments: ${extraKeys.join(", ")}`);
411
+ }
412
+ function deriveFreshnessFromCounts(counts) {
413
+ if (!counts) return "unknown";
414
+ if (counts.aheadBy === 0 && counts.behindBy === 0) return "current";
415
+ if (counts.aheadBy > 0 && counts.behindBy === 0) return "ahead";
416
+ if (counts.aheadBy === 0 && counts.behindBy > 0) return "stale";
417
+ return "diverged";
418
+ }
419
+ async function resolveComparisonRef(localPath, currentRef) {
420
+ const upstreamRef = await getUpstreamRef(localPath);
421
+ if (upstreamRef) return upstreamRef;
422
+ if (currentRef === "HEAD") return null;
423
+ const fallback = `origin/${currentRef}`;
424
+ if (!await getRefSha(localPath, fallback)) return null;
425
+ return fallback;
426
+ }
427
+ async function computeFreshnessDetails(localPath, currentRef) {
428
+ const comparisonRef = await resolveComparisonRef(localPath, currentRef);
429
+ if (!comparisonRef) return {
430
+ comparisonRef: null,
431
+ remoteHeadSha: null,
432
+ aheadBy: null,
433
+ behindBy: null,
434
+ freshness: "unknown"
435
+ };
436
+ const remoteHeadSha = await getRefSha(localPath, comparisonRef);
437
+ const counts = await getAheadBehindCounts(localPath, "HEAD", comparisonRef);
438
+ return {
439
+ comparisonRef,
440
+ remoteHeadSha,
441
+ aheadBy: counts?.aheadBy ?? null,
442
+ behindBy: counts?.behindBy ?? null,
443
+ freshness: deriveFreshnessFromCounts(counts)
444
+ };
445
+ }
446
+ async function checkoutIfRequested(localPath, ref, actions) {
447
+ if (!ref) return;
448
+ await checkoutRef(localPath, ref);
449
+ actions.push(`checked_out_${ref}`);
450
+ }
451
+ async function runFastForward(localPath, actions) {
452
+ if (await isWorktreeDirty(localPath)) throw new RepoPluginError("DIRTY_WORKTREE", "Cannot fast-forward because working tree has local changes", "Commit/stash changes or use update_mode=fetch-only");
453
+ const currentRef = await getCurrentRef(localPath);
454
+ if (currentRef === "HEAD") {
455
+ actions.push("detached_head_no_pull");
456
+ return;
457
+ }
458
+ await pullFfOnlyForBranch(localPath, currentRef);
459
+ actions.push(`fast_forwarded_${currentRef}`);
460
+ }
461
+ async function runResetClean(localPath, actions) {
462
+ const currentRef = await getCurrentRef(localPath);
463
+ if (currentRef === "HEAD") throw new RepoPluginError("DETACHED_HEAD", "Cannot use reset-clean while repository is in detached HEAD state");
464
+ await hardResetToOriginBranch(localPath, currentRef);
465
+ actions.push(`reset_clean_${currentRef}`);
466
+ }
467
+ async function ensureExistingCloneMatchesRemote(localPath, requestedRepo) {
468
+ if (!await isGitRepository(localPath)) throw new RepoPluginError("NOT_GIT_REPO", `Target path exists but is not a git repository: ${localPath}`);
469
+ const existingOrigin = parseRepoUrl(await getOriginUrl(localPath), true);
470
+ if (existingOrigin.key === requestedRepo.key) return;
471
+ throw new RepoPluginError("REPO_URL_MISMATCH", "Existing clone origin does not match requested repository", `requested=${requestedRepo.canonicalUrl}\nexisting=${existingOrigin.canonicalUrl}`);
472
+ }
473
+ async function cloneMissingRepo(localPath, repoUrl, depth, ref, actions) {
474
+ await cloneRepo(repoUrl, localPath, depth);
475
+ actions.push("cloned_repository");
476
+ await checkoutIfRequested(localPath, ref, actions);
477
+ return "cloned";
478
+ }
479
+ async function updateExistingRepo(localPath, requestedRepo, mode, ref, actions) {
480
+ await ensureExistingCloneMatchesRemote(localPath, requestedRepo);
481
+ const beforeSha = await getHeadSha(localPath);
482
+ await fetchOrigin(localPath);
483
+ actions.push("fetched_origin");
484
+ await checkoutIfRequested(localPath, ref, actions);
485
+ if (mode === "ff-only") await runFastForward(localPath, actions);
486
+ if (mode === "reset-clean") await runResetClean(localPath, actions);
487
+ if (mode === "fetch-only") return "fetched";
488
+ return beforeSha === await getHeadSha(localPath) ? "already-current" : "updated";
489
+ }
490
+ async function repoEnsureLocal(args) {
491
+ assertKnownArgs(args);
492
+ const repoInput = args.repo?.trim();
493
+ if (!repoInput) throw new RepoPluginError("INVALID_URL", "repo argument cannot be empty");
494
+ const ref = args.ref?.trim() || void 0;
495
+ const mode = normalizeUpdateMode(args.update_mode);
496
+ const parsedRepo = parseRepoUrl(repoInput, args.allow_ssh ?? process.env.OPENCODE_REPO_ALLOW_SSH === "true");
497
+ await ensureGitAvailable();
498
+ const localPath = buildRepoPath(await resolveCloneRoot(args.clone_root), parsedRepo);
499
+ const actions = [];
500
+ const status = await directoryExists(localPath) ? await updateExistingRepo(localPath, parsedRepo, mode, ref, actions) : await cloneMissingRepo(localPath, parsedRepo.canonicalUrl, args.depth, ref, actions);
501
+ const currentRef = await getCurrentRef(localPath);
502
+ const freshness = await computeFreshnessDetails(localPath, currentRef);
503
+ return {
504
+ status,
505
+ repo_url: parsedRepo.canonicalUrl,
506
+ local_path: localPath,
507
+ current_ref: currentRef,
508
+ default_branch: await getDefaultBranch(localPath),
509
+ head_sha: await getHeadSha(localPath),
510
+ comparison_ref: freshness.comparisonRef,
511
+ remote_head_sha: freshness.remoteHeadSha,
512
+ ahead_by: freshness.aheadBy,
513
+ behind_by: freshness.behindBy,
514
+ freshness: freshness.freshness,
515
+ actions,
516
+ instructions: [`Use built-in tools with local_path: ${localPath}`, `Example: run Grep/Read/Glob with files under ${localPath}`]
517
+ };
518
+ }
519
+ const repoEnsureLocalTool = tool({
520
+ description: "Prepare external repositories for investigation. If a request references a GitHub/remote repo not already in the workspace, call this tool before Read/Grep/Glob/Bash so analysis is grounded in local source code. Returns absolute local_path plus freshness/version metadata (head SHA, remote SHA, ahead/behind).",
521
+ args: REPO_TOOL_ARGS,
522
+ async execute(args) {
523
+ const typedArgs = args;
524
+ try {
525
+ const result = await repoEnsureLocal(typedArgs);
526
+ await logRepoEnsureSuccess(typedArgs, result);
527
+ return toResultText(result);
528
+ } catch (error) {
529
+ await logRepoEnsureFailure(typedArgs, error);
530
+ formatFailure(error);
531
+ }
532
+ }
533
+ });
534
+
535
+ //#endregion
536
+ //#region src/index.ts
537
+ const RepoLocalPlugin = () => {
538
+ return Promise.resolve({ tool: { repo_ensure_local: repoEnsureLocalTool } });
539
+ };
540
+
541
+ //#endregion
542
+ export { RepoLocalPlugin, RepoLocalPlugin as default };
543
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/lib/errors.ts","../src/lib/git.ts","../src/lib/paths.ts","../src/lib/telemetry.ts","../src/lib/url.ts","../src/tools/repo-ensure-local.ts","../src/index.ts"],"sourcesContent":["export class RepoPluginError extends Error {\n code: string;\n details?: string;\n\n constructor(code: string, message: string, details?: string) {\n super(message);\n this.name = \"RepoPluginError\";\n this.code = code;\n this.details = details;\n }\n}\n\nexport function toRepoPluginError(error: unknown): RepoPluginError {\n if (error instanceof RepoPluginError) {\n return error;\n }\n\n if (error instanceof Error) {\n return new RepoPluginError(\"INTERNAL_ERROR\", error.message);\n }\n\n return new RepoPluginError(\n \"INTERNAL_ERROR\",\n \"Unknown failure while handling repository operation\"\n );\n}\n","import { spawn } from \"node:child_process\";\nimport { mkdir, stat } from \"node:fs/promises\";\nimport path from \"node:path\";\n\nimport { RepoPluginError } from \"./errors\";\n\ninterface RunGitOptions {\n cwd?: string;\n timeoutMs?: number;\n}\n\ninterface RunGitResult {\n stdout: string;\n stderr: string;\n exitCode: number;\n}\n\nexport interface AheadBehindCounts {\n aheadBy: number;\n behindBy: number;\n}\n\nconst DEFAULT_TIMEOUT_MS = 120_000;\nconst WHITESPACE_PATTERN = /\\s+/;\n\nfunction runGitRaw(\n args: string[],\n options: RunGitOptions = {}\n): Promise<RunGitResult> {\n const cwd = options.cwd;\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n return new Promise((resolve, reject) => {\n const processRef = spawn(\"git\", args, {\n cwd,\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n\n let stdout = \"\";\n let stderr = \"\";\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n\n if (timeoutMs > 0) {\n timeoutId = setTimeout(() => {\n processRef.kill(\"SIGTERM\");\n }, timeoutMs);\n }\n\n processRef.stdout.on(\"data\", (chunk) => {\n stdout += chunk.toString();\n });\n\n processRef.stderr.on(\"data\", (chunk) => {\n stderr += chunk.toString();\n });\n\n processRef.on(\"error\", (error) => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n }\n\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") {\n reject(\n new RepoPluginError(\"GIT_NOT_FOUND\", \"git binary not found on PATH\")\n );\n return;\n }\n\n reject(\n new RepoPluginError(\n \"GIT_FAILURE\",\n \"Failed to start git command\",\n String(error)\n )\n );\n });\n\n processRef.on(\"close\", (exitCode) => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n }\n\n resolve({\n stdout: stdout.trim(),\n stderr: stderr.trim(),\n exitCode: exitCode ?? 1,\n });\n });\n });\n}\n\nasync function runGit(\n args: string[],\n options: RunGitOptions = {}\n): Promise<string> {\n const result = await runGitRaw(args, options);\n if (result.exitCode !== 0) {\n const details = [`git ${args.join(\" \")}`, result.stderr || result.stdout]\n .filter(Boolean)\n .join(\"\\n\");\n throw new RepoPluginError(\"GIT_FAILURE\", \"git command failed\", details);\n }\n return result.stdout;\n}\n\nexport async function ensureGitAvailable(): Promise<void> {\n await runGit([\"--version\"]);\n}\n\nexport async function directoryExists(target: string): Promise<boolean> {\n try {\n const info = await stat(target);\n return info.isDirectory();\n } catch {\n return false;\n }\n}\n\nexport async function isGitRepository(cwd: string): Promise<boolean> {\n const result = await runGitRaw([\"rev-parse\", \"--is-inside-work-tree\"], {\n cwd,\n });\n return result.exitCode === 0 && result.stdout === \"true\";\n}\n\nexport async function cloneRepo(\n repoUrl: string,\n targetPath: string,\n depth?: number\n): Promise<void> {\n await mkdir(path.dirname(targetPath), { recursive: true });\n const args = [\"clone\", \"--origin\", \"origin\"];\n if (depth !== undefined) {\n args.push(\"--depth\", String(depth));\n }\n args.push(repoUrl, targetPath);\n await runGit(args);\n}\n\nexport async function fetchOrigin(cwd: string): Promise<void> {\n await runGit([\"fetch\", \"--prune\", \"origin\"], { cwd });\n}\n\nexport async function checkoutRef(cwd: string, ref: string): Promise<void> {\n await runGit([\"checkout\", ref], { cwd });\n}\n\nexport async function pullFfOnlyForBranch(\n cwd: string,\n branch: string\n): Promise<void> {\n await runGit([\"pull\", \"--ff-only\", \"--prune\", \"origin\", branch], { cwd });\n}\n\nexport async function hardResetToOriginBranch(\n cwd: string,\n branch: string\n): Promise<void> {\n await runGit([\"reset\", \"--hard\", `origin/${branch}`], { cwd });\n await runGit([\"clean\", \"-fd\"], { cwd });\n}\n\nexport function getHeadSha(cwd: string): Promise<string> {\n return runGit([\"rev-parse\", \"HEAD\"], { cwd });\n}\n\nexport function getCurrentRef(cwd: string): Promise<string> {\n return runGit([\"rev-parse\", \"--abbrev-ref\", \"HEAD\"], { cwd });\n}\n\nexport async function getDefaultBranch(cwd: string): Promise<string | null> {\n const result = await runGitRaw(\n [\"symbolic-ref\", \"--short\", \"refs/remotes/origin/HEAD\"],\n { cwd }\n );\n if (result.exitCode !== 0 || !result.stdout) {\n return null;\n }\n\n const prefix = \"origin/\";\n return result.stdout.startsWith(prefix)\n ? result.stdout.slice(prefix.length)\n : result.stdout;\n}\n\nexport function getOriginUrl(cwd: string): Promise<string> {\n return runGit([\"remote\", \"get-url\", \"origin\"], { cwd });\n}\n\nexport async function isWorktreeDirty(cwd: string): Promise<boolean> {\n const output = await runGit([\"status\", \"--porcelain\"], { cwd });\n return output.length > 0;\n}\n\nexport async function getUpstreamRef(cwd: string): Promise<string | null> {\n const result = await runGitRaw(\n [\"rev-parse\", \"--abbrev-ref\", \"--symbolic-full-name\", \"@{upstream}\"],\n { cwd }\n );\n if (result.exitCode !== 0 || !result.stdout) {\n return null;\n }\n\n return result.stdout;\n}\n\nexport async function getRefSha(\n cwd: string,\n ref: string\n): Promise<string | null> {\n const result = await runGitRaw([\"rev-parse\", ref], { cwd });\n if (result.exitCode !== 0 || !result.stdout) {\n return null;\n }\n\n return result.stdout;\n}\n\nexport async function getAheadBehindCounts(\n cwd: string,\n localRef: string,\n remoteRef: string\n): Promise<AheadBehindCounts | null> {\n const result = await runGitRaw(\n [\"rev-list\", \"--left-right\", \"--count\", `${localRef}...${remoteRef}`],\n { cwd }\n );\n if (result.exitCode !== 0 || !result.stdout) {\n return null;\n }\n\n const [leftCountRaw, rightCountRaw] = result.stdout.split(WHITESPACE_PATTERN);\n const aheadBy = Number.parseInt(leftCountRaw ?? \"\", 10);\n const behindBy = Number.parseInt(rightCountRaw ?? \"\", 10);\n if (Number.isNaN(aheadBy) || Number.isNaN(behindBy)) {\n return null;\n }\n\n return {\n aheadBy,\n behindBy,\n };\n}\n","import { mkdir } from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\nimport { RepoPluginError } from \"./errors\";\nimport type { ParsedRepoUrl } from \"./types\";\n\nconst DEFAULT_CLONE_ROOT = \"~/.opencode/repos\";\n\nfunction expandHome(input: string): string {\n if (input === \"~\") {\n return os.homedir();\n }\n\n if (input.startsWith(\"~/\")) {\n return path.join(os.homedir(), input.slice(2));\n }\n\n return input;\n}\n\nfunction sanitizeSegment(segment: string): string {\n const sanitized = segment\n .replace(/[^A-Za-z0-9._-]/g, \"-\")\n .replace(/-+/g, \"-\")\n .replace(/^-|-$/g, \"\");\n if (!sanitized) {\n throw new RepoPluginError(\n \"INVALID_URL\",\n `Could not derive a safe path segment from: ${segment}`\n );\n }\n return sanitized;\n}\n\nfunction ensureWithinRoot(root: string, target: string): void {\n const resolvedRoot = path.resolve(root);\n const resolvedTarget = path.resolve(target);\n const prefix = resolvedRoot.endsWith(path.sep)\n ? resolvedRoot\n : `${resolvedRoot}${path.sep}`;\n\n if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(prefix)) {\n throw new RepoPluginError(\n \"PATH_VIOLATION\",\n \"Resolved repository path escaped clone root\"\n );\n }\n}\n\nexport async function resolveCloneRoot(override?: string): Promise<string> {\n const fromEnv = process.env.OPENCODE_REPO_CLONE_ROOT?.trim();\n const selected = override?.trim() || fromEnv || DEFAULT_CLONE_ROOT;\n const expanded = expandHome(selected);\n\n if (override?.trim() && !path.isAbsolute(expanded)) {\n throw new RepoPluginError(\n \"INVALID_CLONE_ROOT\",\n \"clone_root must be an absolute path\"\n );\n }\n\n const root = path.resolve(expanded);\n await mkdir(root, { recursive: true });\n return root;\n}\n\nexport function buildRepoPath(cloneRoot: string, repo: ParsedRepoUrl): string {\n const safeSegments = repo.pathSegments.map(sanitizeSegment);\n const target = path.resolve(cloneRoot, repo.host, ...safeSegments);\n ensureWithinRoot(cloneRoot, target);\n return target;\n}\n","import { appendFile, mkdir } from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\nimport { toRepoPluginError } from \"./errors\";\nimport type { RepoEnsureLocalArgs, RepoEnsureResult } from \"./types\";\n\ninterface RepoEnsureTelemetryEvent {\n event: \"repo_ensure_local\";\n timestamp: string;\n ok: boolean;\n repo_input: string;\n canonical_repo_url: string | null;\n local_path: string | null;\n status: string | null;\n freshness: string | null;\n ahead_by: number | null;\n behind_by: number | null;\n update_mode: string | null;\n ref: string | null;\n error_code: string | null;\n error_message: string | null;\n}\n\nconst TELEMETRY_RELATIVE_PATH =\n \".local/share/opencode/plugins/opencode-repo-local/telemetry.jsonl\";\n\nfunction defaultTelemetryPath(): string {\n return path.join(os.homedir(), TELEMETRY_RELATIVE_PATH);\n}\n\nfunction resolveTelemetryPath(): string {\n const fromEnv = process.env.OPENCODE_REPO_TELEMETRY_PATH?.trim();\n if (fromEnv) {\n return path.resolve(fromEnv);\n }\n\n return defaultTelemetryPath();\n}\n\nasync function appendEvent(event: RepoEnsureTelemetryEvent): Promise<void> {\n const telemetryPath = resolveTelemetryPath();\n await mkdir(path.dirname(telemetryPath), { recursive: true });\n await appendFile(telemetryPath, `${JSON.stringify(event)}\\n`, \"utf8\");\n}\n\nexport async function logRepoEnsureSuccess(\n args: RepoEnsureLocalArgs,\n result: RepoEnsureResult\n): Promise<void> {\n const event: RepoEnsureTelemetryEvent = {\n event: \"repo_ensure_local\",\n timestamp: new Date().toISOString(),\n ok: true,\n repo_input: args.repo,\n canonical_repo_url: result.repo_url,\n local_path: result.local_path,\n status: result.status,\n freshness: result.freshness,\n ahead_by: result.ahead_by,\n behind_by: result.behind_by,\n update_mode: args.update_mode ?? \"ff-only\",\n ref: args.ref ?? null,\n error_code: null,\n error_message: null,\n };\n\n try {\n await appendEvent(event);\n } catch {\n // Telemetry must never block tool execution.\n }\n}\n\nexport async function logRepoEnsureFailure(\n args: RepoEnsureLocalArgs,\n error: unknown\n): Promise<void> {\n const parsedError = toRepoPluginError(error);\n const event: RepoEnsureTelemetryEvent = {\n event: \"repo_ensure_local\",\n timestamp: new Date().toISOString(),\n ok: false,\n repo_input: args.repo,\n canonical_repo_url: null,\n local_path: null,\n status: null,\n freshness: null,\n ahead_by: null,\n behind_by: null,\n update_mode: args.update_mode ?? \"ff-only\",\n ref: args.ref ?? null,\n error_code: parsedError.code,\n error_message: parsedError.message,\n };\n\n try {\n await appendEvent(event);\n } catch {\n // Telemetry must never block tool execution.\n }\n}\n","import { RepoPluginError } from \"./errors\";\nimport type { ParsedRepoUrl } from \"./types\";\n\nconst SSH_PATTERN = /^git@([^:/]+):(.+)$/;\nconst GIT_SUFFIX_PATTERN = /\\.git$/i;\nconst HTTP_OR_HTTPS_PATTERN = /^https?:\\/\\//i;\nconst GITHUB_WEB_MARKERS: ReadonlySet<string> = new Set([\n \"tree\",\n \"blob\",\n \"commit\",\n \"pull\",\n \"issues\",\n \"actions\",\n \"releases\",\n \"wiki\",\n]);\n\nfunction normalizePathSegments(host: string, segments: string[]): string[] {\n if (host.toLowerCase() !== \"github.com\") {\n return segments;\n }\n\n if (segments.length >= 3 && GITHUB_WEB_MARKERS.has(segments[2])) {\n return segments.slice(0, 2);\n }\n\n return segments;\n}\n\nfunction splitPathSegments(input: string): string[] {\n const trimmed = input.trim().replace(/^\\/+|\\/+$/g, \"\");\n if (!trimmed) {\n return [];\n }\n\n const segments = trimmed.split(\"/\").filter(Boolean);\n if (segments.length === 0) {\n return [];\n }\n\n const lastIndex = segments.length - 1;\n segments[lastIndex] = segments[lastIndex].replace(GIT_SUFFIX_PATTERN, \"\");\n return segments;\n}\n\nfunction validateSegments(segments: string[]): void {\n if (segments.length < 2) {\n throw new RepoPluginError(\n \"INVALID_URL\",\n \"Repository URL must include owner and repository name\"\n );\n }\n\n for (const segment of segments) {\n if (!segment || segment === \".\" || segment === \"..\") {\n throw new RepoPluginError(\n \"INVALID_URL\",\n \"Repository URL contains an invalid path segment\"\n );\n }\n }\n}\n\nfunction makeRepoKey(host: string, segments: string[]): string {\n return `${host.toLowerCase()}/${segments.join(\"/\").toLowerCase()}`;\n}\n\nfunction buildParsed(\n raw: string,\n host: string,\n segmentsInput: string[] | string,\n protocol: \"https\" | \"ssh\"\n): ParsedRepoUrl {\n const splitSegments = Array.isArray(segmentsInput)\n ? segmentsInput\n : splitPathSegments(segmentsInput);\n const segments = normalizePathSegments(host, splitSegments);\n validateSegments(segments);\n\n const normalizedHost = host.toLowerCase();\n const canonicalPath = segments.join(\"/\");\n const canonicalUrl =\n protocol === \"https\"\n ? `https://${normalizedHost}/${canonicalPath}.git`\n : `git@${normalizedHost}:${canonicalPath}.git`;\n\n return {\n raw,\n host: normalizedHost,\n pathSegments: segments,\n canonicalUrl,\n key: makeRepoKey(normalizedHost, segments),\n };\n}\n\nfunction parseHttpLikeUrl(raw: string): ParsedRepoUrl {\n const url = new URL(raw);\n if (url.protocol !== \"https:\") {\n throw new RepoPluginError(\n \"INVALID_URL\",\n \"Repository URL must use https:// format\"\n );\n }\n\n return buildParsed(\n raw,\n url.hostname,\n splitPathSegments(url.pathname),\n \"https\"\n );\n}\n\nfunction parseHostWithPath(raw: string): ParsedRepoUrl | null {\n if (raw.includes(\"://\") || raw.startsWith(\"git@\")) {\n return null;\n }\n\n const firstSlash = raw.indexOf(\"/\");\n if (firstSlash <= 0) {\n return null;\n }\n\n const host = raw.slice(0, firstSlash).trim();\n const pathValue = raw.slice(firstSlash + 1).trim();\n if (!(host && pathValue)) {\n return null;\n }\n\n if (!(host.includes(\".\") || host.includes(\":\"))) {\n return null;\n }\n\n return buildParsed(raw, host, splitPathSegments(pathValue), \"https\");\n}\n\nfunction parseGitHubShorthand(raw: string): ParsedRepoUrl | null {\n if (raw.includes(\"://\") || raw.startsWith(\"git@\")) {\n return null;\n }\n\n const segments = splitPathSegments(raw);\n if (segments.length < 2) {\n return null;\n }\n\n return buildParsed(raw, \"github.com\", segments, \"https\");\n}\n\nfunction invalidUrlErrorMessage(allowSsh: boolean): string {\n if (allowSsh) {\n return \"Repository must be one of: https://host/owner/repo(.git), git@host:owner/repo.git, host/owner/repo, or owner/repo (GitHub shorthand)\";\n }\n\n return \"Repository must be one of: https://host/owner/repo(.git), host/owner/repo, or owner/repo (GitHub shorthand)\";\n}\n\nexport function parseRepoUrl(repo: string, allowSsh: boolean): ParsedRepoUrl {\n const raw = repo.trim();\n if (!raw) {\n throw new RepoPluginError(\"INVALID_URL\", \"Repository URL is required\");\n }\n\n if (HTTP_OR_HTTPS_PATTERN.test(raw)) {\n return parseHttpLikeUrl(raw);\n }\n\n const hostWithPathParsed = parseHostWithPath(raw);\n if (hostWithPathParsed) {\n return hostWithPathParsed;\n }\n\n const gitHubShorthandParsed = parseGitHubShorthand(raw);\n if (gitHubShorthandParsed) {\n return gitHubShorthandParsed;\n }\n\n if (allowSsh) {\n const match = raw.match(SSH_PATTERN);\n if (match) {\n const host = match[1];\n const path = match[2] ?? \"\";\n return buildParsed(raw, host, splitPathSegments(path), \"ssh\");\n }\n\n if (raw.startsWith(\"ssh://\")) {\n const url = new URL(raw);\n if (url.protocol === \"ssh:\") {\n return buildParsed(\n raw,\n url.hostname,\n splitPathSegments(url.pathname),\n \"ssh\"\n );\n }\n }\n }\n\n throw new RepoPluginError(\"INVALID_URL\", invalidUrlErrorMessage(allowSsh));\n}\n","import { tool } from \"@opencode-ai/plugin\";\n\nimport { RepoPluginError, toRepoPluginError } from \"../lib/errors\";\nimport {\n type AheadBehindCounts,\n checkoutRef,\n cloneRepo,\n directoryExists,\n ensureGitAvailable,\n fetchOrigin,\n getAheadBehindCounts,\n getCurrentRef,\n getDefaultBranch,\n getHeadSha,\n getOriginUrl,\n getRefSha,\n getUpstreamRef,\n hardResetToOriginBranch,\n isGitRepository,\n isWorktreeDirty,\n pullFfOnlyForBranch,\n} from \"../lib/git\";\nimport { buildRepoPath, resolveCloneRoot } from \"../lib/paths\";\nimport { logRepoEnsureFailure, logRepoEnsureSuccess } from \"../lib/telemetry\";\nimport type {\n RepoEnsureLocalArgs,\n RepoEnsureResult,\n RepoEnsureStatus,\n RepoFreshnessStatus,\n UpdateMode,\n} from \"../lib/types\";\nimport { parseRepoUrl } from \"../lib/url\";\n\nconst UPDATE_MODES: ReadonlySet<string> = new Set([\n \"ff-only\",\n \"fetch-only\",\n \"reset-clean\",\n]);\n\nconst REPO_TOOL_ARGS = {\n repo: tool.schema\n .string()\n .describe(\n \"Remote repository reference to prepare locally. Use this FIRST when a user references a GitHub/remote repo outside the current workspace and the agent needs grounded code inspection. Accepted forms include owner/repo, host/owner/repo, and https URLs.\"\n ),\n ref: tool.schema\n .string()\n .optional()\n .describe(\"Optional branch/tag/sha to checkout after clone/fetch.\"),\n clone_root: tool.schema\n .string()\n .optional()\n .describe(\n \"Optional absolute clone root path override. Useful in tests and CI for isolated temporary clone directories.\"\n ),\n depth: tool.schema\n .number()\n .int()\n .positive()\n .optional()\n .describe(\"Optional shallow clone depth.\"),\n update_mode: tool.schema\n .string()\n .optional()\n .describe(\"Update policy: ff-only (default), fetch-only, or reset-clean.\"),\n allow_ssh: tool.schema\n .boolean()\n .optional()\n .describe(\n \"Allow git@host:owner/repo.git URLs. Defaults to false unless OPENCODE_REPO_ALLOW_SSH=true.\"\n ),\n} as const;\n\nconst ALLOWED_KEYS = new Set(Object.keys(REPO_TOOL_ARGS));\n\ninterface RepoFreshnessDetails {\n comparisonRef: string | null;\n remoteHeadSha: string | null;\n aheadBy: number | null;\n behindBy: number | null;\n freshness: RepoFreshnessStatus;\n}\n\nfunction normalizeUpdateMode(value: string | undefined): UpdateMode {\n const mode = (value ?? \"ff-only\").trim();\n if (!UPDATE_MODES.has(mode)) {\n throw new RepoPluginError(\n \"INVALID_UPDATE_MODE\",\n `Unsupported update_mode: ${mode}`\n );\n }\n return mode as UpdateMode;\n}\n\nfunction formatFailure(error: unknown): never {\n const parsed = toRepoPluginError(error);\n const detailSuffix = parsed.details ? `\\n${parsed.details}` : \"\";\n throw new Error(`[${parsed.code}] ${parsed.message}${detailSuffix}`);\n}\n\nfunction toResultText(result: RepoEnsureResult): string {\n return JSON.stringify(result, null, 2);\n}\n\nfunction assertKnownArgs(args: RepoEnsureLocalArgs): void {\n const extraKeys = Object.keys(args ?? {}).filter(\n (key) => !ALLOWED_KEYS.has(key)\n );\n if (extraKeys.length > 0) {\n throw new RepoPluginError(\n \"INVALID_ARGS\",\n `Unknown arguments: ${extraKeys.join(\", \")}`\n );\n }\n}\n\nfunction deriveFreshnessFromCounts(\n counts: AheadBehindCounts | null\n): RepoFreshnessStatus {\n if (!counts) {\n return \"unknown\";\n }\n\n if (counts.aheadBy === 0 && counts.behindBy === 0) {\n return \"current\";\n }\n\n if (counts.aheadBy > 0 && counts.behindBy === 0) {\n return \"ahead\";\n }\n\n if (counts.aheadBy === 0 && counts.behindBy > 0) {\n return \"stale\";\n }\n\n return \"diverged\";\n}\n\nasync function resolveComparisonRef(\n localPath: string,\n currentRef: string\n): Promise<string | null> {\n const upstreamRef = await getUpstreamRef(localPath);\n if (upstreamRef) {\n return upstreamRef;\n }\n\n if (currentRef === \"HEAD\") {\n return null;\n }\n\n const fallback = `origin/${currentRef}`;\n const fallbackSha = await getRefSha(localPath, fallback);\n if (!fallbackSha) {\n return null;\n }\n\n return fallback;\n}\n\nasync function computeFreshnessDetails(\n localPath: string,\n currentRef: string\n): Promise<RepoFreshnessDetails> {\n const comparisonRef = await resolveComparisonRef(localPath, currentRef);\n if (!comparisonRef) {\n return {\n comparisonRef: null,\n remoteHeadSha: null,\n aheadBy: null,\n behindBy: null,\n freshness: \"unknown\",\n };\n }\n\n const remoteHeadSha = await getRefSha(localPath, comparisonRef);\n const counts = await getAheadBehindCounts(localPath, \"HEAD\", comparisonRef);\n return {\n comparisonRef,\n remoteHeadSha,\n aheadBy: counts?.aheadBy ?? null,\n behindBy: counts?.behindBy ?? null,\n freshness: deriveFreshnessFromCounts(counts),\n };\n}\n\nasync function checkoutIfRequested(\n localPath: string,\n ref: string | undefined,\n actions: string[]\n): Promise<void> {\n if (!ref) {\n return;\n }\n\n await checkoutRef(localPath, ref);\n actions.push(`checked_out_${ref}`);\n}\n\nasync function runFastForward(\n localPath: string,\n actions: string[]\n): Promise<void> {\n if (await isWorktreeDirty(localPath)) {\n throw new RepoPluginError(\n \"DIRTY_WORKTREE\",\n \"Cannot fast-forward because working tree has local changes\",\n \"Commit/stash changes or use update_mode=fetch-only\"\n );\n }\n\n const currentRef = await getCurrentRef(localPath);\n if (currentRef === \"HEAD\") {\n actions.push(\"detached_head_no_pull\");\n return;\n }\n\n await pullFfOnlyForBranch(localPath, currentRef);\n actions.push(`fast_forwarded_${currentRef}`);\n}\n\nasync function runResetClean(\n localPath: string,\n actions: string[]\n): Promise<void> {\n const currentRef = await getCurrentRef(localPath);\n if (currentRef === \"HEAD\") {\n throw new RepoPluginError(\n \"DETACHED_HEAD\",\n \"Cannot use reset-clean while repository is in detached HEAD state\"\n );\n }\n\n await hardResetToOriginBranch(localPath, currentRef);\n actions.push(`reset_clean_${currentRef}`);\n}\n\nasync function ensureExistingCloneMatchesRemote(\n localPath: string,\n requestedRepo: ReturnType<typeof parseRepoUrl>\n): Promise<void> {\n if (!(await isGitRepository(localPath))) {\n throw new RepoPluginError(\n \"NOT_GIT_REPO\",\n `Target path exists but is not a git repository: ${localPath}`\n );\n }\n\n const originUrl = await getOriginUrl(localPath);\n const existingOrigin = parseRepoUrl(originUrl, true);\n if (existingOrigin.key === requestedRepo.key) {\n return;\n }\n\n throw new RepoPluginError(\n \"REPO_URL_MISMATCH\",\n \"Existing clone origin does not match requested repository\",\n `requested=${requestedRepo.canonicalUrl}\\nexisting=${existingOrigin.canonicalUrl}`\n );\n}\n\nasync function cloneMissingRepo(\n localPath: string,\n repoUrl: string,\n depth: number | undefined,\n ref: string | undefined,\n actions: string[]\n): Promise<RepoEnsureStatus> {\n await cloneRepo(repoUrl, localPath, depth);\n actions.push(\"cloned_repository\");\n await checkoutIfRequested(localPath, ref, actions);\n return \"cloned\";\n}\n\nasync function updateExistingRepo(\n localPath: string,\n requestedRepo: ReturnType<typeof parseRepoUrl>,\n mode: UpdateMode,\n ref: string | undefined,\n actions: string[]\n): Promise<RepoEnsureStatus> {\n await ensureExistingCloneMatchesRemote(localPath, requestedRepo);\n\n const beforeSha = await getHeadSha(localPath);\n await fetchOrigin(localPath);\n actions.push(\"fetched_origin\");\n\n await checkoutIfRequested(localPath, ref, actions);\n\n if (mode === \"ff-only\") {\n await runFastForward(localPath, actions);\n }\n\n if (mode === \"reset-clean\") {\n await runResetClean(localPath, actions);\n }\n\n if (mode === \"fetch-only\") {\n return \"fetched\";\n }\n\n const afterSha = await getHeadSha(localPath);\n return beforeSha === afterSha ? \"already-current\" : \"updated\";\n}\n\nexport async function repoEnsureLocal(\n args: RepoEnsureLocalArgs\n): Promise<RepoEnsureResult> {\n assertKnownArgs(args);\n\n const repoInput = args.repo?.trim();\n if (!repoInput) {\n throw new RepoPluginError(\"INVALID_URL\", \"repo argument cannot be empty\");\n }\n\n const ref = args.ref?.trim() || undefined;\n const mode = normalizeUpdateMode(args.update_mode);\n const allowSsh =\n args.allow_ssh ?? process.env.OPENCODE_REPO_ALLOW_SSH === \"true\";\n const parsedRepo = parseRepoUrl(repoInput, allowSsh);\n\n await ensureGitAvailable();\n\n const cloneRoot = await resolveCloneRoot(args.clone_root);\n const localPath = buildRepoPath(cloneRoot, parsedRepo);\n const actions: string[] = [];\n\n const status = (await directoryExists(localPath))\n ? await updateExistingRepo(localPath, parsedRepo, mode, ref, actions)\n : await cloneMissingRepo(\n localPath,\n parsedRepo.canonicalUrl,\n args.depth,\n ref,\n actions\n );\n\n const currentRef = await getCurrentRef(localPath);\n const freshness = await computeFreshnessDetails(localPath, currentRef);\n\n return {\n status,\n repo_url: parsedRepo.canonicalUrl,\n local_path: localPath,\n current_ref: currentRef,\n default_branch: await getDefaultBranch(localPath),\n head_sha: await getHeadSha(localPath),\n comparison_ref: freshness.comparisonRef,\n remote_head_sha: freshness.remoteHeadSha,\n ahead_by: freshness.aheadBy,\n behind_by: freshness.behindBy,\n freshness: freshness.freshness,\n actions,\n instructions: [\n `Use built-in tools with local_path: ${localPath}`,\n `Example: run Grep/Read/Glob with files under ${localPath}`,\n ],\n };\n}\n\nexport const repoEnsureLocalTool = tool({\n description:\n \"Prepare external repositories for investigation. If a request references a GitHub/remote repo not already in the workspace, call this tool before Read/Grep/Glob/Bash so analysis is grounded in local source code. Returns absolute local_path plus freshness/version metadata (head SHA, remote SHA, ahead/behind).\",\n args: REPO_TOOL_ARGS,\n async execute(args) {\n const typedArgs = args as RepoEnsureLocalArgs;\n try {\n const result = await repoEnsureLocal(typedArgs);\n await logRepoEnsureSuccess(typedArgs, result);\n return toResultText(result);\n } catch (error) {\n await logRepoEnsureFailure(typedArgs, error);\n formatFailure(error);\n }\n },\n});\n","import type { Plugin } from \"@opencode-ai/plugin\";\n\nimport { repoEnsureLocalTool } from \"./tools/repo-ensure-local\";\n\nexport const RepoLocalPlugin: Plugin = () => {\n return Promise.resolve({\n tool: {\n repo_ensure_local: repoEnsureLocalTool,\n },\n });\n};\n\nexport default RepoLocalPlugin;\n"],"mappings":"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"}
package/index.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+
3
+ export declare const RepoLocalPlugin: Plugin;
4
+
5
+ export default RepoLocalPlugin;
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "opencode-repo-local",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin to clone or update repositories locally for built-in tooling",
5
+ "packageManager": "bun@1.2.22",
6
+ "type": "module",
7
+ "main": "./dist/index.mjs",
8
+ "module": "./dist/index.mjs",
9
+ "types": "./index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./index.d.ts",
13
+ "default": "./dist/index.mjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "index.d.ts",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "dev": "bunx tsdown --watch",
24
+ "build": "bunx tsdown --fail-on-warn",
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "bun test",
27
+ "test:integration": "bun run scripts/integration-test.ts",
28
+ "test:e2e": "bun run scripts/e2e-opencode-run.ts",
29
+ "lint": "ultracite check --error-on-warnings",
30
+ "check": "concurrently --kill-others-on-fail --success all --names core,integration \"bun run scripts/check-no-ignores.ts && bun run lint && bun run typecheck && bun run test && bun run build\" \"bun run test:integration\"",
31
+ "release:verify": "bun run check && npm pack --dry-run",
32
+ "release:patch": "npm version patch -m \"chore(release): v%s\"",
33
+ "release:minor": "npm version minor -m \"chore(release): v%s\"",
34
+ "release:major": "npm version major -m \"chore(release): v%s\"",
35
+ "release:beta:first": "npm version prepatch --preid beta -m \"chore(release): v%s\"",
36
+ "release:beta:next": "npm version prerelease --preid beta -m \"chore(release): v%s\"",
37
+ "prepare": "husky",
38
+ "prepack": "bun run build",
39
+ "fix": "ultracite fix --unsafe && bun run scripts/check-no-ignores.ts"
40
+ },
41
+ "keywords": [
42
+ "opencode",
43
+ "plugin",
44
+ "git",
45
+ "clone"
46
+ ],
47
+ "license": "MIT",
48
+ "peerDependencies": {
49
+ "typescript": "^5.0.0"
50
+ },
51
+ "dependencies": {
52
+ "@opencode-ai/plugin": "^1.0.150"
53
+ },
54
+ "devDependencies": {
55
+ "@biomejs/biome": "2.3.13",
56
+ "@types/bun": "latest",
57
+ "concurrently": "^9.2.1",
58
+ "husky": "^9.1.7",
59
+ "tsdown": "^0.20.3",
60
+ "typescript": "^5.9.3",
61
+ "ultracite": "7.1.4"
62
+ }
63
+ }