UNPKG

@monitoro/herd

Version:

Automate your browser, build AI web tools and MCP servers with Monitoro Herd

1,015 lines (1,014 loc) โ€ข 44 kB
#!/usr/bin/env -S node --no-warnings // Suppress Node.js deprecation warnings import * as process from 'process'; // `herd mcp dev` should start the file in package.main in dev mode using tsx with hot reloading // it should also start the mcp inspector and print the url `http://localhost:INSPECTOR_PORT/?serverUrl=encodeURIComponent(http://localhost:MCP_PORT/MCP_PATH)` // importantly, when the project files change, both the tsx and the inspector should be restarted. import { initConfig, getHerdClient } from './config.js'; initConfig(); import { Command } from 'commander'; import { spawn } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as chokidar from 'chokidar'; import { HerdClient } from '../src/lib/HerdClient.js'; import { buildTrail } from '../src/lib/trails/build.js'; import promptly from 'promptly'; import { execute as executeTrailInit } from '../src/lib/trails/init.js'; import packageJson from '../package.json' with { type: 'json' }; import { homedir } from 'os'; import { isRemoteTrailIdentifier, parseTrailIdentifier } from '../src/lib/TrailEngine.js'; const { version } = packageJson; const prompt = promptly.prompt; const program = new Command(); // check node version and if it is not >=23.1.0, print a warning and exit if (process.version < '23.1.0') { console.warn('โš ๏ธ Warning: Herd requires Node.js version >=23.1.0, you are running ' + process.version); } program .name('herd') .description('๐Ÿฎ Herd CLI - Build, test and run automations, trails and MCP servers with ease') .version(version); program .command('devices') .description('๐Ÿ“ฑ List available Herd devices') .action(async () => { const herdClient = getHerdClient(); console.log('\n ๐Ÿ“ก Listing your devices...\n'); const devices = await herdClient.listDevices(); // Create a table with device information const table = devices.map(device => ({ Name: device.name, ID: device.deviceId, Type: device.type, Status: device.status === 'online' ? '๐ŸŸข Online' : '๐Ÿ”ด Offline', 'Last Active': device.lastActive ? new Date(device.lastActive).toLocaleString() : 'Never' })); // Print the table console.log('\n๐Ÿ“Š Your Herd Devices\n'); console.table(table); // Calculate statistics const total = devices.length; const online = devices.filter(d => d.status === 'online').length; const offline = total - online; // Print summary with appropriate emoji based on status const statusEmoji = total === 0 ? '๐Ÿ˜ข' : online === total ? 'โœจ' : offline === total ? 'โš ๏ธ' : '๐Ÿ“Š'; console.log(`\n${statusEmoji} You have ${total} device(s) in your herd. ${online} are online and ${offline} are offline.\n`); }); // Trail commands const trailCommand = program .command('trail') .description('๐Ÿ›ฃ๏ธ Create and manage Herd trails'); trailCommand .command('init') .description('โœจ Initialize a new trail') .action(async () => { await executeTrailInit(); }); trailCommand .command('build') .description('๐Ÿ”จ Build a trail for production') .option('-w, --watch', '๐Ÿ‘€ Watch for changes and rebuild automatically') .action(async (options) => { let trailPath = process.cwd(); // are we inside a trail directory? if (!fs.existsSync(path.join(trailPath, "actions.ts")) || !fs.existsSync(path.join(trailPath, "urls.ts")) || !fs.existsSync(path.join(trailPath, "selectors.ts"))) { console.error('โŒ Error: No trail directory found in the current directory'); process.exit(1); } try { if (options.watch) { console.log('\n๐Ÿš€ Building trail in watch mode...\n'); const outDir = await buildTrail(trailPath); console.log(`\nโœจ Trail built successfully in ${outDir}\n`); // Watch for changes const watcher = chokidar.watch([ path.join(trailPath, '**/*.ts'), path.join(trailPath, '**/*.js') ], { ignored: [ '**/node_modules/**', '**/.build/**', '**/dist/**' ], persistent: true }); watcher.on('change', async (changedPath) => { console.log(`\n๐Ÿ”„ File ${path.relative(trailPath, changedPath)} has changed. Rebuilding...\n`); try { await buildTrail(trailPath); console.log('โœจ Rebuild successful\n'); } catch (error) { console.error('โŒ Rebuild failed:', error); } }); // Handle process termination const cleanup = () => { watcher.close(); process.exit(0); }; process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); } else { console.log('\n๐Ÿš€ Building trail...\n'); const outDir = await buildTrail(trailPath); console.log(`\nโœจ Trail built successfully in ${outDir}\n`); } } catch (error) { console.error('โŒ Error building trail:', error); process.exit(1); } process.exit(0); }); trailCommand .command('test') .description('๐Ÿงช Test trail actions and selectors') .argument('<trail>', '๐Ÿ“‚ Test a trail by name or path (defaults to current directory)') .option('-a, --action <action>', '๐ŸŽฏ Test a specific action') .option('-s, --selector <selector>', '๐ŸŽฏ Test a specific selector') .option('-w, --watch', '๐Ÿ‘€ Watch for changes and retest automatically') .option('--silent', '๐Ÿคซ Only output the result of running the tests') .action(async (trailNameOrPath, options) => { const herdClient = getHerdClient(); const silent = options.silent || false; let trailPath = process.cwd(); if (trailNameOrPath) { trailPath = trailNameOrPath; } try { // Initialize the trail engine with appropriate options await herdClient.initialize(); const trailEngine = herdClient.trails({ autoBuild: true, silent }); // For testing, we still need access to the original test function // We'll delegate to it with the loaded trail const test = await import('../src/lib/trails/test.js'); const executeTrailTest = test.execute; if (options.watch && !isRemoteTrailIdentifier(trailPath)) { // Watch mode only makes sense for local trails console.log('\n๐Ÿš€ Testing trail in watch mode...\n'); // Load the trail first (this will also build it if needed) const { trailPath: resolvedPath } = await trailEngine.loadTrail(trailPath); // Execute tests await executeTrailTest(herdClient, resolvedPath, { actionName: options.action, selectorId: options.selector }); // Watch for changes const watcher = chokidar.watch([ path.join(resolvedPath, '**/*.ts'), path.join(resolvedPath, '**/*.js') ], { ignored: [ '**/node_modules/**', '**/.build/**', '**/dist/**' ], persistent: true }); watcher.on('change', async (changedPath) => { console.log(`\n๐Ÿ”„ File ${path.relative(resolvedPath, changedPath)} has changed. Retesting...\n`); try { // Load again to ensure fresh build const { trailPath: refreshedPath } = await trailEngine.loadTrail(trailPath); // Execute tests await executeTrailTest(herdClient, refreshedPath, { actionName: options.action, selectorId: options.selector }); } catch (error) { console.error('โŒ Test failed:', error); } }); // Handle process termination const cleanup = () => { watcher.close(); process.exit(0); }; if (process.on) { process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); } } else { console.log('\n๐Ÿš€ Running tests...\n'); // Load the trail first (this will also build it if needed) const { trailPath: resolvedPath } = await trailEngine.loadTrail(trailPath); // Execute tests await executeTrailTest(herdClient, resolvedPath, { actionName: options.action, selectorId: options.selector }); console.log('\nโœจ Tests completed successfully\n'); } } catch (error) { console.error('โŒ Error testing trail:', error); process.exit(1); } }); trailCommand .command('run') .description('โ–ถ๏ธ Run a trail action') .argument('<trail>', '๐Ÿ“‚ Run a trail by name or path (defaults to current directory) - supports versioning with @version') .option('-a, --action <action>', '๐ŸŽฏ Run a specific action') .option('-p, --params <params>', 'โš™๏ธ Parameters to pass to the action (JSON string)') .option('-w, --watch', '๐Ÿ‘€ Watch for changes and rerun automatically') .option('-j, --json', '๐Ÿคซ Only output the result of running the action as JSON') .action(async (trailNameOrPath, options) => { const herdClient = getHerdClient(); const silent = options.json || false; let trailPath = process.cwd(); if (trailNameOrPath) { trailPath = trailNameOrPath; } try { // Initialize the trail engine with appropriate options await herdClient.initialize(); const trailEngine = herdClient.trails({ autoBuild: true, silent }); if (options.watch && !isRemoteTrailIdentifier(trailPath)) { // Watch mode only makes sense for local trails if (!silent) { console.log('๐Ÿš€ Running trail in watch mode...\n'); } // Run initially const result = await trailEngine.loadTrail(trailPath).then(loaded => { return trailEngine.runTrail(trailPath, { actionName: options.action, params: options.params ? JSON.parse(options.params) : {}, silent, actions: loaded.actions, resources: loaded.resources }); }); if (silent) { console.log(JSON.stringify(result, null, 2)); } // Watch for changes const watcher = chokidar.watch([ path.join(trailPath, '**/*.ts'), path.join(trailPath, '**/*.js') ], { ignored: [ '**/node_modules/**', '**/.build/**', '**/dist/**' ], persistent: true }); watcher.on('change', async (changedPath) => { if (!silent) { console.log(`\n๐Ÿ”„ File ${path.relative(trailPath, changedPath)} has changed. Rebuilding and rerunning...\n`); } try { const result = await trailEngine.loadTrail(trailPath).then(loaded => { return trailEngine.runTrail(trailPath, { actionName: options.action, params: options.params ? JSON.parse(options.params) : {}, silent, actions: loaded.actions, resources: loaded.resources }); }); if (silent) { console.log(JSON.stringify(result, null, 2)); } } catch (error) { if (!silent) { console.error('โŒ Run failed:', error); } } }); // Handle process termination const cleanup = () => { watcher.close(); process.exit(0); }; if (typeof process.on === 'function') { process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); } } else { // Single run if (!silent) { console.log(`๐Ÿš€ Running trail...\n`); } const { actions, resources } = await trailEngine.loadTrail(trailPath); const result = await trailEngine.runTrail(trailPath, { actionName: options.action, params: options.params ? JSON.parse(options.params) : {}, silent, actions, resources }); if (silent) { console.log(JSON.stringify(result, null, 2)); } process.exit(0); } } catch (error) { if (!silent) { console.error('โŒ Error running trail:', error); } process.exit(1); } }); trailCommand .command('publish') .description('๐Ÿ“ฆ Publish a trail to the registry') .option('-p, --path <path>', '๐Ÿ“‚ Path to the trail directory (defaults to current directory)') .option('-o, --organization <tag>', '๐Ÿข Organization tag to publish under') .option('--public', '๐ŸŒ Make the trail public (default: true)', true) .option('--private', '๐Ÿ”’ Make the trail private') .option('-s, --silent', '๐Ÿคซ Only output the result of publishing') .action(async (options) => { const herdClient = getHerdClient(); let silent = options.silent || false; let trailPath = options.path || process.cwd(); try { // Initialize the trail engine with appropriate options await herdClient.initialize(); const trailEngine = herdClient.trails({ autoBuild: true, silent }); // Override public setting if --private was specified const isPublic = options.private ? false : options.public; // Publish the trail const result = await trailEngine.publishTrail(trailPath, options.organization, { isPublic, silent }); if (silent) { console.log(JSON.stringify(result, null, 2)); } else { console.log('\nโœจ Trail published successfully!\n'); console.log(`๐Ÿ“ฆ Name: ${result.name}`); console.log(`๐Ÿ“‹ Version: ${result.version}`); console.log(`๐Ÿ“… Published: ${new Date(result.publishedAt).toLocaleString()}`); } } catch (error) { if (!silent) { console.error('โŒ Error publishing trail:', error); } process.exit(1); } process.exit(0); }); trailCommand .command('search') .description('๐Ÿ” Search for trails in the registry') .option('-q, --query <query>', '๐Ÿ”Ž Search query (searches in trail names and descriptions)') .option('-t, --tags <tags>', '๐Ÿท๏ธ Filter by comma-separated tags') .option('-o, --org <organization>', '๐Ÿข Filter by organization tag') .option('-w, --website <domain>', '๐ŸŒ Filter by website domain/origin') .option('-s, --sort <sort>', '๐Ÿ“Š Sort results by: name, description, createdAt, updatedAt, downloads', 'updatedAt') .option('--asc', 'โฌ†๏ธ Sort in ascending order') .option('-l, --limit <limit>', '๐Ÿ“ Maximum number of results to return', '20') .option('--json', '๐Ÿ“„ Output results as JSON') .action(async (options) => { const herdClient = getHerdClient(); try { await herdClient.initialize(); if (!options.json) { console.log('\n๐Ÿ” Searching the trail registry...\n'); } // Build query params const queryParams = new URLSearchParams(); if (options.query) { queryParams.append('query', options.query); } if (options.tags) { const tags = options.tags.split(',').map((tag) => tag.trim()); tags.forEach((tag) => queryParams.append('tags', tag)); } if (options.org) { queryParams.append('organization', options.org); } if (options.website) { queryParams.append('origin', options.website); } queryParams.append('sort', options.sort); queryParams.append('order', options.asc ? 'asc' : 'desc'); queryParams.append('limit', options.limit); // Make request to registry search endpoint const response = await herdClient.request(`/api/registry/search?${queryParams.toString()}`); if (!response.ok) { throw new Error(`Failed to search trails: ${response.statusText}`); } const searchResults = await response.json(); if (options.json) { // Output raw JSON console.log(JSON.stringify(searchResults, null, 2)); return; } const { total, results } = searchResults; if (results.length === 0) { console.log('๐Ÿ˜• No trails found matching your search criteria.'); return; } console.log(`โœจ Found ${total} trail${total !== 1 ? 's' : ''}:`); console.log(''); // Format results in a readable way results.forEach((trail, index) => { const organizationTag = trail.organization.tag; console.log(`${index + 1}. ${trail.name} (@${organizationTag}/${trail.name}) - v${trail.latestVersion}`); console.log(` ${trail.description.slice(0, 80)}${trail.description.length > 80 ? '...' : ''}`); if (trail.tags && trail.tags.length > 0) { console.log(` Tags: ${trail.tags.join(', ')}`); } if (trail.origins && trail.origins.length > 0) { console.log(` Websites: ${trail.origins.join(', ')}`); } console.log(` ๐Ÿ“ฅ ${trail.downloadCount} downloads โ€ข ๐Ÿ—“๏ธ Updated: ${new Date(trail.updatedAt).toLocaleDateString()}`); console.log(''); }); console.log(`โ„น๏ธ Showing ${results.length} of ${total} trails. Use --limit to see more.`); console.log('โ„น๏ธ To use a trail, run: herd trail run @organization/trail-name'); console.log(''); } catch (error) { console.error('โŒ Error searching trails:', error); process.exit(1); } process.exit(0); }); trailCommand .command('info') .description('๐Ÿ“‹ Get detailed information about a trail') .argument('<trail>', '๐Ÿ›ฃ๏ธ Trail name in format @organization/trail-name') .option('--json', '๐Ÿ“„ Output results as JSON') .action(async (trailName, options) => { const herdClient = getHerdClient(); try { await herdClient.initialize(); if (!trailName.includes('/')) { console.error('โŒ Error: Trail name must be in format "@organization/trail-name" or "organization/trail-name"'); process.exit(1); } const { version, org, trail } = parseTrailIdentifier(trailName); if (!org || !trail) { console.error('โŒ Error: Invalid trail name format. Use "organization/trail-name"'); process.exit(1); } if (!options.json) { console.log(`\n๐Ÿ” Getting information for trail: @${org}/${trail}\n`); } // Fetch trail metadata const response = await herdClient.request(`/api/registry/trail/${org}/${trail}`); if (!response.ok) { if (response.status === 404) { console.error(`โŒ Trail not found: @${org}/${trail}`); } else { console.error(`โŒ Failed to get trail information: ${response.statusText}`); } process.exit(1); } const trailInfo = await response.json(); if (options.json) { // Output raw JSON console.log(JSON.stringify(trailInfo, null, 2)); return; } // Format and display trail information console.log(`๐Ÿ“ฆ Trail: ${trailInfo.name}`); console.log(`๐Ÿข Organization: @${trailInfo.organization.tag} (${trailInfo.organization.name})`); console.log(`๐Ÿ“ Description: ${trailInfo.description}`); console.log(`๐Ÿ“‹ Version: ${version}`); if (trailInfo.tags && trailInfo.tags.length > 0) { console.log(`๐Ÿท๏ธ Tags: ${trailInfo.tags.join(', ')}`); } console.log(`๐Ÿ“Š Latest Version: ${trailInfo.latestVersion || 'None'}`); if (trailInfo.versions && trailInfo.versions.length > 0) { console.log(`\n๐Ÿ—‚๏ธ Latest Versions:`); trailInfo.versions.slice(0, 5).forEach((version) => { console.log(` - ${version}`); }); if (trailInfo.versions.length > 10) { console.log(` ... and ${trailInfo.versions.length - 10} more`); } } console.log(`\n๐Ÿ“… Created: ${new Date(trailInfo.createdAt).toLocaleString()}`); console.log(`๐Ÿ“… Last Updated: ${new Date(trailInfo.updatedAt).toLocaleString()}`); // Instructions for using the trail console.log(`\n๐Ÿš€ To use this trail, run:\n`); console.log(` herd trail run @${org}/${trail}${version ? `@${version}` : ''}`); // Instructions for viewing more details console.log(`\n๐Ÿ“š To view the trail's manifest (detailed contents):\n`); console.log(` herd trail manifest @${org}/${trail}${version ? `@${version}` : ''}`); console.log(''); } catch (error) { console.error('โŒ Error getting trail information:', error); process.exit(1); } process.exit(0); }); trailCommand .command('manifest') .description('๐Ÿ“˜ View a trail\'s manifest and available actions') .argument('<trail>', '๐Ÿ›ฃ๏ธ Trail name in format @organization/trail-name') .option('-v, --version <version>', '๐Ÿ“‹ Specific version to retrieve manifest for') .option('--json', '๐Ÿ“„ Output results as JSON') .action(async (trailName, options) => { const herdClient = getHerdClient(); try { await herdClient.initialize(); if (!trailName.includes('/')) { console.error('โŒ Error: Trail name must be in format "@organization/trail-name" or "organization/trail-name"'); process.exit(1); } const { version, org, trail } = parseTrailIdentifier(trailName); if (!org || !trail) { console.error('โŒ Error: Invalid trail name format. Use "organization/trail-name"'); process.exit(1); } if (!options.json) { console.log(`\n๐Ÿ“˜ Getting manifest for trail: @${org}/${trail}\n`); } // Construct URL based on whether version is specified const url = version ? `/api/registry/trail/${org}/${trail}/manifest/${version}` : `/api/registry/trail/${org}/${trail}/manifest`; // Fetch trail manifest const response = await herdClient.request(url); if (!response.ok) { if (response.status === 404) { console.error(`โŒ Trail${options.version ? ' version' : ''} not found: @${org}/${trail}${options.version ? ` v${options.version}` : ''}`); } else { console.error(`โŒ Failed to get trail manifest: ${response.statusText}`); } process.exit(1); } const manifest = await response.json(); if (options.json) { // Output raw JSON console.log(JSON.stringify(manifest, null, 2)); return; } // Format and display manifest information console.log(`๐Ÿ“ฆ Trail: ${manifest.name}`); console.log(`๐Ÿ“ Description: ${manifest.description}`); console.log(`๐Ÿ“Š Version: ${manifest.version}`); if (manifest.author) { console.log(`๐Ÿ‘ค Author: ${manifest.author}`); } if (manifest.license) { console.log(`๐Ÿ“œ License: ${manifest.license}`); } if (manifest.keywords && manifest.keywords.length > 0) { console.log(`๐Ÿท๏ธ Keywords: ${manifest.keywords.join(', ')}`); } if (manifest.dependencies && Object.keys(manifest.dependencies).length > 0) { console.log(`\n๐Ÿ“š Dependencies:`); for (const [dep, version] of Object.entries(manifest.dependencies)) { console.log(` - ${dep}: ${version}`); } } // Show actions if available if (manifest.actions && Object.keys(manifest.actions).length > 0) { console.log(`\n๐ŸŽฏ Available Actions:`); for (const [actionName, actionInfo] of Object.entries(manifest.actions)) { console.log(` - ${actionName}: ${actionInfo.description || 'No description'}`); if (actionInfo.params && Object.keys(actionInfo.params).length > 0) { console.log(` Parameters:`); for (const [paramName, paramInfo] of Object.entries(actionInfo.params)) { console.log(` - ${paramName} (${paramInfo.type || 'any'}): ${paramInfo.description || 'No description'}`); } } } } console.log('\n๐Ÿš€ To use this trail, run:'); console.log(` herd trail run @${org}/${trail}${version ? `@${version}` : ''}`); console.log(''); } catch (error) { console.error('โŒ Error getting trail manifest:', error); process.exit(1); } process.exit(0); }); trailCommand .command('server') .description('๐Ÿš€ Start a TrailServer that exposes trail actions as MCP tools') .argument('[trail]', '๐Ÿ“‚ Trail to serve (path or registry trail in format @org/name)', '') .option('-c, --config <config>', '๐Ÿ“ Path to JSON config file for server') .option('-t, --transport <transport>', '๐Ÿ”Œ Transport type (http, mcp-sse, mcp-stdio)', 'mcp-stdio') .option('-p, --port <port>', '๐Ÿ”Œ Port for HTTP or SSE transport', '3000') .option('--path <path>', '๐Ÿ”Œ Path for SSE transport', '/messages') .option('--cors', '๐ŸŒ Enable CORS for HTTP/SSE transport') .option('-n, --name <name>', '๐Ÿ“› Server name', 'herd-trail-server') .option('-v, --version <version>', '๐Ÿ“‹ Server version', '1.0.0') .option('-d, --description <description>', '๐Ÿ“ Server description') .option('-s, --silent', '๐Ÿคซ Suppress log output') .option('--dev', '๐Ÿ”ง Start in development mode with MCP Inspector') .option('--org-prefix', '๐Ÿข Include organization prefix in tool names') .option('--abridge <boolean>', '๐Ÿ“ Abridge long responses to prevent context window issues', 'true') .option('--max-response-length <number>', '๐Ÿ“ Maximum length for responses before abridging', '10000') .action(async (trailArg, options) => { const herdClient = getHerdClient(); let silent = options.silent || false; const devMode = options.dev || false; try { // Load configuration let config = { name: options.name, version: options.version, description: options.description || 'Herd Trail Server', transports: [], trails: [] }; // If config file is specified, load it if (options.config) { try { if (!fs.existsSync(options.config)) { console.error(`โŒ Config file not found: ${options.config}`); process.exit(1); } const configContent = fs.readFileSync(options.config, 'utf-8'); const fileConfig = JSON.parse(configContent); // Merge configs config = { ...config, ...fileConfig }; } catch (err) { console.error(`โŒ Error loading config file: ${err.message}`); process.exit(1); } } // If trail argument is specified, add it to the trails list if (trailArg) { if (!config.trails.includes(trailArg)) { config.trails.push(trailArg); } } // If no trails specified, error out if (config.trails.length === 0) { console.error('โŒ No trails specified. Use [trail] argument or --config with trails in the config file.'); process.exit(1); } // If no transports specified via config, use the command line options if (!config.transports || config.transports.length === 0) { const transport = { type: options.transport }; if (transport.type === 'http' || transport.type === 'mcp-sse') { transport.port = parseInt(options.port, 10); if (transport.type === 'mcp-sse') { transport.path = options.path; } if (options.cors) { transport.cors = true; } } config.transports.push(transport); } // Check if using stdio - if so, force silent mode regardless of command line options const usingStdio = config.transports.some((t) => t.type === 'mcp-stdio'); if (usingStdio) { silent = true; } if (!silent) { console.log('\n๐Ÿš€ Starting Trail Server...\n'); console.log(`๐Ÿ“› Name: ${config.name}`); console.log(`๐Ÿ“‹ Version: ${config.version}`); console.log(`๐Ÿ“ Description: ${config.description}`); console.log(`๐Ÿ›ฃ๏ธ Trails: ${config.trails.join(', ')}`); console.log(`๐Ÿ”Œ Transports: ${config.transports.map((t) => t.type).join(', ')}`); if (devMode) { console.log(`๐Ÿ”ง Development mode: enabled with MCP Inspector`); } console.log(''); } // Import the TrailServer dynamically to avoid circular dependencies const { TrailServer } = await import('../src/lib/trails/server.js'); // Make absolutely sure silent mode is set if using stdio if (usingStdio) { silent = true; } // Create and start the server await herdClient.initialize(); // Convert abridge option from string to actual boolean const abridgeOption = options.abridge === 'true' || options.abridge === true; const maxResponseLength = parseInt(options.maxResponseLength, 10) || 10000; const server = new TrailServer(herdClient, { ...config, silent, includeOrgPrefix: Boolean(options.orgPrefix), abridge: abridgeOption, maxResponseLength: maxResponseLength }); // Start MCP Inspector if in dev mode let inspectorProcess = null; // Calculate client port (inspector UI) based on server port const clientPort = parseInt(options.port, 10) + 1; if (devMode) { try { if (!silent) { console.log('๐Ÿ” Starting MCP Inspector...'); } // Find the package location to launch it directly if possible let inspectorPath = ''; try { // Create a temporary script to find the package path const tmpScript = path.join(os.tmpdir(), `find-inspector-${Date.now()}.js`); fs.writeFileSync(tmpScript, ` try { const path = require.resolve('@modelcontextprotocol/inspector/package.json'); console.log(path.replace('/package.json', '')); process.exit(0); } catch (e) { console.error(e.message); process.exit(1); } `); // Execute the script const result = require('child_process').execSync(`node ${tmpScript}`, { encoding: 'utf8' }).trim(); inspectorPath = result; // Clean up fs.unlinkSync(tmpScript); } catch (e) { // Ignore errors, we'll try alternative methods } // Try to launch the inspector - using multiple methods in sequence until one works const inspectorEnv = { ...process.env, CLIENT_PORT: clientPort.toString(), SERVER_PORT: '3999', // Use a high port number that's unlikely to conflict OPEN_BROWSER: 'false', // Don't automatically open browser DEBUG: 'inspector:*' // Enable debug logging }; if (inspectorPath) { // Method 1: Direct path try { inspectorProcess = spawn(process.execPath, [ path.join(inspectorPath, 'cli.js') ], { stdio: silent ? 'ignore' : 'inherit', env: inspectorEnv }); } catch (err) { // Fall through to next method } } if (!inspectorProcess) { // Method 2: Via npx try { inspectorProcess = spawn('npx', [ '--yes', '@modelcontextprotocol/inspector' ], { stdio: silent ? 'ignore' : 'inherit', env: inspectorEnv }); } catch (err) { // Fall through to next method } } if (!inspectorProcess) { // Method 3: via npm exec try { inspectorProcess = spawn('npm', [ 'exec', '--', '@modelcontextprotocol/inspector' ], { stdio: silent ? 'ignore' : 'inherit', env: inspectorEnv }); } catch (err) { throw new Error('Failed to launch inspector using any method'); } } // Give the inspector a moment to start await new Promise(resolve => setTimeout(resolve, 2000)); if (!silent) { console.log(`โœ… MCP Inspector started on port ${clientPort}`); } } catch (error) { console.error('โš ๏ธ Failed to start MCP Inspector:', error.message); console.error(''); console.error('You can launch it manually:'); console.error(''); console.error('1. Install the inspector:'); console.error(' npm install -g @modelcontextprotocol/inspector'); console.error(''); console.error('2. Launch it in a separate terminal:'); console.error(` CLIENT_PORT=${clientPort} npx @modelcontextprotocol/inspector`); console.error(''); console.error('3. Open this URL in your browser:'); // Try to determine server URL for any MCP SSE transport in the config let serverUrl = ''; for (const transport of config.transports) { if (transport.type === 'mcp-sse') { const port = transport.port || 3000; const path = transport.path || '/messages'; serverUrl = `http://localhost:${port}${path}`; break; } } if (serverUrl) { console.error(` http://localhost:${clientPort}/?serverUrl=${encodeURIComponent(serverUrl)}`); } else { console.error(` http://localhost:${clientPort}/`); } console.error(''); // Continue without inspector } } await server.start(); // Handle process termination const cleanup = async () => { if (!silent) { console.log('\n๐Ÿ›‘ Stopping Trail Server...'); } try { await server.stop(); if (!silent) { console.log('โœ… Trail Server stopped'); } // Terminate MCP Inspector if it was started if (inspectorProcess) { inspectorProcess.kill(); if (!silent) { console.log('โœ… MCP Inspector stopped'); } } } catch (error) { // Only log errors if not using stdio transport if (!usingStdio) { console.error('โŒ Error stopping server:', error); } } process.exit(0); }; if (typeof process.on === 'function') { process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); } else { // Don't log the warning if using stdio or silent mode if (!silent) { console.log('โš ๏ธ Warning: Unable to register process termination handlers. Use Ctrl+C to stop the server, but cleanup may not occur properly.'); } } if (!silent) { console.log('โœ… Trail Server running. Press Ctrl+C to stop.'); // Print out some help based on the transport type config.transports.forEach((transport) => { switch (transport.type) { case 'http': console.log(`\n๐ŸŒ HTTP API available at: http://localhost:${transport.port}/api/run/{trail}/{action}`); break; case 'mcp-sse': console.log(`\n๐ŸŒ MCP SSE server available at: http://localhost:${transport.port}/sse`); if (devMode && inspectorProcess) { // Use the correct inspector URL with our server as the parameter const serverUrl = `http://localhost:${transport.port}${transport.path}`; console.log(`\n๐Ÿ“ก MCP Inspector Connection Information:`); console.log(`- Inspector URL: http://localhost:${clientPort}/`); console.log(`- Server URL to enter: ${serverUrl}`); console.log(`- Direct link: http://localhost:${clientPort}/?serverUrl=${encodeURIComponent(serverUrl)}`); // Provide a clickable link to open in browser console.log(`\n๐Ÿ”— Open Inspector in browser: http://localhost:${clientPort}/`); console.log(` Then connect to: ${serverUrl}`); } break; case 'mcp-stdio': // Don't log anything for stdio transport break; } }); // Show tool naming convention information if (options.orgPrefix) { console.log('\n๐Ÿ”ง Tools are registered with org prefix (format: org-trail-action)'); } else { console.log('\n๐Ÿ”ง Tools are registered without org prefix (format: trail-action)'); } // Show abridging information if (abridgeOption) { console.log(`\n๐Ÿ“ Response abridging is enabled (max length: ${maxResponseLength} characters)`); } else { console.log('\n๐Ÿ“ Response abridging is disabled'); } } } catch (error) { console.error('โŒ Error starting Trail Server:', error.message); process.exit(1); } }); // login (and save token to .herdrc) program .command('login') .description('๐Ÿ”‘ Authenticate to Herd') .action(async () => { // check if we are already authenticated try { const herdClient = getHerdClient(); await herdClient.initialize(); const user = await herdClient.me(); console.log(`๐Ÿ”‘ Authenticated to Herd as ${user.email}, do you want to re-authenticate?`); const confirm = await prompt('(y/n): '); if (confirm.toLowerCase() === 'y') { fs.unlinkSync('.herdrc'); } else { process.exit(0); } } catch (error) { } // Ask user to enter their token const token = await prompt('Enter your Herd token: '); if (!token) { console.error('โŒ Error: No token provided'); process.exit(1); } // Save token to .herdrc with proper permissions (so that we can read it later) try { // First we try to login with the token const herdClient = new HerdClient({ token }); await herdClient.initialize(); // home directory fs.writeFileSync(path.join(homedir(), '.herdrc'), token, { mode: 0o600 }); const user = await herdClient.me(); console.table({ 'User ID': user.id, 'Email': user.email }); console.log('\nโœจ You are authenticated as ' + user.email + '\n'); } catch (error) { console.error('โŒ Error: Failed to save token to .herdrc', error); process.exit(1); } process.exit(0); }); program .command('logout') .description('๐Ÿ”‘ Logout from Herd') .action(async () => { fs.unlinkSync('.herdrc'); console.log('โœจ Logged out successfully'); process.exit(0); }); program .command('whoami') .description('๐Ÿ‘ค Show current user information') .action(async () => { try { const herdClient = getHerdClient(); let user; try { await herdClient.initialize(); user = await herdClient.me(); } catch (error) { if (error instanceof Error && error.message.includes(': Unauthorized')) { console.log('๐Ÿ’ก You are not authenticated to Herd. Please run `herd login` to authenticate.'); } else { console.error('โŒ Error: Failed to get user information', error); } process.exit(1); } console.log('\n๐Ÿ” Current User Information\n'); console.table({ 'User ID': user.id, 'Email': user.email }); console.log('\nโœจ You are authenticated as ' + user.email + '\n'); } catch (error) { console.error('โŒ Error: Failed to get user information', error); console.log('๐Ÿ’ก Try logging in again with: herd login'); process.exit(1); } process.exit(0); }); program.parse(process.argv);