UNPKG

@monitoro/herd

Version:

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

1,115 lines โ€ข 58.8 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 { homedir } from 'os'; import { isRemoteTrailIdentifier, parseTrailIdentifier } from '../src/lib/TrailEngine.js'; import { getPackageVersion } from '../src/lib/utils/packageUtils.js'; const version = getPackageVersion(undefined, '0.1.44'); 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') .option('--no-minify', '๐Ÿ‘ฝ Disable code minification') .option('--no-tree-shaking', '๐ŸŒด Disable tree shaking (dead code elimination)') .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); } // Log optimization settings console.log('\n๐Ÿš€ Building trail with optimizations:'); console.log(` ๐Ÿ”ง Minification: ${options.minify ? 'โœ… Enabled' : 'โŒ Disabled'}`); console.log(` ๐ŸŒด Tree Shaking: ${options.treeShaking ? 'โœ… Enabled' : 'โŒ Disabled'}\n`); try { if (options.watch) { console.log('๐Ÿ‘€ Building trail in watch mode...\n'); const outDir = await buildTrail(trailPath, { minify: options.minify, treeShaking: options.treeShaking }); // Verify package.json has type: module const outputPackageJsonPath = path.join(outDir, 'package.json'); if (fs.existsSync(outputPackageJsonPath)) { try { const outputPackageJson = JSON.parse(fs.readFileSync(outputPackageJsonPath, 'utf8')); if (outputPackageJson.type === 'module') { console.log(`โœ… Output package.json has correct module type (type: module)`); } else { console.warn(`โš ๏ธ Warning: Output package.json is missing "type": "module" setting`); } } catch (err) { console.error(`โŒ Error reading output package.json: ${err}`); } } 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, { minify: options.minify, treeShaking: options.treeShaking }); 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, { minify: options.minify, treeShaking: options.treeShaking }); // Verify package.json has type: module const outputPackageJsonPath = path.join(outDir, 'package.json'); if (fs.existsSync(outputPackageJsonPath)) { try { const outputPackageJson = JSON.parse(fs.readFileSync(outputPackageJsonPath, 'utf8')); if (outputPackageJson.type === 'module') { console.log(`โœ… Output package.json has correct module type (type: module)`); } else { console.warn(`โš ๏ธ Warning: Output package.json is missing "type": "module" setting`); } } catch (err) { console.error(`โŒ Error reading output package.json: ${err}`); } } 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) { const browserDisconnectedMsg = 'Browser disconnected or no browser registered. Please reconnect your browser: https://herd.garden/docs/troubleshooting#browser-disconnected.'; let errMsg; if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') { errMsg = error.message; } else { errMsg = String(error); } // Try to use chalk for color if available let highlight = (msg) => `\x1b[31m${msg}\x1b[0m`; // fallback: red try { // Dynamically import chalk if present const chalk = await import('chalk'); if (chalk && chalk.default && chalk.default.red) { highlight = chalk.default.red.bold; } } catch { } if (errMsg.trim() === browserDisconnectedMsg.trim()) { console.error('\n' + highlight('โŒ ' + browserDisconnectedMsg) + '\n'); } else { 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) { const browserDisconnectedMsg = 'Browser disconnected or no browser registered. Please reconnect your browser: https://herd.garden/docs/troubleshooting#browser-disconnected.'; let errMsg; if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') { errMsg = error.message; } else { errMsg = String(error); } // Try to use chalk for color if available let highlight = (msg) => `\x1b[31m${msg}\x1b[0m`; // fallback: red try { // Dynamically import chalk if present const chalk = await import('chalk'); if (chalk && chalk.default && chalk.default.red) { highlight = chalk.default.red.bold; } } catch { } if (errMsg.trim() === browserDisconnectedMsg.trim()) { console.error('\n' + highlight('โŒ ' + browserDisconnectedMsg) + '\n'); } else { 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('unpublish') .description('๐Ÿ—‘๏ธ Unpublish a trail from the registry') .argument('<trail>', '๐Ÿ›ฃ๏ธ Trail name in format @organization/trail-name') .option('-v, --version <version>', '๐Ÿ“‹ Specific version to unpublish (if not specified, the entire trail will be unpublished)') .option('-s, --silent', '๐Ÿคซ Only output the result of unpublishing') .option('-d, --debug', '๐Ÿ” Enable verbose debug output') .action(async (trailName, options) => { const herdClient = getHerdClient(); let silent = options.silent || false; const debug = options.debug || false; // Debug function that only logs if debug mode is enabled const debugLog = (message, ...args) => { if (debug) { console.log(`[DEBUG] ${message}`, ...args); } }; try { debugLog('Starting unpublish process'); debugLog('Initializing Herd client'); await herdClient.initialize(); debugLog('Herd client initialized'); // Verify authentication first try { debugLog('Verifying authentication'); const userInfo = await herdClient.me(); debugLog('Authentication successful:', userInfo.email); } catch (authError) { console.error('โŒ Authentication failed. Please run `herd login` first.'); debugLog('Authentication error:', authError); process.exit(1); } 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); const specificVersion = version || options.version; debugLog('Parsed trail identifier:', { org, trail, version, specificVersion }); if (!org || !trail) { console.error('โŒ Error: Invalid trail name format. Use "organization/trail-name"'); process.exit(1); } if (!silent) { console.log(`\n๐Ÿ—‘๏ธ Unpublishing trail: @${org}/${trail}${specificVersion ? `@${specificVersion}` : ''}\n`); } // Confirm unpublish action if (!silent) { console.log(`โš ๏ธ Warning: This action cannot be undone!`); const confirm = await prompt('Are you sure you want to continue? (y/n): '); if (confirm.toLowerCase() !== 'y') { console.log('๐Ÿ›‘ Unpublish cancelled'); process.exit(0); } } // Check if trail exists before attempting to unpublish debugLog('Checking if trail exists'); const checkUrl = `/api/registry/trail/${org}/${trail}`; let trailExactName = ''; // Define outside the try block to be accessible in the entire function try { const checkResponse = await herdClient.request(checkUrl); if (!checkResponse.ok) { console.error(`โŒ Trail not found: @${org}/${trail}`); debugLog('Trail check failed with status:', checkResponse.status, checkResponse.statusText); process.exit(1); } // Get the actual trail data to ensure we use correct IDs const trailData = await checkResponse.json(); debugLog('Trail data:', trailData); // Important: Log the actual name format to understand what we need to send debugLog('Trail name format in DB:', trailData.name); // Store the exact name for use in the DELETE request trailExactName = trailData.name; debugLog('Trail exists, proceeding with unpublish'); debugLog('Using trail name for unpublish:', trailExactName); } catch (checkError) { console.error(`โŒ Error checking trail existence: ${checkError.message}`); debugLog('Trail check error:', checkError); process.exit(1); } // Construct the proper URL const apiUrl = specificVersion ? `/api/registry/unpublish/${org}/${trail}/${specificVersion}` : `/api/registry/unpublish/${org}/${trail}`; debugLog('Using unpublish URL:', apiUrl); // Make the request try { debugLog('Sending unpublish request'); const response = await herdClient.request(apiUrl, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: '', // Add the trail name exactly as it is stored in the database name: trailExactName }) }); debugLog('Unpublish response status:', response.status, response.statusText); // Log response headers for debugging if (debug) { const headers = {}; response.headers.forEach((value, name) => { headers[name] = value; }); debugLog('Response headers:', headers); } if (!response.ok) { // Handle error response let errorBody = ''; let errorMessage = ''; try { errorBody = await response.text(); debugLog('Error response body:', errorBody); // Try to parse the error as JSON try { const errorJson = JSON.parse(errorBody); debugLog('Parsed error JSON:', errorJson); if (errorJson.error && errorJson.error.message) { errorMessage = errorJson.error.message; } } catch (e) { debugLog('Response is not valid JSON'); } } catch (e) { debugLog('Could not read response body:', e); } // Display appropriate error message if (response.status === 404) { console.error(`โŒ Trail${specificVersion ? ' version' : ''} not found: @${org}/${trail}${specificVersion ? `@${specificVersion}` : ''}`); if (errorMessage) { console.error(` Server message: ${errorMessage}`); } } else if (response.status === 403) { console.error(`โŒ Permission denied: You don't have permission to unpublish this trail`); if (errorMessage) { console.error(` Server message: ${errorMessage}`); } } else { console.error(`โŒ Failed to unpublish trail: ${response.statusText}`); console.error(` Status code: ${response.status}`); if (errorMessage) { console.error(` Server message: ${errorMessage}`); } else if (errorBody) { console.error(` Error details: ${errorBody}`); } } process.exit(1); } // Success case let successMessage = ''; try { const responseData = await response.json(); debugLog('Unpublish successful, response:', responseData); if (responseData && responseData.message) { successMessage = responseData.message; } } catch (e) { debugLog('Could not parse success response:', e); } if (silent) { console.log(JSON.stringify({ success: true }, null, 2)); } else { if (successMessage) { console.log(`โœ… ${successMessage}`); } else if (specificVersion) { console.log(`โœ… Successfully unpublished version ${specificVersion} of @${org}/${trail}`); } else { console.log(`โœ… Successfully unpublished @${org}/${trail} and all its versions`); } } } catch (requestError) { console.error(`โŒ Error making unpublish request: ${requestError.message}`); debugLog('Request error:', requestError); process.exit(1); } } catch (error) { console.error('โŒ Error unpublishing trail:', error); debugLog('Uncaught error:', 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('-d, --device <device>', '๐Ÿ”Œ Name of the device to use for running trails') .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('-s, --silent', '๐Ÿคซ Suppress log output') .option('--dev', '๐Ÿ”ง Start in development mode with MCP Inspector') .option('--description <description>', '๐Ÿ“ Server description') .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 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); } // Enforce trail param for mcp and mcp-sse transports const isMcpOrSseTransport = config.transports.some((t) => t.type === 'mcp-stdio' || t.type === 'mcp-sse'); if (isMcpOrSseTransport && config.trails.length === 0) { console.error('โŒ No trails specified. Use [trail] argument or --config with trails in the config file when using ' + config.transports.map((t) => t.type).join(', ') + ' transport(s).'); process.exit(1); } // 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 (options.device) { const devices = await herdClient.listDevices(); const device = devices.find((d) => d.name === options.device); if (!device) { console.error(`โŒ Device not found: ${options.device}`); process.exit(1); } config.device = device; } 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) && options.transport !== 'http'; 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('