langterm
Version:
Secure CLI tool that translates natural language to shell commands using local AI models via Ollama, with project memory system, reusable command templates (hooks), MCP (Model Context Protocol) support, and dangerous command detection
782 lines (664 loc) • 25.1 kB
JavaScript
import chalk from 'chalk';
import { prompt } from './security.js';
import { addMCPServer, removeMCPServer, setMCPEnabled, loadConfig } from './config.js';
import { hookManager } from './hooks.js';
import { memoryManager, MEMORY_FILE } from './memory.js';
/**
* Shows help information for the CLI
*/
export function showHelp() {
console.log(chalk.blue('Langterm - Natural Language to Shell Commands\n'));
console.log('Usage:');
console.log(' langterm [options] [command in natural language]');
console.log('\nOptions:');
console.log(' --setup, -s Configure Ollama model');
console.log(' --model, -m Override model for this run');
console.log(' --mcp-setup Configure MCP servers');
console.log(' --mcp-status Show MCP connection status');
console.log(' --mcp-enable Enable MCP integration');
console.log(' --mcp-disable Disable MCP integration');
console.log(' --hooks-create Create a new hook template');
console.log(' --hooks-list List all available hooks');
console.log(' --hooks-edit Edit an existing hook');
console.log(' --hooks-delete Delete a hook');
console.log(' --hooks-search Search hooks by content');
console.log(' --remember Save information for current directory');
console.log(' --recall Show saved memory for current directory');
console.log(' --forget Clear memory for current directory');
console.log(' --memory-status Show memory status for current location');
console.log(' --verbose Enable verbose output');
console.log(' --help, -h Show this help message');
console.log(' --version, -v Show version');
console.log('\nHow it works:');
console.log(' Langterm intelligently decides between:');
console.log(' • Direct MCP tool execution (for data access, file operations)');
console.log(' • Terminal command generation (for system operations)');
console.log(' • Hybrid approach (combining both for complex tasks)');
console.log('\nExamples:');
console.log(chalk.cyan(' # MCP tool execution:'));
console.log(' langterm "read the package.json file"');
console.log(' langterm "show git status"');
console.log(chalk.cyan(' # Terminal commands:'));
console.log(' langterm "list all files larger than 100MB"');
console.log(' langterm "kill process on port 8080"');
console.log(chalk.cyan(' # Configuration:'));
console.log(' langterm --setup');
console.log(' langterm --mcp-setup');
console.log(chalk.cyan(' # Hook templates:'));
console.log(' langterm --hooks-create backup');
console.log(' langterm --hooks-list');
console.log(' langterm /backup # Uses backup.md template');
console.log(chalk.cyan(' # Location memory:'));
console.log(' langterm --remember "This is a Node.js directory using Express"');
console.log(' langterm --recall');
console.log(' langterm "run tests" # Uses location memory for context');
}
/**
* Parses command line arguments and returns structured data
* @param {Array<string>} args - Process arguments
* @returns {object} Parsed arguments object
*/
export function parseArgs(args) {
const result = {
showHelp: false,
showVersion: false,
runSetup: false,
runMCPSetup: false,
showMCPStatus: false,
enableMCP: false,
disableMCP: false,
createHook: false,
showHooksList: false,
editHook: false,
deleteHook: false,
searchHooks: false,
hookName: null,
rememberData: false,
recallMemory: false,
forgetMemory: false,
showMemoryStatus: false,
memoryData: null,
verbose: false,
modelOverride: null,
userInput: null
};
const remainingArgs = [...args];
// Check for flags
for (let i = 0; i < remainingArgs.length; i++) {
const arg = remainingArgs[i];
if (arg === '--help' || arg === '-h') {
result.showHelp = true;
return result;
}
if (arg === '--version' || arg === '-v') {
result.showVersion = true;
return result;
}
if (arg === '--setup' || arg === '-s') {
result.runSetup = true;
return result;
}
if (arg === '--mcp-setup') {
result.runMCPSetup = true;
return result;
}
if (arg === '--mcp-status') {
result.showMCPStatus = true;
return result;
}
if (arg === '--mcp-enable') {
result.enableMCP = true;
return result;
}
if (arg === '--mcp-disable') {
result.disableMCP = true;
return result;
}
if (arg === '--hooks-create') {
result.createHook = true;
if (remainingArgs[i + 1] && !remainingArgs[i + 1].startsWith('--')) {
result.hookName = remainingArgs[i + 1];
remainingArgs.splice(i + 1, 1); // Remove hook name from args
}
return result;
}
if (arg === '--hooks-list') {
result.showHooksList = true;
return result;
}
if (arg === '--hooks-edit') {
result.editHook = true;
if (remainingArgs[i + 1] && !remainingArgs[i + 1].startsWith('--')) {
result.hookName = remainingArgs[i + 1];
remainingArgs.splice(i + 1, 1); // Remove hook name from args
}
return result;
}
if (arg === '--hooks-delete') {
result.deleteHook = true;
if (remainingArgs[i + 1] && !remainingArgs[i + 1].startsWith('--')) {
result.hookName = remainingArgs[i + 1];
remainingArgs.splice(i + 1, 1); // Remove hook name from args
}
return result;
}
if (arg === '--hooks-search') {
result.searchHooks = true;
if (remainingArgs[i + 1] && !remainingArgs[i + 1].startsWith('--')) {
result.hookName = remainingArgs[i + 1]; // Reuse hookName for search term
remainingArgs.splice(i + 1, 1); // Remove search term from args
}
return result;
}
if (arg === '--remember') {
result.rememberData = true;
if (remainingArgs[i + 1] && !remainingArgs[i + 1].startsWith('--')) {
result.memoryData = remainingArgs[i + 1];
remainingArgs.splice(i + 1, 1); // Remove memory data from args
}
return result;
}
if (arg === '--recall') {
result.recallMemory = true;
return result;
}
if (arg === '--forget') {
result.forgetMemory = true;
return result;
}
if (arg === '--memory-status') {
result.showMemoryStatus = true;
return result;
}
if (arg === '--verbose') {
result.verbose = true;
remainingArgs.splice(i, 1); // Remove from args but continue processing
i--; // Adjust index since we removed an element
continue;
}
}
// Extract model override
const modelIndex = remainingArgs.findIndex(arg => arg === '--model' || arg === '-m');
if (modelIndex !== -1 && remainingArgs[modelIndex + 1]) {
result.modelOverride = remainingArgs[modelIndex + 1];
remainingArgs.splice(modelIndex, 2); // Remove --model and its value
}
// Get user input from remaining args
if (remainingArgs.length > 0) {
result.userInput = remainingArgs.join(' ');
}
return result;
}
/**
* Allows user to select a model from available options
* @param {Array} models - Array of available models
* @returns {Promise<string>} Selected model name
*/
export async function selectModel(models) {
console.log(chalk.cyan('\nAvailable Ollama models:'));
models.forEach((model, index) => {
console.log(chalk.green(`${index + 1}. ${model.name}`));
});
while (true) {
const choice = await prompt(chalk.yellow('\nSelect a model number: '));
const index = parseInt(choice) - 1;
if (index >= 0 && index < models.length) {
return models[index].name;
}
console.log(chalk.red('Invalid selection. Please try again.'));
}
}
/**
* Interactive MCP server setup
*/
export async function setupMCPServers() {
console.log(chalk.blue('🔗 MCP Server Setup\n'));
console.log(chalk.gray('Model Context Protocol allows Langterm to connect to external servers\nfor enhanced context and capabilities.\n'));
while (true) {
const config = await loadConfig();
const mcpServers = config?.mcp?.servers || {};
console.log(chalk.cyan('Current MCP servers:'));
if (Object.keys(mcpServers).length === 0) {
console.log(chalk.gray(' None configured'));
} else {
Object.entries(mcpServers).forEach(([name, serverConfig]) => {
console.log(chalk.green(` ${name}: ${serverConfig.type} - ${serverConfig.command || serverConfig.url}`));
});
}
console.log(chalk.yellow('\nOptions:'));
console.log('1. Add new MCP server');
console.log('2. Remove MCP server');
console.log('3. Toggle MCP enabled/disabled');
console.log('4. Done');
const choice = await prompt(chalk.yellow('\nSelect option (1-4): '));
switch (choice) {
case '1':
await addMCPServerInteractive();
break;
case '2':
await removeMCPServerInteractive();
break;
case '3':
await toggleMCPInteractive();
break;
case '4':
console.log(chalk.green('✅ MCP setup complete!'));
return;
default:
console.log(chalk.red('Invalid option. Please try again.'));
}
}
}
/**
* Interactive MCP server addition
*/
async function addMCPServerInteractive() {
console.log(chalk.cyan('\nAdding new MCP server:'));
const name = await prompt('Server name: ');
if (!name.trim()) {
console.log(chalk.red('Server name cannot be empty.'));
return;
}
console.log(chalk.yellow('\nTransport type:'));
console.log('1. stdio (local command)');
console.log('2. sse (HTTP Server-Sent Events)');
const typeChoice = await prompt('Select transport type (1-2): ');
let serverConfig;
if (typeChoice === '1') {
const command = await prompt('Command to run: ');
const argsInput = await prompt('Arguments (space-separated, optional): ');
const args = argsInput.trim() ? argsInput.trim().split(' ') : [];
serverConfig = {
type: 'stdio',
command,
args
};
} else if (typeChoice === '2') {
const url = await prompt('Server URL: ');
serverConfig = {
type: 'sse',
url
};
} else {
console.log(chalk.red('Invalid transport type.'));
return;
}
try {
await addMCPServer(name, serverConfig);
console.log(chalk.green(`✅ Added MCP server '${name}'`));
} catch (error) {
console.log(chalk.red(`❌ Failed to add MCP server: ${error.message}`));
}
}
/**
* Interactive MCP server removal
*/
async function removeMCPServerInteractive() {
const config = await loadConfig();
const mcpServers = config?.mcp?.servers || {};
if (Object.keys(mcpServers).length === 0) {
console.log(chalk.yellow('No MCP servers configured.'));
return;
}
console.log(chalk.cyan('\nSelect server to remove:'));
const serverNames = Object.keys(mcpServers);
serverNames.forEach((name, index) => {
console.log(chalk.green(`${index + 1}. ${name}`));
});
const choice = await prompt('Server number: ');
const index = parseInt(choice) - 1;
if (index >= 0 && index < serverNames.length) {
const serverName = serverNames[index];
try {
await removeMCPServer(serverName);
console.log(chalk.green(`✅ Removed MCP server '${serverName}'`));
} catch (error) {
console.log(chalk.red(`❌ Failed to remove MCP server: ${error.message}`));
}
} else {
console.log(chalk.red('Invalid selection.'));
}
}
/**
* Interactive MCP enable/disable toggle
*/
async function toggleMCPInteractive() {
const config = await loadConfig();
const isEnabled = config?.mcp?.enabled || false;
console.log(chalk.cyan(`\nMCP is currently: ${isEnabled ? chalk.green('ENABLED') : chalk.red('DISABLED')}`));
const newState = !isEnabled;
const action = newState ? 'enable' : 'disable';
const confirm = await prompt(`${action.charAt(0).toUpperCase() + action.slice(1)} MCP? (y/n): `);
if (confirm.toLowerCase() === 'y' || confirm.toLowerCase() === 'yes') {
try {
await setMCPEnabled(newState);
console.log(chalk.green(`✅ MCP ${newState ? 'enabled' : 'disabled'}`));
} catch (error) {
console.log(chalk.red(`❌ Failed to ${action} MCP: ${error.message}`));
}
}
}
/**
* Show MCP connection status
* @param {MCPManager} mcpManager - MCP manager instance
*/
export async function showMCPStatus(mcpManager) {
console.log(chalk.blue('🔗 MCP Status\n'));
const config = await loadConfig();
const isEnabled = config?.mcp?.enabled || false;
const servers = config?.mcp?.servers || {};
console.log(chalk.cyan(`MCP Integration: ${isEnabled ? chalk.green('ENABLED') : chalk.red('DISABLED')}`));
console.log(chalk.cyan(`Configured servers: ${Object.keys(servers).length}`));
if (Object.keys(servers).length > 0) {
console.log(chalk.yellow('\nConfigured servers:'));
Object.entries(servers).forEach(([name, serverConfig]) => {
console.log(chalk.gray(` ${name}:`));
console.log(chalk.gray(` Type: ${serverConfig.type}`));
console.log(chalk.gray(` Config: ${serverConfig.command || serverConfig.url}`));
});
}
if (mcpManager && mcpManager.isInitialized) {
const connectionStatus = mcpManager.getConnectionStatus();
console.log(chalk.yellow('\nConnection status:'));
connectionStatus.forEach(status => {
const statusIcon = status.connected ? chalk.green('✅') : chalk.red('❌');
console.log(chalk.gray(` ${statusIcon} ${status.name} (${status.type})`));
});
try {
const tools = await mcpManager.getAvailableTools();
const resources = await mcpManager.getAvailableResources();
const prompts = await mcpManager.getAvailablePrompts();
console.log(chalk.yellow('\nAvailable capabilities:'));
console.log(chalk.gray(` Tools: ${tools.length}`));
console.log(chalk.gray(` Resources: ${resources.length}`));
console.log(chalk.gray(` Prompts: ${prompts.length}`));
if (tools.length > 0) {
console.log(chalk.cyan('\nAvailable tools:'));
tools.forEach(tool => {
console.log(chalk.gray(` ${tool.fullName}: ${tool.description || 'No description'}`));
});
}
} catch (error) {
console.log(chalk.yellow(`⚠️ Could not fetch MCP capabilities: ${error.message}`));
}
} else {
console.log(chalk.gray('\nMCP manager not initialized.'));
}
}
/**
* Create a new hook template
*/
export async function createHook(name) {
if (!name) {
name = await prompt('Hook name: ');
}
if (!name.trim()) {
console.log(chalk.red('Hook name cannot be empty.'));
return;
}
name = name.trim();
if (!hookManager.isValidHookName(name)) {
console.log(chalk.red('Invalid hook name. Use only letters, numbers, dashes, and underscores.'));
return;
}
if (await hookManager.hookExists(name)) {
console.log(chalk.yellow(`Hook '${name}' already exists. Use --hooks-edit to modify it.`));
return;
}
console.log(chalk.cyan(`\nCreating hook: ${name}`));
console.log(chalk.gray('Enter the natural language command template (press Ctrl+D when done):'));
console.log(chalk.gray('Example: "backup all important files to the backup directory"\n'));
const content = await prompt('Content: ');
if (!content.trim()) {
console.log(chalk.red('Hook content cannot be empty.'));
return;
}
try {
hookManager.validateHookContent(content);
await hookManager.saveHook(name, content);
console.log(chalk.green(`✅ Created hook '${name}'`));
console.log(chalk.gray(`Use it with: langterm /${name}`));
console.log(chalk.gray(`Location: ${hookManager.getHookPath(name)}`));
} catch (error) {
console.log(chalk.red(`❌ Failed to create hook: ${error.message}`));
}
}
/**
* Edit an existing hook template
*/
export async function editHook(name) {
if (!name) {
name = await prompt('Hook name to edit: ');
}
if (!name.trim()) {
console.log(chalk.red('Hook name cannot be empty.'));
return;
}
name = name.trim();
if (!await hookManager.hookExists(name)) {
console.log(chalk.red(`Hook '${name}' not found.`));
return;
}
try {
const currentContent = await hookManager.loadHook(name);
console.log(chalk.cyan(`\nEditing hook: ${name}`));
console.log(chalk.gray('Current content:'));
console.log(chalk.yellow(currentContent));
console.log(chalk.gray('\nEnter new content (or press Enter to keep current):'));
const newContent = await prompt('New content: ');
if (newContent.trim()) {
hookManager.validateHookContent(newContent);
await hookManager.saveHook(name, newContent);
console.log(chalk.green(`✅ Updated hook '${name}'`));
} else {
console.log(chalk.gray('No changes made.'));
}
} catch (error) {
console.log(chalk.red(`❌ Failed to edit hook: ${error.message}`));
}
}
/**
* Delete a hook template
*/
export async function deleteHook(name) {
if (!name) {
name = await prompt('Hook name to delete: ');
}
if (!name.trim()) {
console.log(chalk.red('Hook name cannot be empty.'));
return;
}
name = name.trim();
if (!await hookManager.hookExists(name)) {
console.log(chalk.red(`Hook '${name}' not found.`));
return;
}
try {
const content = await hookManager.loadHook(name);
console.log(chalk.cyan(`\nDeleting hook: ${name}`));
console.log(chalk.gray('Content:'));
console.log(chalk.yellow(content));
const confirm = await prompt('\nAre you sure? (y/N): ');
if (confirm.toLowerCase() === 'y' || confirm.toLowerCase() === 'yes') {
await hookManager.deleteHook(name);
console.log(chalk.green(`✅ Deleted hook '${name}'`));
} else {
console.log(chalk.gray('Cancelled.'));
}
} catch (error) {
console.log(chalk.red(`❌ Failed to delete hook: ${error.message}`));
}
}
/**
* Search hooks by content
*/
export async function searchHooks(searchTerm) {
if (!searchTerm) {
searchTerm = await prompt('Search term: ');
}
if (!searchTerm.trim()) {
console.log(chalk.red('Search term cannot be empty.'));
return;
}
try {
const results = await hookManager.searchHooks(searchTerm);
if (results.length === 0) {
console.log(chalk.yellow(`No hooks found containing "${searchTerm}"`));
return;
}
console.log(chalk.cyan(`\nFound ${results.length} hook(s) containing "${searchTerm}":\n`));
results.forEach(hook => {
console.log(chalk.green(`📄 ${hook.name}`));
console.log(chalk.gray(` Content: ${hook.content.slice(0, 100)}${hook.content.length > 100 ? '...' : ''}`));
console.log(chalk.gray(` Modified: ${hook.modified.toLocaleDateString()}`));
console.log(chalk.gray(` Usage: langterm /${hook.name}\n`));
});
} catch (error) {
console.log(chalk.red(`❌ Search failed: ${error.message}`));
}
}
/**
* Display all available hook templates
*/
export async function showHooksList() {
console.log(chalk.blue('📄 Available Hook Templates\n'));
try {
const hooks = await hookManager.listHooks();
const stats = await hookManager.getHookStats();
if (hooks.length === 0) {
console.log(chalk.gray('No hooks found.'));
console.log(chalk.yellow('\nTo create a hook, run: langterm --hooks-create <name>'));
console.log(chalk.gray(`Hooks directory: ${stats.hooksDir}`));
return;
}
console.log(chalk.cyan(`Found ${stats.total} hook(s):\n`));
hooks.forEach(hook => {
console.log(chalk.green(`📄 ${hook.name}`));
console.log(chalk.gray(` Content: ${hook.content.slice(0, 80)}${hook.content.length > 80 ? '...' : ''}`));
console.log(chalk.gray(` Size: ${hook.size} bytes`));
console.log(chalk.gray(` Modified: ${hook.modified.toLocaleDateString()}`));
console.log(chalk.gray(` Usage: langterm /${hook.name}`));
console.log();
});
console.log(chalk.gray(`Hooks directory: ${stats.hooksDir}`));
console.log(chalk.gray(`Total size: ${stats.totalSize} bytes`));
} catch (error) {
console.log(chalk.red(`❌ Failed to list hooks: ${error.message}`));
}
}
/**
* Save information to location memory
*/
export async function rememberLocationData(data) {
if (!data) {
data = await prompt('Information to remember: ');
}
if (!data.trim()) {
console.log(chalk.red('Memory data cannot be empty.'));
return;
}
try {
const location = memoryManager.getCurrentLocation();
console.log(chalk.cyan(`\nSaving memory for: ${location}`));
// Check if memory already exists
const existingMemory = await memoryManager.getCurrentMemory();
if (existingMemory) {
console.log(chalk.yellow('Location already has memory. This will add to existing information.'));
console.log(chalk.gray('Current context:'));
console.log(chalk.gray(existingMemory.context.slice(0, 200) + (existingMemory.context.length > 200 ? '...' : '')));
const confirm = await prompt('\nAdd to existing memory? (Y/n): ');
if (confirm.toLowerCase() === 'n' || confirm.toLowerCase() === 'no') {
console.log(chalk.gray('Cancelled.'));
return;
}
// Append to existing context
await memoryManager.addToMemory(data);
} else {
// Create new memory
await memoryManager.saveMemory(data);
}
console.log(chalk.green('✅ Memory saved successfully!'));
console.log(chalk.gray(`Location: ${memoryManager.getMemoryPath()}`));
console.log(chalk.gray('Future commands in this directory will use this context.'));
} catch (error) {
console.log(chalk.red(`❌ Failed to save memory: ${error.message}`));
}
}
/**
* Display current location memory
*/
export async function recallLocationMemory() {
try {
const location = memoryManager.getCurrentLocation();
const memory = await memoryManager.getCurrentMemory();
if (!memory) {
console.log(chalk.yellow(`No memory found for: ${location}`));
console.log(chalk.gray('Use --remember to save information for this location.'));
return;
}
console.log(chalk.blue('🧠 Location Memory\n'));
console.log(chalk.cyan(`Location: ${location}`));
console.log();
if (memory.context) {
console.log(chalk.yellow('Saved Information:'));
console.log(chalk.gray(memory.context));
console.log();
}
console.log(chalk.gray(`Memory file: ${memoryManager.getMemoryPath()}`));
} catch (error) {
console.log(chalk.red(`❌ Failed to recall memory: ${error.message}`));
}
}
/**
* Clear location memory
*/
export async function forgetLocationMemory() {
try {
const location = memoryManager.getCurrentLocation();
const hasMemory = await memoryManager.hasMemory();
if (!hasMemory) {
console.log(chalk.yellow(`No memory found for: ${location}`));
return;
}
console.log(chalk.cyan(`\nClearing memory for: ${location}`));
const confirm = await prompt('Are you sure you want to delete all saved memory? (y/N): ');
if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') {
console.log(chalk.gray('Cancelled.'));
return;
}
const deleted = await memoryManager.deleteMemory();
if (deleted) {
console.log(chalk.green('✅ Memory cleared successfully!'));
} else {
console.log(chalk.yellow('No memory file found to delete.'));
}
} catch (error) {
console.log(chalk.red(`❌ Failed to clear memory: ${error.message}`));
}
}
/**
* Show memory status across projects
*/
export async function showLocationMemoryStatus() {
try {
console.log(chalk.blue('🧠 Project Memory Status\n'));
const stats = await memoryManager.getMemoryStats();
console.log(chalk.cyan(`Current Location: ${stats.currentLocation}`));
console.log(chalk.cyan(`Has Memory: ${stats.hasCurrentMemory ? chalk.green('Yes') : chalk.red('No')}`));
if (stats.hasCurrentMemory) {
console.log(chalk.gray(`Memory Size: ${stats.currentMemorySize} bytes`));
// Show context preview
const memory = await memoryManager.getCurrentMemory();
if (memory && memory.context) {
console.log(chalk.yellow('\nSaved Information Preview:'));
console.log(chalk.gray(memory.context.slice(0, 300) + (memory.context.length > 300 ? '...' : '')));
}
} else {
console.log(chalk.gray('\nNo memory found for current location.'));
console.log(chalk.gray('Use --remember to save location information.'));
}
console.log(chalk.gray(`\nMemory files are stored as: ${MEMORY_FILE}`));
} catch (error) {
console.log(chalk.red(`❌ Failed to show memory status: ${error.message}`));
}
}