@monitoro/herd
Version:
Automate your browser, build AI web tools and MCP servers with Monitoro Herd
1,015 lines (1,014 loc) โข 44 kB
JavaScript
// 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);