open-grok-build 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.
@@ -0,0 +1,538 @@
1
+ // @ts-nocheck — shim tools use dynamic params/renderers; runtime-tested via vitest.
2
+ import {
3
+ existsSync,
4
+ promises as fs,
5
+ mkdirSync,
6
+ readFileSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { basename, dirname, join, resolve, sep } from 'node:path';
11
+ import {
12
+ booleanDetail,
13
+ detailRecord,
14
+ fileError,
15
+ fileNotFound,
16
+ MAX_OUTPUT_CHARS,
17
+ numberDetail,
18
+ recordFrom,
19
+ renderResultSummary,
20
+ stringDetail,
21
+ stringFrom,
22
+ type ToolError,
23
+ text,
24
+ } from './rendering.js';
25
+ import type { ToolRegistrar } from './types.js';
26
+
27
+ type ReplacementEdit = { oldText: string; newText: string };
28
+ type FileDetails = { path: string; [key: string]: unknown };
29
+ type WriteArgs = { path: string; content: string };
30
+ type StrReplaceArgs = { path: string; old_str: string; new_str: string };
31
+ type EditArgs = {
32
+ path: string;
33
+ edits?: ReplacementEdit[];
34
+ applyPatch?: { patchContent: string };
35
+ strReplace?: ReplacementEdit;
36
+ multiStrReplace?: { edits: ReplacementEdit[] };
37
+ };
38
+
39
+ type ToolTheme = {
40
+ bold: (text: string) => string;
41
+ fg: (name: 'accent' | 'toolTitle', text: string) => string;
42
+ };
43
+
44
+ function parseEditList(value: unknown): ReplacementEdit[] | undefined {
45
+ const editList = typeof value === 'string' ? parseJson(value) : value;
46
+ if (!Array.isArray(editList)) return undefined;
47
+ if (
48
+ !editList.every(
49
+ (edit) =>
50
+ typeof recordFrom(edit)?.oldText === 'string' &&
51
+ typeof recordFrom(edit)?.newText === 'string',
52
+ )
53
+ ) {
54
+ return undefined;
55
+ }
56
+ return editList.map((edit) => ({
57
+ oldText: stringFrom(recordFrom(edit)?.oldText) ?? '',
58
+ newText: stringFrom(recordFrom(edit)?.newText) ?? '',
59
+ }));
60
+ }
61
+
62
+ function parseJson(value: string): unknown {
63
+ try {
64
+ return JSON.parse(value);
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ function editFromText(oldText: unknown, newText: unknown) {
71
+ if (typeof oldText !== 'string' || typeof newText !== 'string') return undefined;
72
+ return [{ oldText, newText }];
73
+ }
74
+
75
+ function editsFromArgs(input: Record<string, unknown>) {
76
+ return (
77
+ parseEditList(input.edits) ??
78
+ parseEditList(recordFrom(input.multiStrReplace)?.edits) ??
79
+ editFromText(input.oldText, input.newText) ??
80
+ editFromText(recordFrom(input.strReplace)?.oldText, recordFrom(input.strReplace)?.newText)
81
+ );
82
+ }
83
+
84
+ function applyEdits(content: string, edits: ReplacementEdit[]) {
85
+ return edits.reduce(
86
+ (result, edit) => {
87
+ const count = result.content.split(edit.oldText).length - 1;
88
+ return {
89
+ content:
90
+ count === 0
91
+ ? result.content
92
+ : result.content.replaceAll(edit.oldText, () => edit.newText),
93
+ replacements: result.replacements + count,
94
+ };
95
+ },
96
+ { content, replacements: 0 },
97
+ );
98
+ }
99
+
100
+ function replacementResult(text: string, filePath: string) {
101
+ return {
102
+ content: [{ type: 'text' as const, text }],
103
+ details: { path: filePath, replacements: 0 },
104
+ };
105
+ }
106
+
107
+ function renderReplacementResult(
108
+ result: { content: { type: string; text?: string }[]; details: unknown },
109
+ expanded: boolean,
110
+ isPartial: boolean,
111
+ theme: { fg: (name: 'dim' | 'muted', text: string) => string },
112
+ ) {
113
+ const replacements = numberDetail(result, 'replacements');
114
+ return renderResultSummary(
115
+ result,
116
+ expanded,
117
+ isPartial,
118
+ replacements === 0
119
+ ? theme.fg('dim', 'No replacements')
120
+ : theme.fg('muted', `${replacements} replacement(s)`),
121
+ );
122
+ }
123
+
124
+ function renderPathToolCall(toolName: string, filePath: string, theme: ToolTheme) {
125
+ return text(theme.fg('toolTitle', theme.bold(`${toolName} `)) + theme.fg('accent', filePath));
126
+ }
127
+
128
+ async function canonicalizeWithinWorkspace(cwd: string, requestedPath: string) {
129
+ const targetPath = resolve(cwd, requestedPath);
130
+ const realCwd = await fs.realpath(cwd);
131
+ const missingParts: string[] = [];
132
+ let currentPath = targetPath;
133
+ let realTarget: string | undefined;
134
+ while (!realTarget) {
135
+ try {
136
+ realTarget = join(await fs.realpath(currentPath), ...[...missingParts].reverse());
137
+ } catch (error) {
138
+ const parentPath = dirname(currentPath);
139
+ if (parentPath === currentPath) throw error;
140
+ missingParts.push(basename(currentPath));
141
+ currentPath = parentPath;
142
+ }
143
+ }
144
+ if (realTarget !== realCwd && !realTarget.startsWith(`${realCwd}${sep}`)) {
145
+ throw new Error('Path is outside the workspace');
146
+ }
147
+ return realTarget;
148
+ }
149
+
150
+ async function existingPathWithinWorkspace(cwd: string, requestedPath: string) {
151
+ const safePath = await canonicalizeWithinWorkspace(cwd, requestedPath);
152
+ return existsSync(safePath) ? safePath : undefined;
153
+ }
154
+
155
+ async function existingPathOrNotFound<T extends FileDetails>(
156
+ cwd: string,
157
+ requestedPath: string,
158
+ extraDetails: Omit<T, 'path'>,
159
+ ) {
160
+ return (
161
+ (await existingPathWithinWorkspace(cwd, requestedPath)) ??
162
+ fileNotFound(resolve(cwd, requestedPath), extraDetails)
163
+ );
164
+ }
165
+
166
+ function replacementPathOrNotFound(cwd: string, requestedPath: string) {
167
+ return existingPathOrNotFound(cwd, requestedPath, { replacements: 0 });
168
+ }
169
+
170
+ export function registerFileTools(registrar: ToolRegistrar) {
171
+ // ── LS tool ──────────────────────────────────────────────────────────
172
+
173
+ registrar.registerTool({
174
+ name: 'LS',
175
+ label: 'LS',
176
+ description: 'List the contents of a directory, including hidden files.',
177
+
178
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
179
+ const targetPath = resolve(ctx.cwd, params.path);
180
+
181
+ try {
182
+ const safePath = await canonicalizeWithinWorkspace(ctx.cwd, params.path);
183
+ if (signal?.aborted) throw new Error('The operation was aborted');
184
+
185
+ let output = (await fs.readdir(safePath)).sort().join('\n');
186
+ if (output.length > MAX_OUTPUT_CHARS) {
187
+ output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n\n[LS: output truncated at 50KB]`;
188
+ }
189
+
190
+ return {
191
+ content: [{ type: 'text', text: output }],
192
+ details: { path: safePath },
193
+ };
194
+ } catch (error: unknown) {
195
+ const err = error as ToolError;
196
+ const message = err.message ?? 'Unknown error';
197
+ return {
198
+ content: [
199
+ {
200
+ type: 'text',
201
+ text: `LS error: ${message}`,
202
+ },
203
+ ],
204
+ details: { path: targetPath, failed: true, error: message },
205
+ };
206
+ }
207
+ },
208
+ renderCall(args, theme) {
209
+ return renderPathToolCall('LS', args.path, theme);
210
+ },
211
+ renderResult(result, { expanded, isPartial }, theme) {
212
+ return renderResultSummary(
213
+ result,
214
+ expanded,
215
+ isPartial,
216
+ theme.fg('muted', stringDetail(result, 'path')),
217
+ );
218
+ },
219
+ });
220
+
221
+ // ── Read tool ────────────────────────────────────────────────────────
222
+
223
+ registrar.registerTool({
224
+ name: 'Read',
225
+ label: 'Read',
226
+ description: 'Read the contents of a file. Returns the file content with line numbers.',
227
+
228
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
229
+ const filePath = resolve(ctx.cwd, params.path);
230
+
231
+ try {
232
+ const safePath = await existingPathOrNotFound(ctx.cwd, params.path, {
233
+ exists: false,
234
+ totalLines: 0,
235
+ });
236
+ if (typeof safePath !== 'string') return safePath;
237
+
238
+ const content = readFileSync(safePath, 'utf-8');
239
+ const lines = content.endsWith('\n')
240
+ ? content.slice(0, -1).split('\n')
241
+ : content.split('\n');
242
+
243
+ const startLine = params.offset ?? 0;
244
+ const endLine =
245
+ params.limit !== undefined
246
+ ? Math.min(startLine + params.limit, lines.length)
247
+ : Math.min(startLine + 2000, lines.length);
248
+
249
+ const selectedLines = lines.slice(startLine, endLine);
250
+ const numberedLines = selectedLines.map((line, i) => `${startLine + i + 1}\t${line}`);
251
+
252
+ let output = numberedLines.join('\n');
253
+ if (endLine < lines.length) {
254
+ output += `\n\n[Showing lines ${startLine + 1}-${endLine} of ${lines.length} total lines. Use offset to see more.]`;
255
+ }
256
+
257
+ if (output.length > MAX_OUTPUT_CHARS) {
258
+ output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n\n[Output truncated at 50KB]`;
259
+ }
260
+
261
+ return {
262
+ content: [{ type: 'text', text: output }],
263
+ details: { path: safePath, totalLines: lines.length },
264
+ };
265
+ } catch (error: unknown) {
266
+ const err = error as { code?: string };
267
+ return fileError(error, 'Read', filePath, {
268
+ exists: err.code !== 'ENOENT',
269
+ totalLines: 0,
270
+ });
271
+ }
272
+ },
273
+ renderCall(args, theme) {
274
+ const range =
275
+ args.offset !== undefined || args.limit !== undefined
276
+ ? theme.fg(
277
+ 'muted',
278
+ ` (from ${args.offset ?? 0}${args.limit !== undefined ? `, ${args.limit} lines` : ''})`,
279
+ )
280
+ : '';
281
+ return text(
282
+ theme.fg('toolTitle', theme.bold('Read ')) + theme.fg('accent', args.path) + range,
283
+ );
284
+ },
285
+ renderResult(result, { expanded, isPartial }, theme) {
286
+ return renderResultSummary(
287
+ result,
288
+ expanded,
289
+ isPartial,
290
+ detailRecord(result).exists === false
291
+ ? theme.fg('error', 'File not found')
292
+ : theme.fg('muted', `${numberDetail(result, 'totalLines')} line(s)`),
293
+ );
294
+ },
295
+ });
296
+
297
+ // ── Write tool ───────────────────────────────────────────────────────
298
+
299
+ registrar.registerTool({
300
+ name: 'Write',
301
+ label: 'Write',
302
+ description:
303
+ 'Create or overwrite a file with the given content. Creates parent directories if needed.',
304
+
305
+ prepareArguments(args) {
306
+ const input = recordFrom(args);
307
+ if (!input) return args as WriteArgs;
308
+ return {
309
+ ...input,
310
+ content: stringFrom(input.content) ?? stringFrom(input.contents),
311
+ } as WriteArgs;
312
+ },
313
+
314
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
315
+ const filePath = resolve(ctx.cwd, params.path);
316
+
317
+ try {
318
+ const safePath = await canonicalizeWithinWorkspace(ctx.cwd, params.path);
319
+ mkdirSync(dirname(safePath), { recursive: true });
320
+ writeFileSync(safePath, params.content, 'utf-8');
321
+ const bytesWritten = Buffer.byteLength(params.content, 'utf8');
322
+
323
+ return {
324
+ content: [
325
+ {
326
+ type: 'text',
327
+ text: `Successfully wrote ${bytesWritten} bytes to ${params.path}`,
328
+ },
329
+ ],
330
+ details: { path: safePath, bytesWritten },
331
+ };
332
+ } catch (error: unknown) {
333
+ const err = error as ToolError;
334
+ const message = err.message ?? 'Unknown error';
335
+ return {
336
+ content: [
337
+ {
338
+ type: 'text',
339
+ text: `Write error: ${message}`,
340
+ },
341
+ ],
342
+ details: { path: filePath, bytesWritten: 0, failed: true, error: message },
343
+ };
344
+ }
345
+ },
346
+ renderCall(args, theme) {
347
+ return renderPathToolCall('Write', args.path, theme);
348
+ },
349
+ renderResult(result, { expanded, isPartial }, theme) {
350
+ return renderResultSummary(
351
+ result,
352
+ expanded,
353
+ isPartial,
354
+ theme.fg('muted', `${numberDetail(result, 'bytesWritten')} bytes written`),
355
+ );
356
+ },
357
+ });
358
+
359
+ // ── StrReplace tool ──────────────────────────────────────────────────
360
+
361
+ registrar.registerTool({
362
+ name: 'StrReplace',
363
+ label: 'StrReplace',
364
+ description:
365
+ 'Replace all occurrences of a string in a file. The old_str must be an exact match.',
366
+
367
+ prepareArguments(args) {
368
+ const input = recordFrom(args);
369
+ if (!input) return args as StrReplaceArgs;
370
+ return {
371
+ ...input,
372
+ old_str:
373
+ stringFrom(input.old_str) ??
374
+ stringFrom(input.old_string) ??
375
+ stringFrom(input.oldText) ??
376
+ stringFrom(recordFrom(input.strReplace)?.oldText),
377
+ new_str:
378
+ stringFrom(input.new_str) ??
379
+ stringFrom(input.new_string) ??
380
+ stringFrom(input.newText) ??
381
+ stringFrom(recordFrom(input.strReplace)?.newText),
382
+ } as StrReplaceArgs;
383
+ },
384
+
385
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
386
+ const requestedPath = params.path;
387
+ const filePath = resolve(ctx.cwd, requestedPath);
388
+
389
+ try {
390
+ const safePath = await replacementPathOrNotFound(ctx.cwd, requestedPath);
391
+ if (typeof safePath !== 'string') return safePath;
392
+
393
+ const content = readFileSync(safePath, 'utf-8');
394
+ if (params.old_str === '') {
395
+ return replacementResult('StrReplace error: old_str must not be empty', safePath);
396
+ }
397
+
398
+ const count = content.split(params.old_str).length - 1;
399
+
400
+ if (count === 0) {
401
+ return replacementResult(
402
+ `String not found in ${params.path}: "${params.old_str}"`,
403
+ safePath,
404
+ );
405
+ }
406
+
407
+ const newContent = content.replaceAll(params.old_str, () => params.new_str);
408
+ writeFileSync(safePath, newContent, 'utf-8');
409
+
410
+ return {
411
+ content: [
412
+ {
413
+ type: 'text',
414
+ text: `Replaced ${count} occurrence(s) in ${params.path}`,
415
+ },
416
+ ],
417
+ details: { path: safePath, replacements: count },
418
+ };
419
+ } catch (error: unknown) {
420
+ return fileError(error, 'StrReplace', filePath, { replacements: 0 });
421
+ }
422
+ },
423
+ renderCall(args, theme) {
424
+ return renderPathToolCall('StrReplace', args.path, theme);
425
+ },
426
+ renderResult(result, { expanded, isPartial }, theme) {
427
+ return renderReplacementResult(result, expanded, isPartial, theme);
428
+ },
429
+ });
430
+
431
+ // ── Edit tool ────────────────────────────────────────────────────────
432
+
433
+ registrar.registerTool({
434
+ name: 'Edit',
435
+ label: 'Edit',
436
+ description:
437
+ 'Modify a file with exact text replacement. applyPatch is not supported by this Grok tool shim.',
438
+
439
+ prepareArguments(args) {
440
+ const input = recordFrom(args);
441
+ if (!input) return args as EditArgs;
442
+ return {
443
+ ...input,
444
+ edits: editsFromArgs(input),
445
+ } as EditArgs;
446
+ },
447
+
448
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
449
+ const filePath = resolve(ctx.cwd, params.path);
450
+
451
+ try {
452
+ const safePath = await replacementPathOrNotFound(ctx.cwd, params.path);
453
+ if (typeof safePath !== 'string') return safePath;
454
+ if (!params.edits?.length) {
455
+ return {
456
+ content: [
457
+ {
458
+ type: 'text',
459
+ text: params.applyPatch
460
+ ? 'Edit error: applyPatch is not supported by this Grok tool shim'
461
+ : 'Edit error: provide at least one exact text replacement',
462
+ },
463
+ ],
464
+ details: { path: safePath, replacements: 0 },
465
+ };
466
+ }
467
+ if (params.edits.some((edit) => edit.oldText === '')) {
468
+ return replacementResult('Edit error: oldText must not be empty', safePath);
469
+ }
470
+
471
+ const result = applyEdits(readFileSync(safePath, 'utf-8'), params.edits);
472
+
473
+ if (result.replacements === 0) {
474
+ return replacementResult(`No replacement strings found in ${params.path}`, safePath);
475
+ }
476
+
477
+ writeFileSync(safePath, result.content, 'utf-8');
478
+
479
+ return {
480
+ content: [
481
+ {
482
+ type: 'text',
483
+ text: `Applied ${result.replacements} replacement(s) in ${params.path}`,
484
+ },
485
+ ],
486
+ details: { path: safePath, replacements: result.replacements },
487
+ };
488
+ } catch (error: unknown) {
489
+ return fileError(error, 'Edit', filePath, { replacements: 0 });
490
+ }
491
+ },
492
+ renderCall(args, theme) {
493
+ return renderPathToolCall('Edit', args.path, theme);
494
+ },
495
+ renderResult(result, { expanded, isPartial }, theme) {
496
+ return renderReplacementResult(result, expanded, isPartial, theme);
497
+ },
498
+ });
499
+
500
+ // ── Delete tool ──────────────────────────────────────────────────────
501
+
502
+ registrar.registerTool({
503
+ name: 'Delete',
504
+ label: 'Delete',
505
+ description: 'Delete a file from the filesystem.',
506
+
507
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
508
+ const filePath = resolve(ctx.cwd, params.path);
509
+
510
+ try {
511
+ const safePath = await existingPathOrNotFound(ctx.cwd, params.path, { deleted: false });
512
+ if (typeof safePath !== 'string') return safePath;
513
+
514
+ unlinkSync(safePath);
515
+
516
+ return {
517
+ content: [{ type: 'text', text: `Successfully deleted ${params.path}` }],
518
+ details: { path: safePath, deleted: true },
519
+ };
520
+ } catch (error: unknown) {
521
+ return fileError(error, 'Delete', filePath, { deleted: false });
522
+ }
523
+ },
524
+ renderCall(args, theme) {
525
+ return renderPathToolCall('Delete', args.path, theme);
526
+ },
527
+ renderResult(result, { expanded, isPartial }, theme) {
528
+ return renderResultSummary(
529
+ result,
530
+ expanded,
531
+ isPartial,
532
+ booleanDetail(result, 'deleted')
533
+ ? theme.fg('muted', 'Deleted')
534
+ : theme.fg('error', 'Not deleted'),
535
+ );
536
+ },
537
+ });
538
+ }
@@ -0,0 +1,31 @@
1
+ import { registerFileTools } from './files.js';
2
+ import { registerSearchTools } from './search.js';
3
+ import { registerShellTool } from './shell.js';
4
+ import type { ToolRegistrar } from './types.js';
5
+
6
+ /** Grok/Cursor shims registered for Grok Build models. */
7
+ export const GROK_SHIM_TOOL_NAMES = [
8
+ 'Grep',
9
+ 'Glob',
10
+ 'LS',
11
+ 'Read',
12
+ 'Write',
13
+ 'StrReplace',
14
+ 'Edit',
15
+ 'Delete',
16
+ 'Shell',
17
+ ] as const;
18
+
19
+ export const GROK_TOOL_NAMES_FOR_SCOPE = [...GROK_SHIM_TOOL_NAMES] as const;
20
+
21
+ export const GROK_SUPPRESSED_TOOL_NAMES = ['web_search', 'websearch'] as const;
22
+
23
+ export function grokToolsToActivate() {
24
+ return [...GROK_SHIM_TOOL_NAMES];
25
+ }
26
+
27
+ export function registerGrokTools(registrar: ToolRegistrar) {
28
+ registerSearchTools(registrar);
29
+ registerFileTools(registrar);
30
+ registerShellTool(registrar);
31
+ }