-
Notifications
You must be signed in to change notification settings - Fork 33
Expand file tree
/
Copy pathwhat.mjs
More file actions
474 lines (434 loc) · 15.6 KB
/
what.mjs
File metadata and controls
474 lines (434 loc) · 15.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
#!/usr/bin/env node
/**
* @file main.mjs
* @description A command-line script that parses ZSH alias and function definitions
* from a list of shell files (supplied as an argument) using the
* `@google/genai` package, caches the results, and allows searching for them
* using fuzzy matching. Uses Yargs for argument parsing. Exports the main
* function for potential re-use.
*/
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import fs from 'node:fs/promises';
import crypto from 'node:crypto';
import process from 'node:process';
import { execSync } from 'node:child_process';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { GoogleGenAI } from '@google/genai';
import Fuse from 'fuse.js';
import yoctoSpinner from 'yocto-spinner';
// --- Configuration ---
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const GEMINI_MODEL = 'gemini-2.5-flash';
/**
* Calculates the MD5 hash of the content of multiple files.
* @param {string[]} filepaths - An array of paths to the files.
* @returns {Promise<string|null>} The combined MD5 hash of the file contents, or null if an error occurs.
*/
async function calculateCombinedFileHash(filepaths) {
const hasher = crypto.createHash('md5');
try {
for (const filepath of filepaths) {
const data = await fs.readFile(filepath);
hasher.update(data);
}
return hasher.digest('hex');
} catch (error) {
console.error('Error calculating combined file hash:', error);
return null;
}
}
/**
* Loads cached alias and function data from the cache file if the source
* shell files have not been modified since the cache was created.
* @param {string} cacheFileName - The name of the cache file
* @param {string[]} filepaths - An array of paths to the source shell files used for caching.
* @param {boolean} cacheBust - If to force the cache to be ignored
* @returns {Promise<Array<object>|null>} An array of alias and function objects if the cache is valid, otherwise null.
*/
async function loadCachedData(cacheFileName, filepaths, cacheBust) {
try {
const cacheContent = await fs.readFile(cacheFileName, 'utf-8');
const cacheData = JSON.parse(cacheContent);
const currentHash = await calculateCombinedFileHash(filepaths);
if (cacheData?.sourceHash === currentHash && !cacheBust) {
return cacheData.items || [];
}
} catch (error) {
if (error.code !== 'ENOENT') {
console.error('Error loading or parsing cache file:', error);
}
}
return null;
}
/**
* Fetches ZSH aliases and functions (and their keywords from comments)
* from a list of shell files using the `@google/genai` package and a JSON schema
* for structured output.
* @param {string[]} filepaths - An array of paths to the shell files to parse.
* @returns {Promise<Array<object>|null>} An array of alias and function objects extracted from the Gemini API, or null if an error occurs.
*/
async function fetchAndParseShellFiles(filepaths) {
let allShellContent = '';
for (const filepath of filepaths) {
try {
const shellContent = await fs.readFile(filepath, 'utf-8');
allShellContent += `\n---\nFile: ${filepath}\n${shellContent}`;
} catch (error) {
console.error(`Error reading shell file: ${filepath}`, error);
return null;
}
}
const prompt = `You are a helpful assistant that extracts ZSH aliases and shell function definitions, along with their associated keywords from preceding comments, from the following text. The text may contain content from multiple files, separated by '---'. If there are no associated keywords, please add some to the output based on your understanding of the alias.
For example, here is an alias:
\`\`\`
# check git status
alias gs="git status"
\`\`\`
When you parse this you should produce JSON that looks like this:
\`\`\`
{
name: 'gs',
type: 'alias',
keywords: 'check git status'
}
\`\`\`
And here is a function:
\`\`\`
# pick a branch that is on gerrit as a CL
function pickclbranch() {
branch_name=$(git cl status --no-branch-color --date-order | awk '/ : / {print $0}' | fzf | awk '{print $1}')
git checkout $branch_name
}
\`\`\`
When you parse this you should produce JSON that looks like this:
\`\`\`
{
name: 'pickclbranch',
type: 'function',
keywords: 'pick a branch that is on gerrit as a CL'
}
\`\`\`
Your task is to process this text and return a JSON data structure as an array of objects. Each object should represent either an alias or a function and have the following keys:
- "type": Either "alias" or "function".
- "name": The name of the alias or function.
- "keywords": A string containing the keywords from the comment(s) immediately preceding the alias or function.
Here is the shell file content:
\`\`\`
${allShellContent}
\`\`\`
Return only the JSON output`;
const jsonSchema = {
type: 'array',
items: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['alias', 'function'],
description: 'The type of item',
},
name: {
type: 'string',
description: 'The name of the alias or function',
},
keywords: {
type: 'string',
description: 'Keywords from preceding comments',
},
},
required: ['type', 'name', 'keywords'],
},
};
const key = process.env.GEMINI_API_KEY;
if (!key) {
throw new Error('No $GEMINI_API_KEY found');
}
const ai = new GoogleGenAI({ apiKey: key });
try {
const result = await ai.models.generateContent({
model: GEMINI_MODEL,
contents: prompt,
config: {
responseMimeType: 'application/json',
responseSchema: jsonSchema,
},
});
const structuredData = JSON.parse(result.text);
return structuredData;
} catch (error) {
console.error(
'Error interacting with Gemini API or parsing response:',
error,
);
console.error('Raw Response:', result.text);
return null;
}
}
/**
* Generates a deterministic JSON cache filename based on the list of input files.
* The filename will always have the prefix "what_alias_cache_" and the suffix ".json".
* The cache file will be created in the same directory as the script.
* @param {string[]} filepaths - An array of paths to the shell files.
* @returns {string} The deterministic cache filename.
*/
function generateCacheFilename(filepaths) {
const sortedPaths = [...filepaths].sort(); // Sort to ensure order doesn't matter
const combinedPaths = sortedPaths.join(',');
const hash = crypto.createHash('md5').update(combinedPaths).digest('hex');
const cachePrefix = 'what_alias_cache_';
const cacheSuffix = '.json';
let baseDir;
try {
const __filename = fileURLToPath(import.meta.url);
baseDir = path.dirname(__filename);
} catch (error) {
// Handle the case where import.meta.url is not available (e.g., in CommonJS)
baseDir = process.cwd(); // Default to current working directory
console.warn(
'Could not determine script directory, using current working directory for cache file.',
);
}
return path.join(baseDir, `${cachePrefix}${hash}${cacheSuffix}`);
}
/**
* Saves the extracted alias and function data to the cache file, along with the
* combined hash of the source shell files and a timestamp.
* @param {string} cacheFileName - The name of the cache file
* @param {string[]} filepaths - An array of paths to the source shell files used for caching.
* @param {Array<object>} items - An array of alias and function objects to cache.
* @returns {Promise<void>}
*/
async function cacheParsedData(cacheFileName, filepaths, items) {
const sourceHash = await calculateCombinedFileHash(filepaths);
const cacheData = {
sourceHash,
items,
timestamp: Date.now(),
};
try {
await fs.writeFile(
cacheFileName,
JSON.stringify(cacheData, null, 2),
'utf-8',
);
console.log('Alias and function data cached successfully.');
} catch (error) {
console.error('Error saving cache data:', error);
}
}
/**
* Finds the closest matching aliases or functions to a search string using fuzzy matching.
* @param {Array<object>} itemsData - An array of alias and function objects to search within.
* @param {string} searchString - The string to search for.
* @param {number} [maxResults=3] - The maximum number of closest matches to return.
* @returns {Array<object>} An array of the top matching alias or function objects.
*/
function findClosestMatches(itemsData, searchString, maxResults = 10) {
const options = {
keys: ['name', 'keywords'],
findAllMatches: true,
shouldSort: true,
includeScore: true, // remember: a lower score is better!
// threshold: 0.6, // Adjust as needed
};
const fuse = new Fuse(itemsData, options);
const results = fuse.search(searchString);
return results.slice(0, maxResults).map((result) => result.item);
}
/**
* Recursively finds all files with a specific extension within a directory,
* optionally filtering them by a provided function.
*
* @async
* @param {string} dirPath - The absolute or relative path to the directory to search.
* @param {string} extension - The desired file extension (e.g., '.txt', 'js', '.json').
* The leading dot is optional and will be added if missing.
* @param {(filePath: string) => boolean} [filterFn=(filePath) => true] - An optional
* function that takes the full file path as an argument. If it returns `false`,
* the file will be excluded from the results. Defaults to including all found files.
* @returns {Promise<string[]>} A promise that resolves with an array of full file paths
* matching the extension and filter. Returns an empty array if the
* directory doesn't exist or is inaccessible.
* @throws {Error} Throws errors for issues other than directory not found or access denied.
*/
async function findFilesByExtension(
dirPath,
extension,
filterFn = (_filePath) => true, // Default filter includes everything
) {
// 1. Normalize the extension
const normalizedExtension = extension.startsWith('.')
? extension
: `.${extension}`;
let filesFound = [];
try {
// 2. Read the directory contents
const entries = await fs.readdir(dirPath, { withFileTypes: true });
// 3. Iterate through each entry
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
// 4. If it's a directory, recurse, passing the filter function
const subDirFiles = await findFilesByExtension(
fullPath,
normalizedExtension,
filterFn, // Pass the filter down
);
filesFound = filesFound.concat(subDirFiles);
} else if (entry.isFile()) {
// 5. If it's a file, check extension and apply filter
if (path.extname(fullPath) === normalizedExtension) {
if (filterFn(fullPath)) {
// Apply the filter function
filesFound.push(fullPath);
}
}
}
// Ignore other types like symbolic links
}
} catch (err) {
// 6. Handle potential errors
if (
err.code === 'ENOENT' ||
err.code === 'EACCES' ||
err.code === 'ENOTDIR'
) {
console.warn(
`Warning: Could not read directory ${dirPath}: ${err.message}`,
);
return []; // Return empty array for common issues
} else {
console.error(`Error processing directory ${dirPath}: ${err.message}`);
throw err; // Re-throw unexpected errors
}
}
// 7. Return the list of found and filtered files
return filesFound;
}
/**
* Detects the shell type ('fish' or 'zsh') based on the file paths.
* @param {string[]} filepaths - An array of paths to the shell files.
* @returns {('fish'|'zsh'|null)} The detected shell type, or null if it cannot be determined.
*/
function detectShellFromFiles(filepaths) {
if (filepaths.some(p => p.includes('fish'))) {
return 'fish';
}
if (filepaths.some(p => p.includes('zsh'))) {
return 'zsh';
}
return null;
}
/**
* The main function of the script, which uses Yargs to parse command-line arguments,
* loads or fetches alias and function data from the provided shell files,
* and performs the search. This function is exported for potential re-use.
* @param {string[]} filesToProcess - An array of paths to the shell files to process.
* @returns {Promise<void>}
*/
export async function main(filesToProcess) {
const argv = yargs(hideBin(process.argv))
.usage('Usage: $0 [searchTerm] [--files <path1>,<path2>,...]')
.positional('searchTerm', {
describe: 'The string to search for in aliases or functions',
type: 'string',
required: true,
})
.option('cacheBust', {
describe: 'Force the cache to be cleared',
type: 'boolean',
default: false,
})
.help()
.alias('h', 'help').argv;
const searchTerm = argv._.join(' ');
if (!filesToProcess || filesToProcess.length === 0) {
console.error('Error: Please provide a list of shell files to process.');
process.exit(1);
}
const cacheFileName = generateCacheFilename(filesToProcess);
let allItemsData = await loadCachedData(
cacheFileName,
filesToProcess,
argv.cacheBust,
);
if (!allItemsData) {
console.log(
'Fetching alias and function data from Gemini API using @google/genai...',
);
const fetchedItems = await fetchAndParseShellFiles(filesToProcess);
if (fetchedItems) {
await cacheParsedData(cacheFileName, filesToProcess, fetchedItems);
allItemsData = fetchedItems;
} else {
console.error('Failed to retrieve alias and function data.');
process.exit(1);
}
}
if (searchTerm) {
const closestMatches = findClosestMatches(allItemsData, searchTerm);
if (closestMatches.length > 0) {
const spinner = yoctoSpinner({
text: 'Looking up commands...',
color: 'magenta',
});
spinner.start();
const outputLines = [];
outputLines.push(`\nTop ${closestMatches.length} matches for '${searchTerm}':`);
const shellType = detectShellFromFiles(filesToProcess);
closestMatches.forEach((match) => {
outputLines.push(` Type: ${match.type}`);
outputLines.push(` Name: ${match.name}`);
try {
let command;
if (shellType === 'fish') {
command = `fish -i -c "type ${match.name}"`;
} else if (shellType === 'zsh') {
command = `zsh -i -c "which ${match.name}"`;
} else {
command = `which ${match.name}`;
}
const whichOutput = execSync(command, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
outputLines.push(` Info: ${whichOutput}`);
} catch (error) {
// Ignore errors if 'which' or 'type' command fails
}
outputLines.push('-'.repeat(40));
});
spinner.stop();
console.log(outputLines.join('\n'));
} else {
console.log(`No matches found for '${searchTerm}'.`);
}
} else {
console.log('No search term provided.');
}
}
// Default execution when the script is run directly
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const fishAliases = path.join(__dirname, '..', 'fish', 'config.fish');
const fishFunctionsDir = path.join(__dirname, '..', 'fish', 'functions');
const fishFunctionsFiles = await findFilesByExtension(
fishFunctionsDir,
'.fish',
(file) => {
const fileName = path.basename(file);
if (fileName.startsWith('__')) {
return false;
}
return true;
},
);
const defaultShellFiles = [
fishAliases,
...fishFunctionsFiles,
// Add more default file paths here if needed for direct execution
];
main(defaultShellFiles).catch(console.error);
}