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
261 lines (222 loc) • 6.38 kB
JavaScript
/**
* Reusable Command Templates (Hooks) System
*
* Allows users to create reusable natural language command templates
* stored as .md files that can be invoked with /hookname syntax.
*/
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import chalk from 'chalk';
/**
* Global hooks directory location
*/
export const HOOKS_DIR = path.join(os.homedir(), '.langterm', 'hooks');
/**
* Manages reusable command templates (hooks)
*/
export class HookManager {
constructor() {
this.hooksDir = HOOKS_DIR;
}
/**
* Ensure hooks directory exists
*/
async ensureHooksDir() {
try {
await fs.mkdir(this.hooksDir, { recursive: true });
} catch (error) {
// Directory might already exist, that's fine
}
}
/**
* Check if a hook name is valid (alphanumeric, dashes, underscores)
*/
isValidHookName(name) {
return /^[a-zA-Z0-9_-]+$/.test(name);
}
/**
* Get the file path for a hook
*/
getHookPath(name) {
return path.join(this.hooksDir, `${name}.md`);
}
/**
* Check if a hook exists
*/
async hookExists(name) {
if (!this.isValidHookName(name)) {
return false;
}
try {
const hookPath = this.getHookPath(name);
await fs.access(hookPath);
return true;
} catch (error) {
return false;
}
}
/**
* Load a hook's content
*/
async loadHook(name) {
if (!this.isValidHookName(name)) {
throw new Error(`Invalid hook name: ${name}. Hook names can only contain letters, numbers, dashes, and underscores.`);
}
const hookPath = this.getHookPath(name);
try {
const content = await fs.readFile(hookPath, 'utf8');
return content.trim();
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`Hook '${name}' not found. Create it with: langterm --hooks-create ${name}`);
}
throw new Error(`Failed to load hook '${name}': ${error.message}`);
}
}
/**
* Save a hook's content
*/
async saveHook(name, content) {
if (!this.isValidHookName(name)) {
throw new Error(`Invalid hook name: ${name}. Hook names can only contain letters, numbers, dashes, and underscores.`);
}
await this.ensureHooksDir();
const hookPath = this.getHookPath(name);
try {
await fs.writeFile(hookPath, content.trim() + '\n', 'utf8');
} catch (error) {
throw new Error(`Failed to save hook '${name}': ${error.message}`);
}
}
/**
* Delete a hook
*/
async deleteHook(name) {
if (!this.isValidHookName(name)) {
throw new Error(`Invalid hook name: ${name}`);
}
const hookPath = this.getHookPath(name);
try {
await fs.unlink(hookPath);
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`Hook '${name}' not found`);
}
throw new Error(`Failed to delete hook '${name}': ${error.message}`);
}
}
/**
* List all available hooks
*/
async listHooks() {
await this.ensureHooksDir();
try {
const files = await fs.readdir(this.hooksDir);
const hooks = [];
for (const file of files) {
if (file.endsWith('.md')) {
const name = file.slice(0, -3); // Remove .md extension
if (this.isValidHookName(name)) {
const hookPath = this.getHookPath(name);
const stats = await fs.stat(hookPath);
const content = await fs.readFile(hookPath, 'utf8');
hooks.push({
name,
content: content.trim(),
created: stats.birthtime,
modified: stats.mtime,
size: stats.size
});
}
}
}
// Sort by name
return hooks.sort((a, b) => a.name.localeCompare(b.name));
} catch (error) {
throw new Error(`Failed to list hooks: ${error.message}`);
}
}
/**
* Get hook usage statistics
*/
async getHookStats() {
const hooks = await this.listHooks();
return {
total: hooks.length,
totalSize: hooks.reduce((sum, hook) => sum + hook.size, 0),
hooksDir: this.hooksDir
};
}
/**
* Search for hooks containing specific text
*/
async searchHooks(searchTerm) {
const hooks = await this.listHooks();
const searchLower = searchTerm.toLowerCase();
return hooks.filter(hook =>
hook.name.toLowerCase().includes(searchLower) ||
hook.content.toLowerCase().includes(searchLower)
);
}
/**
* Validate hook content (basic checks)
*/
validateHookContent(content) {
const trimmed = content.trim();
if (!trimmed) {
throw new Error('Hook content cannot be empty');
}
if (trimmed.length > 10000) {
throw new Error('Hook content is too long (max 10000 characters)');
}
// Check for potentially dangerous patterns in the template itself
const dangerousPatterns = [
/rm\s+-rf\s+\/(?:\s|$)/, // rm -rf /
/:\(\)\s*{\s*:\|:&\s*}/, // fork bombs
/format\s+[cC]:/, // Windows format
/>\/dev\/sd[a-z]/ // overwrite disk
];
for (const pattern of dangerousPatterns) {
if (pattern.test(trimmed)) {
console.log(chalk.yellow(`⚠️ Warning: Hook contains potentially dangerous command patterns. Use with caution.`));
break;
}
}
return true;
}
}
/**
* Global hook manager instance
*/
export const hookManager = new HookManager();
/**
* Check if input string is a hook reference (starts with /)
*/
export function isHookReference(input) {
return typeof input === 'string' && input.startsWith('/') && input.length > 1;
}
/**
* Extract hook name from hook reference
*/
export function extractHookName(input) {
if (!isHookReference(input)) {
throw new Error('Not a valid hook reference');
}
return input.slice(1); // Remove leading /
}
/**
* Resolve hook reference to its content
*/
export async function resolveHook(input) {
if (!isHookReference(input)) {
return input; // Not a hook reference, return as-is
}
const hookName = extractHookName(input);
try {
const content = await hookManager.loadHook(hookName);
return content;
} catch (error) {
throw new Error(`Hook resolution failed: ${error.message}`);
}
}