opencode-readseek 0.6.3 → 0.6.5
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/README.md +11 -8
- package/index.ts +355 -55
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# opencode-readseek
|
|
2
2
|
|
|
3
|
-
`opencode-readseek` exposes ReadSeek's hash-anchored
|
|
4
|
-
navigation as OpenCode tools.
|
|
5
|
-
built-in `read`, `edit`, or `write` tools.
|
|
3
|
+
`opencode-readseek` exposes ReadSeek's hash-anchored text tools and structural
|
|
4
|
+
code navigation as OpenCode tools.
|
|
6
5
|
|
|
7
6
|
## Installation
|
|
8
7
|
|
|
@@ -20,16 +19,20 @@ binary dependency with Bun at startup.
|
|
|
20
19
|
## Tools
|
|
21
20
|
|
|
22
21
|
- `readseek_read`: read text with `LINE:HASH` anchors; image/PDF handling is explicit.
|
|
22
|
+
- `readseek_edit`: apply line, range, and insertion edits with fresh `LINE:HASH` anchors.
|
|
23
|
+
- `readseek_write`: create or replace a complete text file.
|
|
24
|
+
- `readseek_grep`: plain-text or regular-expression search with anchored results.
|
|
23
25
|
- `readseek_map`: generate a structural symbol map.
|
|
24
26
|
- `readseek_search`: AST-pattern search.
|
|
25
27
|
- `readseek_def`, `readseek_refs`, `readseek_hover`: symbol navigation.
|
|
26
|
-
- `readseek_rename`:
|
|
28
|
+
- `readseek_rename`: atomically apply a verified rename by default; `apply: false` returns a dry-run plan.
|
|
27
29
|
- `readseek_check`: parse diagnostics.
|
|
28
30
|
|
|
29
|
-
The plugin
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
the OpenCode compaction context.
|
|
31
|
+
The plugin asks for OpenCode read, grep, external-directory, and edit permissions
|
|
32
|
+
as appropriate. It discards remembered anchors after file changes, records only
|
|
33
|
+
results that contain actual hashlines, and adds current anchors plus pending
|
|
34
|
+
dry-run rename plans to the OpenCode compaction context. Text reads return at
|
|
35
|
+
most 2000 lines by default.
|
|
33
36
|
|
|
34
37
|
`imageMode` defaults to `"auto"`: it exposes `none`, `ocr`, `caption`, and
|
|
35
38
|
`objects`. `"on"` omits `none`; `"off"` skips image/PDF files. Omitting
|
package/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
|
-
import { stat } from "node:fs/promises";
|
|
4
|
+
import { lstat, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
|
|
@@ -9,6 +9,7 @@ import { tool, type Plugin, type PluginOptions, type ToolContext } from "@openco
|
|
|
9
9
|
|
|
10
10
|
const require = createRequire(import.meta.url);
|
|
11
11
|
const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
12
|
+
const DEFAULT_READ_LIMIT = 2000;
|
|
12
13
|
const READSEEK_PLATFORM_PACKAGES: Record<string, string> = {
|
|
13
14
|
"darwin-arm64": "@jarkkojs/readseek-darwin-arm64",
|
|
14
15
|
"linux-arm64": "@jarkkojs/readseek-linux-arm64",
|
|
@@ -21,7 +22,7 @@ type RenamePlan = {
|
|
|
21
22
|
files: Set<string>;
|
|
22
23
|
};
|
|
23
24
|
|
|
24
|
-
type PresentationKind = "read" | "map" | "search" | "def" | "refs" | "hover" | "rename" | "check";
|
|
25
|
+
type PresentationKind = "read" | "map" | "search" | "grep" | "def" | "refs" | "hover" | "rename" | "edit" | "write" | "check";
|
|
25
26
|
|
|
26
27
|
type Presentation = {
|
|
27
28
|
title: string;
|
|
@@ -73,6 +74,10 @@ class SessionAnchors {
|
|
|
73
74
|
planRename(sessionID: string, output: unknown): void {
|
|
74
75
|
if (!output || typeof output !== "object") return;
|
|
75
76
|
const record = output as Record<string, unknown>;
|
|
77
|
+
if (record.applied === true) {
|
|
78
|
+
this.#renamePlans.delete(sessionID);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
76
81
|
const { file, old_name: oldName, new_name: newName, others } = record;
|
|
77
82
|
if (typeof file !== "string" || typeof oldName !== "string" || typeof newName !== "string") return;
|
|
78
83
|
|
|
@@ -111,13 +116,34 @@ function containsPath(directory: string, filePath: string): boolean {
|
|
|
111
116
|
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
112
117
|
}
|
|
113
118
|
|
|
119
|
+
async function canonicalPath(filePath: string): Promise<string> {
|
|
120
|
+
const missing: string[] = [];
|
|
121
|
+
let existing = filePath;
|
|
122
|
+
while (true) {
|
|
123
|
+
try {
|
|
124
|
+
return path.join(await realpath(existing), ...missing.reverse());
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
127
|
+
const parent = path.dirname(existing);
|
|
128
|
+
if (parent === existing) throw error;
|
|
129
|
+
missing.push(path.basename(existing));
|
|
130
|
+
existing = parent;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
114
135
|
async function authorizeExternal(context: ToolContext, filePath: string): Promise<void> {
|
|
115
|
-
|
|
136
|
+
const [accessPath, directory, worktree] = await Promise.all([
|
|
137
|
+
canonicalPath(filePath),
|
|
138
|
+
canonicalPath(context.directory),
|
|
139
|
+
context.worktree === "/" ? Promise.resolve("/") : canonicalPath(context.worktree),
|
|
140
|
+
]);
|
|
141
|
+
if (containsPath(directory, accessPath) || (worktree !== "/" && containsPath(worktree, accessPath))) {
|
|
116
142
|
return;
|
|
117
143
|
}
|
|
118
144
|
|
|
119
|
-
const info = await stat(
|
|
120
|
-
const parentDir = info?.isDirectory() ?
|
|
145
|
+
const info = await stat(accessPath).catch(() => undefined);
|
|
146
|
+
const parentDir = info?.isDirectory() ? accessPath : path.dirname(accessPath);
|
|
121
147
|
const pattern = path.join(parentDir, "*").replaceAll("\\", "/");
|
|
122
148
|
await context.ask({
|
|
123
149
|
permission: "external_directory",
|
|
@@ -127,6 +153,14 @@ async function authorizeExternal(context: ToolContext, filePath: string): Promis
|
|
|
127
153
|
});
|
|
128
154
|
}
|
|
129
155
|
|
|
156
|
+
async function rejectSymlinkMutation(filePath: string): Promise<void> {
|
|
157
|
+
const info = await lstat(filePath).catch((error: NodeJS.ErrnoException) => {
|
|
158
|
+
if (error.code === "ENOENT") return undefined;
|
|
159
|
+
throw error;
|
|
160
|
+
});
|
|
161
|
+
if (info?.isSymbolicLink()) throw new Error(`refusing to modify symbolic link: ${filePath}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
130
164
|
async function authorizeRead(context: ToolContext, filePath: string): Promise<void> {
|
|
131
165
|
await authorizeExternal(context, filePath);
|
|
132
166
|
await context.ask({
|
|
@@ -147,6 +181,24 @@ async function authorizeSearch(context: ToolContext, filePath: string, pattern:
|
|
|
147
181
|
await authorizeExternal(context, filePath);
|
|
148
182
|
}
|
|
149
183
|
|
|
184
|
+
async function authorizeEdit(
|
|
185
|
+
context: ToolContext,
|
|
186
|
+
filePaths: string[],
|
|
187
|
+
diff = "",
|
|
188
|
+
authorizeExternalPaths = true,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
if (authorizeExternalPaths) {
|
|
191
|
+
for (const filePath of filePaths) await authorizeExternal(context, filePath);
|
|
192
|
+
}
|
|
193
|
+
const patterns = filePaths.map((filePath) => path.relative(context.worktree, filePath).replaceAll("\\", "/"));
|
|
194
|
+
await context.ask({
|
|
195
|
+
permission: "edit",
|
|
196
|
+
patterns,
|
|
197
|
+
always: ["*"],
|
|
198
|
+
metadata: { filepath: filePaths.join(", "), diff },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
150
202
|
function optionalFlag(args: string[], enabled: boolean | undefined, flag: string): void {
|
|
151
203
|
if (enabled) args.push(flag);
|
|
152
204
|
}
|
|
@@ -192,17 +244,115 @@ function render(value: unknown): string {
|
|
|
192
244
|
return JSON.stringify(value, null, 2);
|
|
193
245
|
}
|
|
194
246
|
|
|
195
|
-
function
|
|
247
|
+
function collectAnchoredFiles(value: unknown, files: Set<string>): void {
|
|
196
248
|
if (Array.isArray(value)) {
|
|
197
|
-
for (const item of value)
|
|
249
|
+
for (const item of value) collectAnchoredFiles(item, files);
|
|
198
250
|
return;
|
|
199
251
|
}
|
|
200
252
|
if (!value || typeof value !== "object") return;
|
|
201
253
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
254
|
+
const output = value as Record<string, unknown>;
|
|
255
|
+
const hashlines = output.hashlines;
|
|
256
|
+
if (
|
|
257
|
+
typeof output.file === "string" &&
|
|
258
|
+
Array.isArray(hashlines) &&
|
|
259
|
+
hashlines.some((line) => typeof record(line).line === "number" && typeof record(line).hash === "string")
|
|
260
|
+
) files.add(output.file);
|
|
261
|
+
for (const item of Object.values(output)) collectAnchoredFiles(item, files);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
type AnchorEdit =
|
|
265
|
+
| { set_line: { anchor: string; new_text: string } }
|
|
266
|
+
| { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
|
|
267
|
+
| { insert_after: { anchor: string; new_text: string } };
|
|
268
|
+
|
|
269
|
+
function parseAnchor(anchor: string): { line: number; hash: string } {
|
|
270
|
+
const match = /^(\d+):([0-9a-fA-F]{3})$/.exec(anchor.trim());
|
|
271
|
+
if (!match) throw new Error(`invalid LINE:HASH anchor: ${anchor}`);
|
|
272
|
+
return { line: Number(match[1]), hash: match[2].toLowerCase() };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function anchorRefs(edit: AnchorEdit): { line: number; hash: string }[] {
|
|
276
|
+
if ("set_line" in edit) return [parseAnchor(edit.set_line.anchor)];
|
|
277
|
+
if ("insert_after" in edit) return [parseAnchor(edit.insert_after.anchor)];
|
|
278
|
+
return [parseAnchor(edit.replace_lines.start_anchor), parseAnchor(edit.replace_lines.end_anchor)];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function verifyAnchors(context: ToolContext, filePath: string, edits: AnchorEdit[]): Promise<void> {
|
|
282
|
+
const refs = new Map<number, string>();
|
|
283
|
+
for (const edit of edits) {
|
|
284
|
+
for (const ref of anchorRefs(edit)) {
|
|
285
|
+
const previous = refs.get(ref.line);
|
|
286
|
+
if (previous !== undefined && previous !== ref.hash) throw new Error(`conflicting hashes for line ${ref.line}`);
|
|
287
|
+
refs.set(ref.line, ref.hash);
|
|
288
|
+
}
|
|
205
289
|
}
|
|
290
|
+
for (const [line, expected] of refs) {
|
|
291
|
+
const output = record(await runReadSeek(context, ["read", `${filePath}:${line}`, "--end", String(line)]));
|
|
292
|
+
const hashline = items(output.hashlines).map(record)[0];
|
|
293
|
+
const actual = hashline?.hash;
|
|
294
|
+
if (typeof actual !== "string" || actual !== expected) {
|
|
295
|
+
throw new Error(`stale anchor ${line}:${expected}; current hash is ${typeof actual === "string" ? actual : "unavailable"}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function applyAnchorEdits(content: string, edits: AnchorEdit[]): string {
|
|
301
|
+
const newline = content.includes("\r\n") ? "\r\n" : "\n";
|
|
302
|
+
const trailingNewline = content.endsWith("\n");
|
|
303
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
304
|
+
if (trailingNewline) lines.pop();
|
|
305
|
+
const planned = edits.map((edit) => {
|
|
306
|
+
if ("set_line" in edit) {
|
|
307
|
+
const { line } = parseAnchor(edit.set_line.anchor);
|
|
308
|
+
return { start: line - 1, deleteCount: 1, text: edit.set_line.new_text };
|
|
309
|
+
}
|
|
310
|
+
if ("insert_after" in edit) {
|
|
311
|
+
const { line } = parseAnchor(edit.insert_after.anchor);
|
|
312
|
+
return { start: line, deleteCount: 0, text: edit.insert_after.new_text };
|
|
313
|
+
}
|
|
314
|
+
const start = parseAnchor(edit.replace_lines.start_anchor).line;
|
|
315
|
+
const end = parseAnchor(edit.replace_lines.end_anchor).line;
|
|
316
|
+
if (end < start) throw new Error("replace_lines end anchor precedes start anchor");
|
|
317
|
+
return { start: start - 1, deleteCount: end - start + 1, text: edit.replace_lines.new_text };
|
|
318
|
+
});
|
|
319
|
+
const ascending = [...planned].sort((left, right) => left.start - right.start || left.deleteCount - right.deleteCount);
|
|
320
|
+
for (let index = 1; index < ascending.length; index++) {
|
|
321
|
+
const previous = ascending[index - 1];
|
|
322
|
+
const current = ascending[index];
|
|
323
|
+
const previousEnd = previous.start + Math.max(previous.deleteCount, 1);
|
|
324
|
+
if (current.start < previousEnd) throw new Error("anchored edits overlap or target the same location");
|
|
325
|
+
}
|
|
326
|
+
planned.sort((left, right) => right.start - left.start);
|
|
327
|
+
for (const edit of planned) {
|
|
328
|
+
if (edit.start < 0 || edit.start > lines.length || edit.start + edit.deleteCount > lines.length) {
|
|
329
|
+
throw new Error("anchor line is outside the file");
|
|
330
|
+
}
|
|
331
|
+
const replacement = edit.text === "" ? [] : edit.text.replace(/\r\n/g, "\n").split("\n");
|
|
332
|
+
lines.splice(edit.start, edit.deleteCount, ...replacement);
|
|
333
|
+
}
|
|
334
|
+
return lines.join(newline) + (trailingNewline ? newline : "");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function writeText(filePath: string, content: string): Promise<void> {
|
|
338
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
339
|
+
await writeFile(filePath, content, "utf8");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function renameFiles(value: unknown): string[] {
|
|
343
|
+
const output = record(value);
|
|
344
|
+
const files: string[] = [];
|
|
345
|
+
if (typeof output.file === "string" && items(output.edits).length > 0) files.push(path.resolve(output.file));
|
|
346
|
+
for (const other of items(output.others)) {
|
|
347
|
+
const item = record(other);
|
|
348
|
+
if (typeof item.file === "string" && items(item.edits).length > 0) files.push(path.resolve(item.file));
|
|
349
|
+
}
|
|
350
|
+
return files;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function simpleDiff(filePath: string, before: string, after: string): string {
|
|
354
|
+
if (before === after) return "";
|
|
355
|
+
return `--- ${filePath}\n+++ ${filePath}\n@@ full file @@\n-${before}\n+${after}`;
|
|
206
356
|
}
|
|
207
357
|
|
|
208
358
|
function identifiedName(value: unknown): string | undefined {
|
|
@@ -229,6 +379,8 @@ function initialTitle(kind: PresentationKind, args: any): string {
|
|
|
229
379
|
return `Map ${args.path}`;
|
|
230
380
|
case "search":
|
|
231
381
|
return `Search ${args.pattern}`;
|
|
382
|
+
case "grep":
|
|
383
|
+
return `Grep ${args.pattern}`;
|
|
232
384
|
case "def":
|
|
233
385
|
return `Find definition ${args.name}`;
|
|
234
386
|
case "refs":
|
|
@@ -237,6 +389,10 @@ function initialTitle(kind: PresentationKind, args: any): string {
|
|
|
237
389
|
return `Identify ${args.path}:${args.line}`;
|
|
238
390
|
case "rename":
|
|
239
391
|
return `Rename ${args.path}:${args.line} to ${args.to}`;
|
|
392
|
+
case "edit":
|
|
393
|
+
return `Edit ${args.path}`;
|
|
394
|
+
case "write":
|
|
395
|
+
return `Write ${args.path}`;
|
|
240
396
|
case "check":
|
|
241
397
|
return `Check ${args.path}`;
|
|
242
398
|
}
|
|
@@ -270,6 +426,11 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
|
|
|
270
426
|
const matches = results.reduce<number>((total, result) => total + items(record(result).matches).length, 0);
|
|
271
427
|
return { title: `Found ${matches} matches`, metadata: { results: results.length, matches } };
|
|
272
428
|
}
|
|
429
|
+
case "grep": {
|
|
430
|
+
const results = items(output.results);
|
|
431
|
+
const matches = results.reduce<number>((total, result) => total + items(record(result).matches).length, 0);
|
|
432
|
+
return { title: `Found ${matches} matches`, metadata: { results: results.length, matches } };
|
|
433
|
+
}
|
|
273
434
|
case "def": {
|
|
274
435
|
const locations = items(output.locations).length;
|
|
275
436
|
return { title: `Found ${locations} definitions`, metadata: { locations } };
|
|
@@ -299,10 +460,13 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
|
|
|
299
460
|
const others = otherOutputs.length;
|
|
300
461
|
const title =
|
|
301
462
|
typeof oldName === "string" && typeof newName === "string"
|
|
302
|
-
?
|
|
463
|
+
? `${output.applied === true ? "Renamed" : "Plan"} ${oldName} -> ${newName}`
|
|
303
464
|
: initialTitle(kind, args);
|
|
304
465
|
return { title, metadata: { edits, conflicts, others } };
|
|
305
466
|
}
|
|
467
|
+
case "edit":
|
|
468
|
+
case "write":
|
|
469
|
+
return { title: `${kind === "edit" ? "Edited" : "Wrote"} ${args.path}`, metadata: {} };
|
|
306
470
|
case "check": {
|
|
307
471
|
const errors = typeof output.error_count === "number" ? output.error_count : 0;
|
|
308
472
|
const missing = typeof output.missing_count === "number" ? output.missing_count : 0;
|
|
@@ -355,17 +519,18 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
355
519
|
tool: {
|
|
356
520
|
readseek_read: readseekTool(
|
|
357
521
|
imagePolicy === "off"
|
|
358
|
-
? "Read text with
|
|
359
|
-
: `Read text with
|
|
522
|
+
? "Read text with LINE:HASH anchors for durable references. Images and PDFs are skipped."
|
|
523
|
+
: `Read text with LINE:HASH anchors for durable references. For images or PDFs, select a mode (${imageModes.join(", ")}) or omit image to skip.`,
|
|
360
524
|
{
|
|
361
525
|
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
362
526
|
offset: tool.schema.number().int().positive().optional().describe("One-based starting line"),
|
|
363
|
-
limit: tool.schema.number().int().positive().optional().describe(
|
|
527
|
+
limit: tool.schema.number().int().positive().optional().describe(`Maximum number of lines; defaults to ${DEFAULT_READ_LIMIT}`),
|
|
528
|
+
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
364
529
|
...(imagePolicy === "off"
|
|
365
530
|
? {}
|
|
366
531
|
: {
|
|
367
532
|
image: tool.schema.enum(imageModes as [ImageMode, ...ImageMode[]]).optional()
|
|
368
|
-
.describe(
|
|
533
|
+
.describe("Image or PDF processing mode"),
|
|
369
534
|
}),
|
|
370
535
|
},
|
|
371
536
|
"read",
|
|
@@ -382,30 +547,100 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
382
547
|
}
|
|
383
548
|
}
|
|
384
549
|
const args = ["read", input.offset === undefined ? filePath : `${filePath}:${input.offset}`];
|
|
385
|
-
if (
|
|
550
|
+
if (image === undefined) {
|
|
551
|
+
args.push("--end", String((input.offset ?? 1) + ((input.limit as number | undefined) ?? DEFAULT_READ_LIMIT) - 1));
|
|
552
|
+
}
|
|
553
|
+
if (input.language) args.push("--language", input.language as string);
|
|
386
554
|
if (image !== undefined) args.push("--image", image);
|
|
387
555
|
return runReadSeek(context, args);
|
|
388
556
|
},
|
|
389
557
|
),
|
|
390
558
|
readseek_map: readseekTool(
|
|
391
|
-
"
|
|
392
|
-
{
|
|
559
|
+
"List symbols and ranges in a source file. Use to inspect structure without reading the full file.",
|
|
560
|
+
{
|
|
561
|
+
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
562
|
+
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
563
|
+
},
|
|
393
564
|
"map",
|
|
394
565
|
async (input, context) => {
|
|
395
566
|
const filePath = resolvePath(context.directory, input.path as string);
|
|
396
567
|
await authorizeRead(context, filePath);
|
|
397
|
-
|
|
568
|
+
const args = ["map", filePath];
|
|
569
|
+
if (input.language) args.push("--language", input.language as string);
|
|
570
|
+
return runReadSeek(context, args);
|
|
571
|
+
},
|
|
572
|
+
),
|
|
573
|
+
readseek_grep: readseekTool(
|
|
574
|
+
"Search text or regular expressions and return matching LINE:HASH anchors. Use literal for exact text.",
|
|
575
|
+
{
|
|
576
|
+
pattern: tool.schema.string().describe("Text or regular expression to search for"),
|
|
577
|
+
path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
|
|
578
|
+
glob: tool.schema.string().optional().describe("File-name glob, such as **/*.ts"),
|
|
579
|
+
literal: tool.schema.boolean().optional().describe("Treat pattern as literal text"),
|
|
580
|
+
ignoreCase: tool.schema.boolean().optional().describe("Ignore case"),
|
|
581
|
+
context: tool.schema.number().int().nonnegative().optional().describe("Surrounding lines to return"),
|
|
582
|
+
limit: tool.schema.number().int().positive().optional().describe("Maximum matching lines; defaults to 100"),
|
|
583
|
+
},
|
|
584
|
+
"grep",
|
|
585
|
+
async (input, context) => {
|
|
586
|
+
const target = resolvePath(context.directory, (input.path as string | undefined) ?? ".");
|
|
587
|
+
const pattern = input.pattern as string;
|
|
588
|
+
await authorizeSearch(context, target, pattern);
|
|
589
|
+
let expression: RegExp;
|
|
590
|
+
try {
|
|
591
|
+
const source = input.literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
|
|
592
|
+
expression = new RegExp(source, input.ignoreCase ? "i" : "");
|
|
593
|
+
} catch (error) {
|
|
594
|
+
throw new Error(`invalid regular expression: ${error instanceof Error ? error.message : String(error)}`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const info = await stat(target);
|
|
598
|
+
const files: string[] = [];
|
|
599
|
+
if (info.isFile()) files.push(target);
|
|
600
|
+
else if (info.isDirectory()) {
|
|
601
|
+
const glob = new Bun.Glob((input.glob as string | undefined) ?? "**/*");
|
|
602
|
+
for await (const filePath of glob.scan({ cwd: target, absolute: true, onlyFiles: true, followSymlinks: false })) {
|
|
603
|
+
files.push(filePath);
|
|
604
|
+
}
|
|
605
|
+
} else throw new Error(`grep target is not a file or directory: ${target}`);
|
|
606
|
+
|
|
607
|
+
const maxMatches = (input.limit as number | undefined) ?? 100;
|
|
608
|
+
const contextLines = (input.context as number | undefined) ?? 0;
|
|
609
|
+
const results: { file: string; matches: unknown[]; hashlines: unknown[] }[] = [];
|
|
610
|
+
let totalMatches = 0;
|
|
611
|
+
for (const filePath of files.sort()) {
|
|
612
|
+
context.abort.throwIfAborted();
|
|
613
|
+
if (totalMatches >= maxMatches) break;
|
|
614
|
+
const buffer = await readFile(filePath);
|
|
615
|
+
if (buffer.includes(0)) continue;
|
|
616
|
+
const lines = buffer.toString("utf8").replace(/\r\n/g, "\n").split("\n");
|
|
617
|
+
const ranges: [number, number][] = [];
|
|
618
|
+
for (let index = 0; index < lines.length && totalMatches < maxMatches; index++) {
|
|
619
|
+
expression.lastIndex = 0;
|
|
620
|
+
if (!expression.test(lines[index] ?? "")) continue;
|
|
621
|
+
ranges.push([Math.max(1, index + 1 - contextLines), Math.min(lines.length, index + 1 + contextLines)]);
|
|
622
|
+
totalMatches++;
|
|
623
|
+
}
|
|
624
|
+
if (ranges.length === 0) continue;
|
|
625
|
+
const matches: unknown[] = [];
|
|
626
|
+
for (const [start, end] of ranges) {
|
|
627
|
+
const output = record(await runReadSeek(context, ["read", `${filePath}:${start}`, "--end", String(end)]));
|
|
628
|
+
matches.push(...items(output.hashlines));
|
|
629
|
+
}
|
|
630
|
+
results.push({ file: filePath, matches, hashlines: matches });
|
|
631
|
+
}
|
|
632
|
+
return { results, truncated: totalMatches >= maxMatches };
|
|
398
633
|
},
|
|
399
634
|
),
|
|
400
635
|
readseek_search: readseekTool(
|
|
401
|
-
"Search
|
|
636
|
+
"Search syntax-aware code shapes with an ast-grep pattern and return LINE:HASH anchors. Use for calls, imports, declarations, JSX, or control flow; use text search for plain text.",
|
|
402
637
|
{
|
|
403
|
-
pattern: tool.schema.string().describe("
|
|
638
|
+
pattern: tool.schema.string().describe("ast-grep pattern, such as console.log($$$ARGS)"),
|
|
404
639
|
path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
|
|
405
|
-
language: tool.schema.string().optional().describe("
|
|
406
|
-
cached: tool.schema.boolean().optional(),
|
|
407
|
-
others: tool.schema.boolean().optional(),
|
|
408
|
-
ignored: tool.schema.boolean().optional(),
|
|
640
|
+
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
641
|
+
cached: tool.schema.boolean().optional().describe("Search tracked or indexed files in a Git repository"),
|
|
642
|
+
others: tool.schema.boolean().optional().describe("Search untracked files in a Git repository"),
|
|
643
|
+
ignored: tool.schema.boolean().optional().describe("Include ignored untracked files; requires others"),
|
|
409
644
|
},
|
|
410
645
|
"search",
|
|
411
646
|
async (input, context) => {
|
|
@@ -419,14 +654,14 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
419
654
|
},
|
|
420
655
|
),
|
|
421
656
|
readseek_def: readseekTool(
|
|
422
|
-
"Find
|
|
657
|
+
"Find symbol declarations by name and return LINE:HASH anchors. Use instead of text search to locate where a function, class, type, or other symbol is defined.",
|
|
423
658
|
{
|
|
424
659
|
name: tool.schema.string().describe("Symbol name"),
|
|
425
660
|
path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
|
|
426
|
-
language: tool.schema.string().optional(),
|
|
427
|
-
cached: tool.schema.boolean().optional(),
|
|
428
|
-
others: tool.schema.boolean().optional(),
|
|
429
|
-
ignored: tool.schema.boolean().optional(),
|
|
661
|
+
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
662
|
+
cached: tool.schema.boolean().optional().describe("Search tracked or indexed files in a Git repository"),
|
|
663
|
+
others: tool.schema.boolean().optional().describe("Search untracked files in a Git repository"),
|
|
664
|
+
ignored: tool.schema.boolean().optional().describe("Include ignored untracked files; requires others"),
|
|
430
665
|
},
|
|
431
666
|
"def",
|
|
432
667
|
async (input, context) => {
|
|
@@ -439,17 +674,17 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
439
674
|
},
|
|
440
675
|
),
|
|
441
676
|
readseek_refs: readseekTool(
|
|
442
|
-
"Find
|
|
677
|
+
"Find identifier usages with enclosing symbols. Use before changing or deleting a symbol; add a cursor scope to exclude same-named bindings.",
|
|
443
678
|
{
|
|
444
679
|
name: tool.schema.string().describe("Identifier name"),
|
|
445
680
|
path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
|
|
446
|
-
language: tool.schema.string().optional(),
|
|
447
|
-
scope: tool.schema.boolean().optional(),
|
|
448
|
-
line: tool.schema.number().int().positive().optional(),
|
|
449
|
-
column: tool.schema.number().int().positive().optional(),
|
|
450
|
-
cached: tool.schema.boolean().optional(),
|
|
451
|
-
others: tool.schema.boolean().optional(),
|
|
452
|
-
ignored: tool.schema.boolean().optional(),
|
|
681
|
+
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
682
|
+
scope: tool.schema.boolean().optional().describe("Restrict results to the binding at the cursor"),
|
|
683
|
+
line: tool.schema.number().int().positive().optional().describe("Cursor line, required with scope"),
|
|
684
|
+
column: tool.schema.number().int().positive().optional().describe("Cursor byte column for disambiguation"),
|
|
685
|
+
cached: tool.schema.boolean().optional().describe("Search tracked or indexed files in a Git repository"),
|
|
686
|
+
others: tool.schema.boolean().optional().describe("Search untracked files in a Git repository"),
|
|
687
|
+
ignored: tool.schema.boolean().optional().describe("Include ignored untracked files; requires others"),
|
|
453
688
|
},
|
|
454
689
|
"refs",
|
|
455
690
|
async (input, context) => {
|
|
@@ -469,12 +704,12 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
469
704
|
},
|
|
470
705
|
),
|
|
471
706
|
readseek_hover: readseekTool(
|
|
472
|
-
"Identify the token and enclosing symbol at a
|
|
707
|
+
"Identify the token and enclosing symbol at a cursor. Use before definition lookup or rename, or to identify a line's enclosing symbol.",
|
|
473
708
|
{
|
|
474
709
|
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
475
710
|
line: tool.schema.number().int().positive().describe("One-based cursor line"),
|
|
476
711
|
column: tool.schema.number().int().positive().optional().describe("One-based cursor byte column"),
|
|
477
|
-
language: tool.schema.string().optional(),
|
|
712
|
+
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
478
713
|
},
|
|
479
714
|
"hover",
|
|
480
715
|
async (input, context) => {
|
|
@@ -487,13 +722,14 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
487
722
|
},
|
|
488
723
|
),
|
|
489
724
|
readseek_rename: readseekTool(
|
|
490
|
-
"
|
|
725
|
+
"Rename a symbol without changing same-named bindings. Applies verified edits atomically by default; set apply false for a dry-run plan.",
|
|
491
726
|
{
|
|
492
727
|
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
493
|
-
line: tool.schema.number().int().positive().describe("One-based
|
|
494
|
-
column: tool.schema.number().int().positive().optional().describe("One-based
|
|
495
|
-
to: tool.schema.string().min(1).describe("New
|
|
728
|
+
line: tool.schema.number().int().positive().describe("One-based line of the symbol to rename"),
|
|
729
|
+
column: tool.schema.number().int().positive().optional().describe("One-based byte column for disambiguation"),
|
|
730
|
+
to: tool.schema.string().min(1).describe("New symbol name"),
|
|
496
731
|
workspace: tool.schema.boolean().optional().describe("Include project-wide occurrences"),
|
|
732
|
+
apply: tool.schema.boolean().optional().describe("Apply verified edits atomically; defaults to true"),
|
|
497
733
|
},
|
|
498
734
|
"rename",
|
|
499
735
|
async (input, context) => {
|
|
@@ -509,17 +745,81 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
509
745
|
const args = ["rename", filePath, "--line", String(input.line), "--to", input.to as string];
|
|
510
746
|
if (input.column) args.push("--column", String(input.column));
|
|
511
747
|
if (input.workspace) args.push("--workspace", context.directory);
|
|
512
|
-
|
|
748
|
+
const plan = await runReadSeek(context, args);
|
|
749
|
+
if (input.apply === false) return plan;
|
|
750
|
+
const files = renameFiles(plan);
|
|
751
|
+
if (input.workspace) {
|
|
752
|
+
await context.ask({
|
|
753
|
+
permission: "edit",
|
|
754
|
+
patterns: ["**"],
|
|
755
|
+
always: ["**"],
|
|
756
|
+
metadata: { filepath: files.join(", "), diff: "" },
|
|
757
|
+
});
|
|
758
|
+
} else {
|
|
759
|
+
await authorizeEdit(context, files);
|
|
760
|
+
}
|
|
761
|
+
return runReadSeek(context, [...args, "--apply"]);
|
|
762
|
+
},
|
|
763
|
+
),
|
|
764
|
+
readseek_edit: readseekTool(
|
|
765
|
+
"Edit an existing text file using LINE:HASH anchors. Stale hashes are rejected before writing.",
|
|
766
|
+
{
|
|
767
|
+
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
768
|
+
edits: tool.schema.array(tool.schema.union([
|
|
769
|
+
tool.schema.object({ set_line: tool.schema.object({ anchor: tool.schema.string(), new_text: tool.schema.string() }) }),
|
|
770
|
+
tool.schema.object({ replace_lines: tool.schema.object({ start_anchor: tool.schema.string(), end_anchor: tool.schema.string(), new_text: tool.schema.string() }) }),
|
|
771
|
+
tool.schema.object({ insert_after: tool.schema.object({ anchor: tool.schema.string(), new_text: tool.schema.string() }) }),
|
|
772
|
+
])).min(1).describe("Anchored edits to apply"),
|
|
773
|
+
},
|
|
774
|
+
"edit",
|
|
775
|
+
async (input, context) => {
|
|
776
|
+
const filePath = resolvePath(context.directory, input.path as string);
|
|
777
|
+
await authorizeRead(context, filePath);
|
|
778
|
+
await rejectSymlinkMutation(filePath);
|
|
779
|
+
const edits = input.edits as AnchorEdit[];
|
|
780
|
+
const before = await readFile(filePath, "utf8");
|
|
781
|
+
await verifyAnchors(context, filePath, edits);
|
|
782
|
+
const after = applyAnchorEdits(before, edits);
|
|
783
|
+
await authorizeEdit(context, [filePath], simpleDiff(filePath, before, after), false);
|
|
784
|
+
if (await readFile(filePath, "utf8") !== before) throw new Error(`refusing to edit changed file: ${filePath}`);
|
|
785
|
+
if (before !== after) await writeText(filePath, after);
|
|
786
|
+
return runReadSeek(context, ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)]);
|
|
787
|
+
},
|
|
788
|
+
),
|
|
789
|
+
readseek_write: readseekTool(
|
|
790
|
+
"Create or replace a whole text file and return LINE:HASH anchors.",
|
|
791
|
+
{
|
|
792
|
+
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
793
|
+
content: tool.schema.string().describe("Complete text file content"),
|
|
794
|
+
},
|
|
795
|
+
"write",
|
|
796
|
+
async (input, context) => {
|
|
797
|
+
const filePath = resolvePath(context.directory, input.path as string);
|
|
798
|
+
await authorizeExternal(context, filePath);
|
|
799
|
+
await rejectSymlinkMutation(filePath);
|
|
800
|
+
const before = await readFile(filePath, "utf8").catch(() => "");
|
|
801
|
+
const content = input.content as string;
|
|
802
|
+
if (content.includes("\0")) throw new Error("write content must be text");
|
|
803
|
+
await authorizeEdit(context, [filePath], simpleDiff(filePath, before, content), false);
|
|
804
|
+
const current = await readFile(filePath, "utf8").catch(() => "");
|
|
805
|
+
if (current !== before) throw new Error(`refusing to overwrite changed file: ${filePath}`);
|
|
806
|
+
if (before !== content) await writeText(filePath, content);
|
|
807
|
+
return runReadSeek(context, ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)]);
|
|
513
808
|
},
|
|
514
809
|
),
|
|
515
810
|
readseek_check: readseekTool(
|
|
516
|
-
"Check a source file for parser errors and missing syntax.",
|
|
517
|
-
{
|
|
811
|
+
"Check a source file for parser errors and missing syntax. Use after edits for quick syntax validation, not as a compiler or linter.",
|
|
812
|
+
{
|
|
813
|
+
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
814
|
+
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
815
|
+
},
|
|
518
816
|
"check",
|
|
519
817
|
async (input, context) => {
|
|
520
818
|
const filePath = resolvePath(context.directory, input.path as string);
|
|
521
819
|
await authorizeRead(context, filePath);
|
|
522
|
-
|
|
820
|
+
const args = ["check", filePath];
|
|
821
|
+
if (input.language) args.push("--language", input.language as string);
|
|
822
|
+
return runReadSeek(context, args);
|
|
523
823
|
},
|
|
524
824
|
),
|
|
525
825
|
},
|
|
@@ -530,19 +830,19 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
530
830
|
}
|
|
531
831
|
if (event.type === "session.deleted") anchors.deleteSession(event.properties.info.id);
|
|
532
832
|
},
|
|
533
|
-
"tool.execute.before": async (input, output) => {
|
|
534
|
-
if (input.tool === "readseek_rename" && output.args.apply === true) {
|
|
535
|
-
throw new Error("readseek_rename only creates plans; apply its edits with OpenCode's edit tools");
|
|
536
|
-
}
|
|
537
|
-
},
|
|
538
833
|
"tool.execute.after": async (input, output) => {
|
|
539
834
|
if (!input.tool.startsWith("readseek_")) return;
|
|
540
835
|
try {
|
|
541
836
|
const result = JSON.parse(output.output) as unknown;
|
|
542
|
-
if (input.tool === "readseek_rename")
|
|
837
|
+
if (input.tool === "readseek_rename") {
|
|
838
|
+
anchors.planRename(input.sessionID, result);
|
|
839
|
+
if (record(result).applied === true) {
|
|
840
|
+
for (const filePath of renameFiles(result)) anchors.forget(filePath);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
543
843
|
|
|
544
844
|
const files = new Set<string>();
|
|
545
|
-
|
|
845
|
+
collectAnchoredFiles(result, files);
|
|
546
846
|
for (const filePath of files) anchors.mark(input.sessionID, path.resolve(filePath));
|
|
547
847
|
} catch {
|
|
548
848
|
// A failed tool result has no valid anchors to retain.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-readseek",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "OpenCode plugin for readseek-backed structural code navigation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"node": ">=20.0.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@jarkkojs/readseek": "^0.6.
|
|
33
|
+
"@jarkkojs/readseek": "^0.6.5",
|
|
34
34
|
"@opencode-ai/plugin": "^1.17.20"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|