@ridit/lens 0.2.1 → 0.2.2

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.
@@ -0,0 +1,324 @@
1
+ import type { Tool, ToolContext, ToolResult } from "./registry";
2
+ import {
3
+ fetchUrl,
4
+ searchWeb,
5
+ runShell,
6
+ openUrl,
7
+ readFile,
8
+ readFolder,
9
+ grepFiles,
10
+ writeFile,
11
+ deleteFile,
12
+ deleteFolder,
13
+ generatePdf,
14
+ } from "../../tools";
15
+
16
+ // ── fetch ─────────────────────────────────────────────────────────────────────
17
+
18
+ export const fetchTool: Tool<string> = {
19
+ name: "fetch",
20
+ description: "load a URL",
21
+ safe: true,
22
+ permissionLabel: "fetch",
23
+ systemPromptEntry: (i) =>
24
+ `### ${i}. fetch — load a URL\n<fetch>https://example.com</fetch>`,
25
+ parseInput: (body) => body.replace(/^<|>$/g, "").trim() || null,
26
+ summariseInput: (url) => url,
27
+ execute: async (url) => {
28
+ try {
29
+ const value = await fetchUrl(url);
30
+ return { kind: "text", value };
31
+ } catch (err) {
32
+ return {
33
+ kind: "error",
34
+ value: `Fetch failed: ${err instanceof Error ? err.message : String(err)}`,
35
+ };
36
+ }
37
+ },
38
+ };
39
+
40
+ // ── shell ─────────────────────────────────────────────────────────────────────
41
+
42
+ export const shellTool: Tool<string> = {
43
+ name: "shell",
44
+ description: "run a terminal command",
45
+ safe: false,
46
+ permissionLabel: "run",
47
+ systemPromptEntry: (i) =>
48
+ `### ${i}. shell — run a terminal command\n<shell>node -v</shell>`,
49
+ parseInput: (body) => body || null,
50
+ summariseInput: (cmd) => cmd,
51
+ execute: async (cmd, ctx) => {
52
+ const value = await runShell(cmd, ctx.repoPath);
53
+ return { kind: "text", value };
54
+ },
55
+ };
56
+
57
+ // ── read-file ─────────────────────────────────────────────────────────────────
58
+
59
+ export const readFileTool: Tool<string> = {
60
+ name: "read-file",
61
+ description: "read a file from the repo",
62
+ safe: true,
63
+ permissionLabel: "read",
64
+ systemPromptEntry: (i) =>
65
+ `### ${i}. read-file — read a file from the repo\n<read-file>src/foo.ts</read-file>`,
66
+ parseInput: (body) => body || null,
67
+ summariseInput: (p) => p,
68
+ execute: (filePath, ctx) => ({
69
+ kind: "text",
70
+ value: readFile(filePath, ctx.repoPath),
71
+ }),
72
+ };
73
+
74
+ // ── read-folder ───────────────────────────────────────────────────────────────
75
+
76
+ export const readFolderTool: Tool<string> = {
77
+ name: "read-folder",
78
+ description: "list contents of a folder (files + subfolders, one level deep)",
79
+ safe: true,
80
+ permissionLabel: "folder",
81
+ systemPromptEntry: (i) =>
82
+ `### ${i}. read-folder — list contents of a folder (files + subfolders, one level deep)\n<read-folder>src/components</read-folder>`,
83
+ parseInput: (body) => body || null,
84
+ summariseInput: (p) => p,
85
+ execute: (folderPath, ctx) => ({
86
+ kind: "text",
87
+ value: readFolder(folderPath, ctx.repoPath),
88
+ }),
89
+ };
90
+
91
+ // ── grep ──────────────────────────────────────────────────────────────────────
92
+
93
+ interface GrepInput {
94
+ pattern: string;
95
+ glob: string;
96
+ }
97
+
98
+ export const grepTool: Tool<GrepInput> = {
99
+ name: "grep",
100
+ description: "search for a pattern across files in the repo",
101
+ safe: true,
102
+ permissionLabel: "grep",
103
+ systemPromptEntry: (i) =>
104
+ `### ${i}. grep — search for a pattern across files in the repo (cross-platform, no shell needed)\n<grep>\n{"pattern": "ChatRunner", "glob": "src/**/*.tsx"}\n</grep>`,
105
+ parseInput: (body) => {
106
+ try {
107
+ const parsed = JSON.parse(body) as { pattern: string; glob?: string };
108
+ return { pattern: parsed.pattern, glob: parsed.glob ?? "**/*" };
109
+ } catch {
110
+ return { pattern: body, glob: "**/*" };
111
+ }
112
+ },
113
+ summariseInput: ({ pattern, glob }) => `${pattern} — ${glob}`,
114
+ execute: ({ pattern, glob }, ctx) => ({
115
+ kind: "text",
116
+ value: grepFiles(pattern, glob, ctx.repoPath),
117
+ }),
118
+ };
119
+
120
+ // ── write-file ────────────────────────────────────────────────────────────────
121
+
122
+ interface WriteFileInput {
123
+ path: string;
124
+ content: string;
125
+ }
126
+
127
+ export const writeFileTool: Tool<WriteFileInput> = {
128
+ name: "write-file",
129
+ description: "create or overwrite a file",
130
+ safe: false,
131
+ permissionLabel: "write",
132
+ systemPromptEntry: (i) =>
133
+ `### ${i}. write-file — create or overwrite a file\n<write-file>\n{"path": "data/output.csv", "content": "col1,col2\\nval1,val2"}\n</write-file>`,
134
+ parseInput: (body) => {
135
+ try {
136
+ const parsed = JSON.parse(body) as { path: string; content: string };
137
+ if (!parsed.path) return null;
138
+ return parsed;
139
+ } catch {
140
+ return null;
141
+ }
142
+ },
143
+ summariseInput: ({ path, content }) => `${path} (${content.length} bytes)`,
144
+ execute: ({ path: filePath, content }, ctx) => ({
145
+ kind: "text",
146
+ value: writeFile(filePath, content, ctx.repoPath),
147
+ }),
148
+ };
149
+
150
+ // ── delete-file ───────────────────────────────────────────────────────────────
151
+
152
+ export const deleteFileTool: Tool<string> = {
153
+ name: "delete-file",
154
+ description: "permanently delete a single file",
155
+ safe: false,
156
+ permissionLabel: "delete",
157
+ systemPromptEntry: (i) =>
158
+ `### ${i}. delete-file — permanently delete a single file\n<delete-file>src/old-component.tsx</delete-file>`,
159
+ parseInput: (body) => body || null,
160
+ summariseInput: (p) => p,
161
+ execute: (filePath, ctx) => ({
162
+ kind: "text",
163
+ value: deleteFile(filePath, ctx.repoPath),
164
+ }),
165
+ };
166
+
167
+ // ── delete-folder ─────────────────────────────────────────────────────────────
168
+
169
+ export const deleteFolderTool: Tool<string> = {
170
+ name: "delete-folder",
171
+ description: "permanently delete a folder and all its contents",
172
+ safe: false,
173
+ permissionLabel: "delete folder",
174
+ systemPromptEntry: (i) =>
175
+ `### ${i}. delete-folder — permanently delete a folder and all its contents\n<delete-folder>src/legacy</delete-folder>`,
176
+ parseInput: (body) => body || null,
177
+ summariseInput: (p) => p,
178
+ execute: (folderPath, ctx) => ({
179
+ kind: "text",
180
+ value: deleteFolder(folderPath, ctx.repoPath),
181
+ }),
182
+ };
183
+
184
+ // ── open-url ──────────────────────────────────────────────────────────────────
185
+
186
+ export const openUrlTool: Tool<string> = {
187
+ name: "open-url",
188
+ description: "open a URL in the user's default browser",
189
+ safe: true,
190
+ permissionLabel: "open",
191
+ systemPromptEntry: (i) =>
192
+ `### ${i}. open-url — open a URL in the user's default browser\n<open-url>https://github.com/owner/repo</open-url>`,
193
+ parseInput: (body) => body.replace(/^<|>$/g, "").trim() || null,
194
+ summariseInput: (url) => url,
195
+ execute: (url) => ({ kind: "text", value: openUrl(url) }),
196
+ };
197
+
198
+ // ── generate-pdf ──────────────────────────────────────────────────────────────
199
+
200
+ interface GeneratePdfInput {
201
+ filePath: string;
202
+ content: string;
203
+ }
204
+
205
+ export const generatePdfTool: Tool<GeneratePdfInput> = {
206
+ name: "generate-pdf",
207
+ description: "generate a PDF file from markdown-style content",
208
+ safe: false,
209
+ permissionLabel: "pdf",
210
+ systemPromptEntry: (i) =>
211
+ `### ${i}. generate-pdf — generate a PDF file from markdown-style content\n<generate-pdf>\n{"path": "output/report.pdf", "content": "# Title\\n\\nSome body text."}\n</generate-pdf>`,
212
+ parseInput: (body) => {
213
+ try {
214
+ const parsed = JSON.parse(body) as {
215
+ path?: string;
216
+ filePath?: string;
217
+ content?: string;
218
+ };
219
+ return {
220
+ filePath: parsed.path ?? parsed.filePath ?? "output.pdf",
221
+ content: parsed.content ?? "",
222
+ };
223
+ } catch {
224
+ return null;
225
+ }
226
+ },
227
+ summariseInput: ({ filePath }) => filePath,
228
+ execute: ({ filePath, content }, ctx) => ({
229
+ kind: "text",
230
+ value: generatePdf(filePath, content, ctx.repoPath),
231
+ }),
232
+ };
233
+
234
+ // ── search ────────────────────────────────────────────────────────────────────
235
+
236
+ export const searchTool: Tool<string> = {
237
+ name: "search",
238
+ description: "search the internet for anything you are unsure about",
239
+ safe: true,
240
+ permissionLabel: "search",
241
+ systemPromptEntry: (i) =>
242
+ `### ${i}. search — search the internet for anything you are unsure about\n<search>how to use React useEffect cleanup function</search>`,
243
+ parseInput: (body) => body || null,
244
+ summariseInput: (q) => `"${q}"`,
245
+ execute: async (query) => {
246
+ try {
247
+ const value = await searchWeb(query);
248
+ return { kind: "text", value };
249
+ } catch (err) {
250
+ return {
251
+ kind: "error",
252
+ value: `Search failed: ${err instanceof Error ? err.message : String(err)}`,
253
+ };
254
+ }
255
+ },
256
+ };
257
+
258
+ // ── clone ─────────────────────────────────────────────────────────────────────
259
+
260
+ export const cloneTool: Tool<string> = {
261
+ name: "clone",
262
+ description: "clone a GitHub repo so you can explore and discuss it",
263
+ safe: false,
264
+ permissionLabel: "clone",
265
+ systemPromptEntry: (i) =>
266
+ `### ${i}. clone — clone a GitHub repo so you can explore and discuss it\n<clone>https://github.com/owner/repo</clone>`,
267
+ parseInput: (body) => body.replace(/^<|>$/g, "").trim() || null,
268
+ summariseInput: (url) => url,
269
+ // Clone is handled specially by ChatRunner (it triggers a UI flow),
270
+ // so execute here is just a fallback that should never run.
271
+ execute: (repoUrl) => ({
272
+ kind: "text",
273
+ value: `Clone of ${repoUrl} was handled by the UI.`,
274
+ }),
275
+ };
276
+
277
+ // ── changes ───────────────────────────────────────────────────────────────────
278
+
279
+ export interface ChangesInput {
280
+ summary: string;
281
+ patches: { path: string; content: string; isNew: boolean }[];
282
+ }
283
+
284
+ export const changesTool: Tool<ChangesInput> = {
285
+ name: "changes",
286
+ description: "propose code edits (shown as a diff for user approval)",
287
+ safe: false,
288
+ permissionLabel: "changes",
289
+ systemPromptEntry: (i) =>
290
+ `### ${i}. changes — propose code edits (shown as a diff for user approval)\n<changes>\n{"summary": "what changed and why", "patches": [{"path": "src/foo.ts", "content": "COMPLETE file content", "isNew": false}]}\n</changes>`,
291
+ parseInput: (body) => {
292
+ try {
293
+ return JSON.parse(body) as ChangesInput;
294
+ } catch {
295
+ return null;
296
+ }
297
+ },
298
+ summariseInput: ({ summary }) => summary,
299
+ // changes is handled specially by ChatRunner (diff preview UI).
300
+ execute: ({ summary }) => ({
301
+ kind: "text",
302
+ value: `Changes proposed: ${summary}`,
303
+ }),
304
+ };
305
+
306
+ // ── registerBuiltins ──────────────────────────────────────────────────────────
307
+
308
+ import { registry } from "./registry";
309
+
310
+ export function registerBuiltins(): void {
311
+ registry.register(fetchTool);
312
+ registry.register(shellTool);
313
+ registry.register(readFileTool);
314
+ registry.register(readFolderTool);
315
+ registry.register(grepTool);
316
+ registry.register(writeFileTool);
317
+ registry.register(deleteFileTool);
318
+ registry.register(deleteFolderTool);
319
+ registry.register(openUrlTool);
320
+ registry.register(generatePdfTool);
321
+ registry.register(searchTool);
322
+ registry.register(cloneTool);
323
+ registry.register(changesTool);
324
+ }
@@ -0,0 +1,119 @@
1
+ // ── Tool Plugin System ────────────────────────────────────────────────────────
2
+ //
3
+ // To create a new tool:
4
+ //
5
+ // 1. Implement the Tool interface
6
+ // 2. Call registry.register(myTool) before the app starts
7
+ //
8
+ // External addon example:
9
+ //
10
+ // import { registry } from "lens/tools/registry";
11
+ // registry.register({ name: "my-tool", ... });
12
+
13
+ export interface ToolContext {
14
+ repoPath: string;
15
+ /** All messages in the current conversation so far */
16
+ messages: unknown[];
17
+ }
18
+
19
+ export type ToolResult =
20
+ | { kind: "text"; value: string }
21
+ | { kind: "error"; value: string };
22
+
23
+ export interface Tool<TInput = string> {
24
+ /**
25
+ * Tag name used in XML: <name>...</name>
26
+ * Must be lowercase, hyphens allowed. Must be unique.
27
+ */
28
+ name: string;
29
+
30
+ /**
31
+ * Short description shown in system prompt and /help.
32
+ */
33
+ description: string;
34
+
35
+ /**
36
+ * System prompt snippet explaining how to invoke this tool.
37
+ * Return the full ### N. name — description block.
38
+ */
39
+ systemPromptEntry(index: number): string;
40
+
41
+ /**
42
+ * Parse the raw inner text of the XML tag into a typed input.
43
+ * Throw or return null to signal a parse failure (tool will be skipped).
44
+ */
45
+ parseInput(body: string): TInput | null;
46
+
47
+ /**
48
+ * Execute the tool. May be async.
49
+ * Return a ToolResult — the value is fed back to the model as the tool result.
50
+ */
51
+ execute(input: TInput, ctx: ToolContext): Promise<ToolResult> | ToolResult;
52
+
53
+ /**
54
+ * Whether this tool is safe to auto-approve (read-only, no side effects).
55
+ * Defaults to false.
56
+ */
57
+ safe?: boolean;
58
+
59
+ /**
60
+ * Optional: permission prompt label shown to the user before execution.
61
+ * e.g. "run", "read", "write", "delete"
62
+ * Defaults to the tool name.
63
+ */
64
+ permissionLabel?: string;
65
+
66
+ /**
67
+ * Optional: summarise the input for display in the chat history.
68
+ * Defaults to showing the raw input string.
69
+ */
70
+ summariseInput?(input: TInput): string;
71
+ }
72
+
73
+ // ── Registry ──────────────────────────────────────────────────────────────────
74
+
75
+ class ToolRegistry {
76
+ private tools = new Map<string, Tool<unknown>>();
77
+
78
+ register<T>(tool: Tool<T>): void {
79
+ if (this.tools.has(tool.name)) {
80
+ console.warn(`[ToolRegistry] Overwriting existing tool: "${tool.name}"`);
81
+ }
82
+ this.tools.set(tool.name, tool as Tool<unknown>);
83
+ }
84
+
85
+ unregister(name: string): void {
86
+ this.tools.delete(name);
87
+ }
88
+
89
+ get(name: string): Tool<unknown> | undefined {
90
+ return this.tools.get(name);
91
+ }
92
+
93
+ all(): Tool<unknown>[] {
94
+ return Array.from(this.tools.values());
95
+ }
96
+
97
+ names(): string[] {
98
+ return Array.from(this.tools.keys());
99
+ }
100
+
101
+ /**
102
+ * Build the TOOLS section of the system prompt from all registered tools.
103
+ */
104
+ buildSystemPromptSection(): string {
105
+ const lines: string[] = ["## TOOLS\n"];
106
+ lines.push(
107
+ "You have exactly " +
108
+ this.tools.size +
109
+ " tools. To use a tool you MUST wrap it in the exact XML tags shown below — no other format will work.\n",
110
+ );
111
+ let i = 1;
112
+ for (const tool of this.tools.values()) {
113
+ lines.push(tool.systemPromptEntry(i++));
114
+ }
115
+ return lines.join("\n");
116
+ }
117
+ }
118
+
119
+ export const registry = new ToolRegistry();