Skip to main content
Glama
path-scanner.ts14.4 kB
/** * Tool Scanner Module * * Scans workspace to discover available command-line tools. * Works in conjunction with package-scanner.ts to provide comprehensive tool discovery. */ import { readdir, readFile } from 'fs/promises'; import { join, dirname } from 'path'; import { env } from '@/env'; import { getWorkspacePath } from '@/utils/workspace'; import { readFileSync } from 'fs'; import { type ToolInfo } from '@/types/index'; import { promisify } from 'util'; import { execFile as execFileCb } from 'child_process'; import * as fs from 'fs/promises'; import { logTools, logDebug } from '@/utils/logging'; const execFile = promisify(execFileCb); // Cache of scanned tools per workspace const toolCache = new Map<string, { tools: Map<string, ToolInfo>, timestamp: number }>(); const CACHE_TTL = 5 * 60 * 1000; // 5 minutes // Helper function for logging objects function logObject(prefix: string, obj: unknown) { logTools(`${prefix}: ${JSON.stringify(obj, null, 2)}`); } export interface ToolScannerOptions { types?: string[]; categories?: string[]; debug?: boolean; } interface PackageJson { name?: string; scripts?: Record<string, string>; bin?: Record<string, string> | string; workspaces?: string[] | { packages: string[] }; } /** * Checks if a file is executable based on its extension and platform * @param filename Name of the file to check * @returns boolean */ function isExecutable(filename: string): boolean { logDebug('Tools', `Checking if file is executable: ${filename} (${process.platform})`); if (process.platform === 'win32') { const result = env.executableExtensions.some(ext => filename.toUpperCase().endsWith(ext)); logDebug('Tools', `Windows executable check: ${filename} -> ${result}`); return result; } logDebug('Tools', 'Unix executable check - assuming true'); return true; // On Unix, we trust the file permissions (handled by readdir) } /** * Scans PATH for globally available tools */ async function scanGlobalBinaries( tools: Map<string, ToolInfo>, workspacePath: string ): Promise<void> { const globalTools = ['git', 'node', 'npm', 'yarn', 'pnpm']; logTools(`Scanning for global tools: ${globalTools.join(', ')}`); // First check if any of these tools exist in the workspace bin directory const binPath = join(workspacePath, 'bin'); try { const files = await readdir(binPath, { withFileTypes: true }); for (const file of files) { if ((file.isFile() || file.isSymbolicLink()) && isExecutable(file.name)) { const name = process.platform === 'win32' ? file.name.replace(/\.[^/.]+$/, '') : file.name; if (globalTools.includes(name)) { tools.set(name, { name, location: join(binPath, file.name), workingDirectory: workspacePath, type: 'workspace-bin', context: {} }); logTools(`Found global tool in workspace: ${name}`); } } } } catch (error) { logTools(`Error scanning workspace bin for global tools: ${error}`, 'warn'); } // Then add any remaining tools as global for (const tool of globalTools) { // Don't override tools already found in workspace if (!tools.has(tool)) { tools.set(tool, { name: tool, type: 'global-bin', workingDirectory: getWorkspacePath(), context: {} }); logTools(`Added global tool: ${tool}`); } } } /** * Scans the workspace for available command-line tools * @param workspacePath The workspace path to scan * @returns Promise<Map<string, ToolInfo>> Map of tool names to their info */ export async function scanWorkspaceTools( workspacePath: string ): Promise<Map<string, ToolInfo>> { logTools(`Scanning workspace for tools: ${workspacePath}`); // Check cache first const now = Date.now(); const cached = toolCache.get(workspacePath); if (cached && (now - cached.timestamp < CACHE_TTL)) { const toolCount = cached.tools.size; logTools('Using cached tools', { toolCount }); return cached.tools; } const tools = new Map<string, ToolInfo>(); try { logTools('Scanning package.json...'); await scanPackageJson(workspacePath, tools); logTools('Package.json scan complete', { toolCount: tools.size }); logTools('Scanning node_modules/.bin...'); await scanNodeModulesBin(workspacePath, tools); logTools('Node modules scan complete', { toolCount: tools.size }); logTools('Scanning workspace bin directory...'); await scanWorkspaceBin(workspacePath, tools); logTools('Workspace bin scan complete', { toolCount: tools.size }); logTools('Scanning for global tools...'); await scanGlobalBinaries(tools, workspacePath); logTools('Global tools scan complete', { toolCount: tools.size }); // Update cache toolCache.set(workspacePath, { tools, timestamp: now }); logTools('Updated tool cache', { totalTools: tools.size }); return tools; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logTools('Error scanning workspace for tools', { error: errorMessage }); return new Map(); } } /** * Scans package.json for scripts and workspaces */ async function scanPackageJson( workspacePath: string, tools: Map<string, ToolInfo> ): Promise<void> { try { const pkgPath = join(workspacePath, 'package.json'); const pkgContent = await readFile(pkgPath, 'utf-8'); const pkg = JSON.parse(pkgContent) as PackageJson; // Add npm scripts if (pkg.scripts) { for (const [name, script] of Object.entries(pkg.scripts)) { tools.set(`npm:${name}`, { name: `npm:${name}`, location: pkgPath, workingDirectory: workspacePath, type: 'npm-script', context: { scriptSource: pkgPath, script } }); } } // Scan workspaces if present if (pkg.workspaces) { const patterns = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages; for (const pattern of patterns) { // Note: This is a simplified glob pattern handling // In reality, you'd want to use a proper glob matcher const workspacePkgPath = join(workspacePath, pattern.replace('/*', ''), 'package.json'); try { await scanPackageJson(dirname(workspacePkgPath), tools); } catch (error) { logTools(`Error scanning workspace package: ${workspacePkgPath}`, 'warn'); } } } } catch (error) { logTools('No package.json found or error scanning:', error); } } /** * Scans node_modules/.bin for package tools */ async function scanNodeModulesBin( workspacePath: string, tools: Map<string, ToolInfo> ): Promise<void> { const binPath = join(workspacePath, 'node_modules', '.bin'); try { const files = await readdir(binPath, { withFileTypes: true }); for (const file of files) { // Handle both regular files and symlinks if ((file.isFile() || file.isSymbolicLink()) && isExecutable(file.name)) { const name = process.platform === 'win32' ? file.name.replace(/\.[^/.]+$/, '') : file.name; tools.set(name, { name, location: join('node_modules', '.bin', file.name), // Important: Tools in node_modules/.bin should run from workspace root workingDirectory: workspacePath, type: 'package-bin', context: {} }); } } } catch (error) { logTools('No node_modules/.bin found or error scanning:', error); } } /** * Scans workspace bin directory for local tools */ async function scanWorkspaceBin( workspacePath: string, tools: Map<string, ToolInfo> ): Promise<void> { const binPath = join(workspacePath, 'bin'); logTools('Scanning workspace bin directory:'); try { const files = await readdir(binPath, { withFileTypes: true }); logTools('Found files in bin directory:'); logObject('Found files in bin directory', files.map(f => ({ name: f.name, type: f.isFile() ? 'file' : f.isSymbolicLink() ? 'symlink' : 'other', isExecutable: isExecutable(f.name) }))); for (const file of files) { // Handle both regular files and symlinks if ((file.isFile() || file.isSymbolicLink()) && isExecutable(file.name)) { const name = process.platform === 'win32' ? file.name.replace(/\.[^/.]+$/, '') : file.name; const location = join(binPath, file.name); logTools(`Found workspace binary: ${name}`, { name, location, isFile: file.isFile(), isSymlink: file.isSymbolicLink(), isExecutable: isExecutable(file.name) }); // Don't override tools already found in node_modules/.bin if (!tools.has(name)) { tools.set(name, { name, location, // Important: Local tools in bin/ should run from workspace root workingDirectory: workspacePath, type: 'workspace-bin', context: {} }); logTools(`Added workspace binary to tools:`, tools.get(name)); } else { logTools(`Skipping workspace binary (already exists):`, name); } } else { logTools(`Skipping non-executable or non-file:`, { name: file.name, isFile: file.isFile(), isSymlink: file.isSymbolicLink(), isExecutable: isExecutable(file.name) }); } } } catch (error) { logTools('Error scanning workspace bin directory:', { error, binPath }); } } /** * Gets info about a specific tool * @param workspacePath The workspace path to scan * @param toolName Name of the tool to check * @returns Promise<ToolInfo | undefined> */ export async function getToolInfo( workspacePath: string, toolName: string ): Promise<ToolInfo | undefined> { const tools = await scanWorkspaceTools(workspacePath); return tools.get(toolName); } /** * Get all available tools in a workspace * @param workspacePath Path to workspace root * @param options Optional scanner configuration * @returns Array of tool info objects */ export async function getAvailableTools( workspacePath: string, options: ToolScannerOptions = {} ): Promise<ToolInfo[]> { logTools(`Starting tool discovery in workspace: ${workspacePath}`); const tools: ToolInfo[] = []; const { types = [] } = options; try { // Only scan if no type filter or 'npm-script' is included if (!types.length || types.includes('npm-script')) { logTools('Scanning for npm scripts...'); const packageJsonPath = join(workspacePath, 'package.json'); try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); const scripts = packageJson.scripts || {}; for (const [name, script] of Object.entries(scripts)) { tools.push({ name: `npm:${name}`, type: 'npm-script', workingDirectory: workspacePath, context: { script } }); } logTools(`Found ${Object.keys(scripts).length} npm scripts`); } catch (error) { logTools('No package.json found or invalid format'); } } // Only scan if no type filter or 'package-bin' is included if (!types.length || types.includes('package-bin')) { logTools('Scanning node_modules/.bin directory...'); const nodeModulesBinPath = join(workspacePath, 'node_modules', '.bin'); try { const binFiles = await readdir(nodeModulesBinPath, { withFileTypes: true }); let executableCount = 0; for (const file of binFiles) { const fullPath = join(nodeModulesBinPath, file.name); if (file.isFile()) { const stats = await fs.stat(fullPath); if (stats.mode & 0o111) { // Check if executable tools.push({ name: file.name, type: 'package-bin', location: fullPath, workingDirectory: workspacePath }); executableCount++; } } } logTools(`Found ${executableCount} package binaries`); } catch (error) { logTools('No node_modules/.bin directory found'); } } // Only scan if no type filter or 'global-bin' is included if (!types.length || types.includes('global-bin')) { const commonTools = ['git', 'node', 'npm', 'yarn', 'pnpm']; logTools('Scanning for global tools:', commonTools); let globalToolCount = 0; for (const tool of commonTools) { try { const result = await execFile('which', [tool]); if (result.stdout) { tools.push({ name: tool, type: 'global-bin', workingDirectory: workspacePath }); globalToolCount++; logTools(`Found global tool: ${tool}`); } } catch (error) { // Tool not found, skip } } logTools(`Found ${globalToolCount} global tools`); } logObject('Tool discovery completed', { total: tools.length, byType: tools.reduce((acc, tool) => { acc[tool.type] = (acc[tool.type] || 0) + 1; return acc; }, {} as Record<string, number>) }); return tools; } catch (error) { logTools('Error scanning workspace:', error); throw error; } } /** * Clears the tool cache for a specific workspace * @param workspacePath The workspace path to clear cache for */ export function clearToolCache(workspacePath: string): void { toolCache.delete(workspacePath); } /** * Check if a binary is available in the workspace * @param binaryName Name of binary to check * @param projectPath Optional project path, defaults to workspace path * @returns Promise<boolean> */ export async function isBinaryAvailable(binaryName: string, projectPath?: string): Promise<boolean> { try { const tools = await scanWorkspaceTools(projectPath || getWorkspacePath()); return tools.has(binaryName); } catch (error) { logTools(`Error checking binary availability: ${error}`); return false; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/patelnav/my-tools-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server