@monitoro/herd
Version:
Automate your browser, build AI web tools and MCP servers with Monitoro Herd
1,115 lines โข 58.8 kB
JavaScript
#!/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('