xtrm-tools 0.7.1 → 0.7.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.
package/cli/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "xtrm": "dist/index.cjs",
9
- "xt": "dist/index.cjs"
9
+ "xt": "dist/index.cjs",
10
+ "ghgrep": "scripts/ghgrep.mjs"
10
11
  },
11
12
  "files": [
12
13
  "dist",
13
- "config"
14
+ "config",
15
+ "scripts/ghgrep.mjs"
14
16
  ],
15
17
  "scripts": {
16
18
  "prebuild": "node -e \"if(process.cwd().includes('/.xtrm/worktrees/')){console.error('ERROR: Do not run npm run build from a worktree — dist paths will be contaminated.\\nRun from the main repo: cd <repo-root>/cli && npm run build');process.exit(1)}\"",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-tools",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,13 +10,15 @@
10
10
  ],
11
11
  "bin": {
12
12
  "xtrm": "cli/dist/index.cjs",
13
- "xt": "cli/dist/index.cjs"
13
+ "xt": "cli/dist/index.cjs",
14
+ "ghgrep": "scripts/ghgrep.mjs"
14
15
  },
15
16
  "files": [
16
17
  "README.md",
17
18
  "CHANGELOG.md",
18
19
  "cli/dist",
19
20
  "cli/package.json",
21
+ "scripts/ghgrep.mjs",
20
22
  ".xtrm/config",
21
23
  ".xtrm/hooks",
22
24
  ".xtrm/extensions",
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env node
2
+
3
+ const ENDPOINT = 'https://mcp.grep.app';
4
+ const DEFAULT_LIMIT = 10;
5
+
6
+ function printUsage() {
7
+ console.log(`ghgrep <query> [options]
8
+
9
+ Options:
10
+ --lang <langs> Filter by language (comma-separated): TypeScript,TSX,Python
11
+ --repo <repo> Filter by repo: facebook/react
12
+ --path <path> Filter by file path pattern
13
+ --regexp Treat query as regex (auto-prefixes (?s) for multiline)
14
+ --case Case-sensitive match
15
+ --words Match whole words only
16
+ --json Raw JSON output
17
+ --limit <n> Max results (default: 10)
18
+ -h, --help Show help
19
+ `);
20
+ }
21
+
22
+ function parseCliArgs(argv) {
23
+ const options = {
24
+ caseSensitive: false,
25
+ json: false,
26
+ languages: [],
27
+ limit: DEFAULT_LIMIT,
28
+ path: undefined,
29
+ regexp: false,
30
+ repo: undefined,
31
+ wholeWords: false,
32
+ };
33
+
34
+ const queryParts = [];
35
+
36
+ for (let index = 0; index < argv.length; index += 1) {
37
+ const arg = argv[index];
38
+
39
+ if (arg === '-h' || arg === '--help') {
40
+ options.help = true;
41
+ continue;
42
+ }
43
+
44
+ if (arg === '--json') {
45
+ options.json = true;
46
+ continue;
47
+ }
48
+
49
+ if (arg === '--regexp') {
50
+ options.regexp = true;
51
+ continue;
52
+ }
53
+
54
+ if (arg === '--case') {
55
+ options.caseSensitive = true;
56
+ continue;
57
+ }
58
+
59
+ if (arg === '--words') {
60
+ options.wholeWords = true;
61
+ continue;
62
+ }
63
+
64
+ if (arg === '--lang' || arg === '--repo' || arg === '--path' || arg === '--limit') {
65
+ const value = argv[index + 1];
66
+ if (!value || value.startsWith('-')) {
67
+ throw new Error(`Missing value for ${arg}`);
68
+ }
69
+
70
+ if (arg === '--lang') {
71
+ options.languages = value
72
+ .split(',')
73
+ .map((item) => item.trim())
74
+ .filter(Boolean);
75
+ }
76
+
77
+ if (arg === '--repo') {
78
+ options.repo = value;
79
+ }
80
+
81
+ if (arg === '--path') {
82
+ options.path = value;
83
+ }
84
+
85
+ if (arg === '--limit') {
86
+ const parsedLimit = Number.parseInt(value, 10);
87
+ if (!Number.isFinite(parsedLimit) || parsedLimit <= 0) {
88
+ throw new Error(`Invalid --limit value: ${value}`);
89
+ }
90
+ options.limit = parsedLimit;
91
+ }
92
+
93
+ index += 1;
94
+ continue;
95
+ }
96
+
97
+ if (arg.startsWith('-')) {
98
+ throw new Error(`Unknown option: ${arg}`);
99
+ }
100
+
101
+ queryParts.push(arg);
102
+ }
103
+
104
+ return {
105
+ options,
106
+ query: queryParts.join(' ').trim(),
107
+ };
108
+ }
109
+
110
+ function parseSseChunk(chunk, state, events) {
111
+ state.buffer += chunk;
112
+ const lines = state.buffer.split(/\r?\n/);
113
+ state.buffer = lines.pop() ?? '';
114
+
115
+ for (const line of lines) {
116
+ if (line === '') {
117
+ if (state.eventData.length > 0) {
118
+ const payload = state.eventData.join('\n').trim();
119
+ if (payload && payload !== '[DONE]') {
120
+ events.push(payload);
121
+ }
122
+ }
123
+ state.eventData = [];
124
+ continue;
125
+ }
126
+
127
+ if (line.startsWith('data:')) {
128
+ state.eventData.push(line.slice(5).trimStart());
129
+ }
130
+ }
131
+ }
132
+
133
+ async function parseMcpResponse(response) {
134
+ const contentType = response.headers.get('content-type') ?? '';
135
+
136
+ if (contentType.includes('application/json')) {
137
+ return [await response.json()];
138
+ }
139
+
140
+ if (!contentType.includes('text/event-stream')) {
141
+ const body = await response.text();
142
+ throw new Error(`Unexpected response type: ${contentType || 'unknown'}\n${body}`);
143
+ }
144
+
145
+ const reader = response.body?.getReader();
146
+ if (!reader) {
147
+ throw new Error('Response stream is empty');
148
+ }
149
+
150
+ const decoder = new TextDecoder();
151
+ const events = [];
152
+ const state = { buffer: '', eventData: [] };
153
+
154
+ while (true) {
155
+ const { done, value } = await reader.read();
156
+ if (done) {
157
+ break;
158
+ }
159
+
160
+ parseSseChunk(decoder.decode(value, { stream: true }), state, events);
161
+ }
162
+
163
+ const trailingChunk = decoder.decode();
164
+ if (trailingChunk) {
165
+ parseSseChunk(trailingChunk, state, events);
166
+ }
167
+
168
+ if (state.eventData.length > 0) {
169
+ const payload = state.eventData.join('\n').trim();
170
+ if (payload && payload !== '[DONE]') {
171
+ events.push(payload);
172
+ }
173
+ }
174
+
175
+ return events.map((event) => {
176
+ try {
177
+ return JSON.parse(event);
178
+ } catch {
179
+ return { raw: event };
180
+ }
181
+ });
182
+ }
183
+
184
+ async function callMcp(method, params) {
185
+ const response = await fetch(ENDPOINT, {
186
+ method: 'POST',
187
+ headers: {
188
+ Accept: 'application/json, text/event-stream',
189
+ 'Content-Type': 'application/json',
190
+ },
191
+ body: JSON.stringify({
192
+ id: Date.now(),
193
+ jsonrpc: '2.0',
194
+ method,
195
+ params,
196
+ }),
197
+ });
198
+
199
+ const payloads = await parseMcpResponse(response);
200
+ if (!response.ok) {
201
+ throw new Error(`MCP request failed: ${response.status} ${response.statusText}`);
202
+ }
203
+
204
+ return payloads;
205
+ }
206
+
207
+ function parseSnippetSections(snippetsText) {
208
+ const sections = snippetsText.split(/--- Snippet \d+ \(Line (\d+)\) ---\n/g);
209
+ const snippets = [];
210
+
211
+ for (let index = 1; index < sections.length; index += 2) {
212
+ const line = Number.parseInt(sections[index], 10);
213
+ const code = (sections[index + 1] ?? '').trimEnd();
214
+ snippets.push({
215
+ code,
216
+ line: Number.isFinite(line) ? line : undefined,
217
+ });
218
+ }
219
+
220
+ return snippets;
221
+ }
222
+
223
+ function parseResultBlock(text) {
224
+ const match = text.match(
225
+ /^Repository:\s*(.+)\nPath:\s*(.+)\nURL:\s*(.+)\nLicense:\s*(.+)\n\nSnippets:\n([\s\S]*)$/,
226
+ );
227
+
228
+ if (!match) {
229
+ return null;
230
+ }
231
+
232
+ return {
233
+ license: match[4].trim(),
234
+ path: match[2].trim(),
235
+ repo: match[1].trim(),
236
+ snippets: parseSnippetSections(match[5]),
237
+ url: match[3].trim(),
238
+ };
239
+ }
240
+
241
+ function formatEntries(entries) {
242
+ for (let index = 0; index < entries.length; index += 1) {
243
+ const entry = entries[index];
244
+ const firstSnippetLine = entry.snippets[0]?.line;
245
+ const location = firstSnippetLine ? `${entry.repo}/${entry.path}:${firstSnippetLine}` : `${entry.repo}/${entry.path}`;
246
+
247
+ console.log(`${index + 1}. ${location}`);
248
+ console.log(` ${entry.url}`);
249
+ console.log(` License: ${entry.license}`);
250
+
251
+ for (const snippet of entry.snippets) {
252
+ const lineLabel = snippet.line ? `Line ${snippet.line}` : 'Snippet';
253
+ console.log(`\n --- ${lineLabel} ---`);
254
+ const snippetLines = snippet.code.split('\n');
255
+ for (const snippetLine of snippetLines) {
256
+ console.log(` ${snippetLine}`);
257
+ }
258
+ }
259
+
260
+ if (index < entries.length - 1) {
261
+ console.log('\n' + '-'.repeat(80) + '\n');
262
+ }
263
+ }
264
+ }
265
+
266
+ function getTextContent(resultPayload) {
267
+ const content = resultPayload?.result?.content;
268
+ if (!Array.isArray(content)) {
269
+ return [];
270
+ }
271
+
272
+ return content
273
+ .filter((item) => item?.type === 'text' && typeof item.text === 'string')
274
+ .map((item) => item.text);
275
+ }
276
+
277
+ async function run() {
278
+ const { options, query } = parseCliArgs(process.argv.slice(2));
279
+
280
+ if (options.help || !query) {
281
+ printUsage();
282
+ process.exit(options.help ? 0 : 1);
283
+ }
284
+
285
+ const requestArguments = {
286
+ query: options.regexp && !query.startsWith('(?s)') ? `(?s)${query}` : query,
287
+ };
288
+
289
+ if (options.caseSensitive) {
290
+ requestArguments.matchCase = true;
291
+ }
292
+
293
+ if (options.wholeWords) {
294
+ requestArguments.matchWholeWords = true;
295
+ }
296
+
297
+ if (options.regexp) {
298
+ requestArguments.useRegexp = true;
299
+ }
300
+
301
+ if (options.repo) {
302
+ requestArguments.repo = options.repo;
303
+ }
304
+
305
+ if (options.path) {
306
+ requestArguments.path = options.path;
307
+ }
308
+
309
+ if (options.languages.length > 0) {
310
+ requestArguments.language = options.languages;
311
+ }
312
+
313
+ const payloads = await callMcp('tools/call', {
314
+ arguments: requestArguments,
315
+ name: 'searchGitHub',
316
+ });
317
+
318
+ if (options.json) {
319
+ console.log(JSON.stringify(payloads, null, 2));
320
+ return;
321
+ }
322
+
323
+ const resultPayload = payloads.find((payload) => payload?.result || payload?.error) ?? payloads[0];
324
+
325
+ if (resultPayload?.error) {
326
+ throw new Error(resultPayload.error.message ?? JSON.stringify(resultPayload.error));
327
+ }
328
+
329
+ const textBlocks = getTextContent(resultPayload);
330
+ const isError = resultPayload?.result?.isError === true;
331
+
332
+ if (isError) {
333
+ throw new Error(textBlocks.join('\n\n') || 'searchGitHub returned an error');
334
+ }
335
+
336
+ const parsedEntries = textBlocks
337
+ .map((block) => parseResultBlock(block))
338
+ .filter((entry) => entry !== null)
339
+ .slice(0, options.limit);
340
+
341
+ if (parsedEntries.length > 0) {
342
+ formatEntries(parsedEntries);
343
+ return;
344
+ }
345
+
346
+ const plainText = textBlocks.join('\n\n').trim();
347
+ if (!plainText) {
348
+ console.log('No results.');
349
+ return;
350
+ }
351
+
352
+ console.log(plainText);
353
+ }
354
+
355
+ run().catch((error) => {
356
+ console.error(`ghgrep error: ${error.message}`);
357
+ process.exit(1);
358
+ });