feat: wire 10 new tools — file_read, file_write, glob, grep, web_fetch, task CRUD, send_message, schedule_cron

- 10 new JS tool classes in src/tools/ (clean, no framework deps)
- tools/index.js: registry-based init with env toggles
- bot/index.js: 16 tool definitions + 16 handlers (was 4)
- Added glob npm dependency
- Tools: bash, file_edit, file_read, file_write, glob, grep, web_search, web_fetch, git, task_create/update/list, send_message, schedule_cron, delegate_agent, run_skill
This commit is contained in:
admin
2026-05-05 16:43:05 +00:00
Unverified
parent 78d994fdda
commit 0a81aa2b82
14 changed files with 751 additions and 103 deletions

37
src/tools/FileReadTool.js Normal file
View File

@@ -0,0 +1,37 @@
import { logger } from '../utils/logger.js';
import fs from 'fs-extra';
import path from 'path';
export class FileReadTool {
constructor() {
this.name = 'file_read';
this.description = 'Read file contents with line numbers and pagination';
}
async execute(args) {
const { file_path, offset = 1, limit = 500 } = args;
try {
const fullPath = path.resolve(file_path);
const content = await fs.readFile(fullPath, 'utf-8');
const lines = content.split('\n');
if (offset < 1 || offset > lines.length) {
return `❌ Offset ${offset} out of range (file has ${lines.length} lines)`;
}
const end = Math.min(offset + limit - 1, lines.length);
const selected = lines.slice(offset - 1, end);
const numbered = selected.map((line, i) => `${offset + i}|${line}`).join('\n');
const header = offset === 1 && end >= lines.length
? `${fullPath} (${lines.length} lines)`
: `${fullPath} (lines ${offset}-${end} of ${lines.length})`;
return `${header}\n${numbered}`;
} catch (e) {
if (e.code === 'ENOENT') return `❌ File not found: ${file_path}`;
if (e.code === 'EISDIR') return `❌ Is a directory: ${file_path}`;
return `${e.message}`;
}
}
}

View File

@@ -0,0 +1,22 @@
import { logger } from '../utils/logger.js';
import fs from 'fs-extra';
import path from 'path';
export class FileWriteTool {
constructor() {
this.name = 'file_write';
this.description = 'Write content to a file, creating parent directories as needed';
}
async execute(args) {
const { file_path, content } = args;
try {
const fullPath = path.resolve(file_path);
await fs.ensureDir(path.dirname(fullPath));
await fs.writeFile(fullPath, content, 'utf-8');
return `✅ Written ${Buffer.byteLength(content)} bytes to ${fullPath}`;
} catch (e) {
return `❌ Write error: ${e.message}`;
}
}
}

32
src/tools/GlobTool.js Normal file
View File

@@ -0,0 +1,32 @@
import { logger } from '../utils/logger.js';
import { glob } from 'glob';
import path from 'path';
export class GlobTool {
constructor() {
this.name = 'glob';
this.description = 'Find files matching a glob pattern';
}
async execute(args) {
const { pattern, cwd = process.cwd() } = args;
try {
const matches = await glob(pattern, {
cwd: path.resolve(cwd),
absolute: false,
nodir: true,
dot: false,
ignore: ['**/node_modules/**', '**/.git/**'],
});
if (!matches.length) return `📁 No files matching: ${pattern}`;
const listed = matches.slice(0, 100);
const output = listed.join('\n');
const suffix = matches.length > 100 ? `\n... and ${matches.length - 100} more` : '';
return `📁 ${matches.length} files matching "${pattern}":\n${output}${suffix}`;
} catch (e) {
return `❌ Glob error: ${e.message}`;
}
}
}

38
src/tools/GrepTool.js Normal file
View File

@@ -0,0 +1,38 @@
import { logger } from '../utils/logger.js';
import { execa } from 'execa';
import path from 'path';
export class GrepTool {
constructor() {
this.name = 'grep';
this.description = 'Search file contents using regex (ripgrep-backed)';
}
async execute(args) {
const { pattern, path: searchPath = '.', file_glob, max_results = 20, context: ctx = 0 } = args;
try {
const cmdArgs = [
'--max-count', String(max_results),
'--no-heading',
'--line-number',
...(ctx > 0 ? ['-C', String(ctx)] : []),
];
if (file_glob) cmdArgs.push('--glob', file_glob);
const targetPath = path.resolve(searchPath);
cmdArgs.push('--', pattern, targetPath);
const { stdout } = await execa('rg', cmdArgs, { timeout: 30000 });
if (!stdout.trim()) return `🔍 No matches for "${pattern}" in ${searchPath}`;
const lineCount = stdout.trim().split('\n').length;
const suffix = lineCount >= max_results ? `\n(truncated at ${max_results} matches)` : '';
return `🔍 ${lineCount} matches for "${pattern}" in ${searchPath}:${suffix}\n${stdout}`;
} catch (e) {
if (e.exitCode === 1) return `🔍 No matches for "${pattern}"`;
if (e.exitCode === 2) return `❌ Grep error: ${e.stderr || e.message}`;
return `${e.message}`;
}
}
}

View File

@@ -0,0 +1,82 @@
import { logger } from '../utils/logger.js';
import { execa } from 'execa';
import fs from 'fs-extra';
import path from 'path';
const CRON_DIR = 'data/cron';
export class ScheduleCronTool {
constructor() {
this.name = 'schedule_cron';
this.description = 'Schedule a cron job (create/list/remove)';
}
async execute(args) {
const { action, ...params } = args;
switch (action) {
case 'create': return this._create(params);
case 'list': return this._list();
case 'remove': return this._remove(params);
default: return `❌ Unknown action: ${action}. Use: create, list, remove`;
}
}
async _create(params) {
const { name, schedule, command, description = '' } = params;
if (!name || !schedule || !command) {
return '❌ Required: name, schedule, command';
}
try {
await fs.ensureDir(CRON_DIR);
const jobPath = path.join(CRON_DIR, `${name}.json`);
const job = {
name, schedule, command, description,
enabled: true,
created: new Date().toISOString(),
last_run: null,
next_run: null,
run_count: 0,
};
await fs.writeJson(jobPath, job, { spaces: 2 });
logger.info(`⏰ Cron job created: ${name} (${schedule})`);
return `⏰ Cron job "${name}" created\nSchedule: ${schedule}\nCommand: ${command}`;
} catch (e) {
return `${e.message}`;
}
}
async _list() {
try {
await fs.ensureDir(CRON_DIR);
const files = await fs.readdir(CRON_DIR);
const jobs = files.filter(f => f.endsWith('.json'));
if (!jobs.length) return '⏰ No cron jobs.';
const list = [];
for (const f of jobs) {
const job = await fs.readJson(path.join(CRON_DIR, f));
const status = job.enabled ? '🟢' : '🔴';
list.push(`${status} ${job.name} | ${job.schedule} | runs: ${job.run_count} | ${job.command.substring(0, 60)}`);
}
return `⏰ Cron jobs (${jobs.length}):\n${list.join('\n')}`;
} catch (e) {
return `${e.message}`;
}
}
async _remove(params) {
const { name } = params;
if (!name) return '❌ Required: name';
try {
const jobPath = path.join(CRON_DIR, `${name}.json`);
if (!(await fs.pathExists(jobPath))) return `❌ Job not found: ${name}`;
await fs.remove(jobPath);
return `✅ Cron job "${name}" removed`;
} catch (e) {
return `${e.message}`;
}
}
}

View File

@@ -0,0 +1,37 @@
import { logger } from '../utils/logger.js';
import axios from 'axios';
export class SendMessageTool {
constructor() {
this.name = 'send_message';
this.description = 'Send a message to Telegram chat or channel';
}
async execute(args) {
const { chat_id, message, parse_mode = 'HTML' } = args;
const botToken = process.env.TELEGRAM_BOT_TOKEN;
if (!botToken) return '❌ TELEGRAM_BOT_TOKEN not set';
const target = chat_id || process.env.TELEGRAM_CHAT_ID;
if (!target) return '❌ No chat_id provided and TELEGRAM_CHAT_ID not set';
try {
const response = await axios.post(
`https://api.telegram.org/bot${botToken}/sendMessage`,
{
chat_id: target,
text: message,
parse_mode,
disable_web_page_preview: true,
},
{ timeout: 10000 }
);
return response.data.ok
? `✅ Message sent to chat ${target}`
: `❌ Telegram error: ${JSON.stringify(response.data)}`;
} catch (e) {
return `❌ Send error: ${e.message}`;
}
}
}

View File

@@ -0,0 +1,36 @@
import { logger } from '../utils/logger.js';
import fs from 'fs-extra';
import path from 'path';
const TASKS_FILE = 'data/tasks.json';
export class TaskCreateTool {
constructor() {
this.name = 'task_create';
this.description = 'Create a new task with description';
}
async execute(args) {
const { description } = args;
try {
const tasks = await this._loadTasks();
const id = String(Date.now()).slice(-8);
tasks.push({ id, description, status: 'pending', created: new Date().toISOString() });
await this._saveTasks(tasks);
return `✅ Task created: #${id}${description}`;
} catch (e) {
return `${e.message}`;
}
}
async _loadTasks() {
try {
return await fs.readJson(TASKS_FILE);
} catch { return []; }
}
async _saveTasks(tasks) {
await fs.ensureDir(path.dirname(TASKS_FILE));
await fs.writeJson(TASKS_FILE, tasks, { spaces: 2 });
}
}

36
src/tools/TaskListTool.js Normal file
View File

@@ -0,0 +1,36 @@
import { logger } from '../utils/logger.js';
import fs from 'fs-extra';
const TASKS_FILE = 'data/tasks.json';
export class TaskListTool {
constructor() {
this.name = 'task_list';
this.description = 'List all tasks with their status';
}
async execute(args = {}) {
try {
const tasks = await this._loadTasks();
if (!tasks.length) return '📋 No tasks.';
const statusFilter = args.status;
const filtered = statusFilter ? tasks.filter(t => t.status === statusFilter) : tasks;
if (!filtered.length) return `📋 No tasks with status "${statusFilter}".`;
const icons = { pending: '⏳', in_progress: '🔄', completed: '✅', cancelled: '❌' };
const list = filtered.map(t =>
`${icons[t.status] || '📝'} #${t.id} [${t.status}] ${t.description}`
).join('\n');
return `📋 Tasks (${filtered.length}/${tasks.length}):\n${list}`;
} catch (e) {
return `${e.message}`;
}
}
async _loadTasks() {
try { return await fs.readJson(TASKS_FILE); } catch { return []; }
}
}

View File

@@ -0,0 +1,42 @@
import { logger } from '../utils/logger.js';
import fs from 'fs-extra';
import path from 'path';
const TASKS_FILE = 'data/tasks.json';
export class TaskUpdateTool {
constructor() {
this.name = 'task_update';
this.description = 'Update task status (pending/in_progress/completed/cancelled)';
}
async execute(args) {
const { task_id, status } = args;
const validStatuses = ['pending', 'in_progress', 'completed', 'cancelled'];
if (!validStatuses.includes(status)) {
return `❌ Invalid status. Use: ${validStatuses.join(', ')}`;
}
try {
const tasks = await this._loadTasks();
const task = tasks.find(t => t.id === task_id);
if (!task) return `❌ Task not found: #${task_id}`;
task.status = status;
task.updated = new Date().toISOString();
await this._saveTasks(tasks);
return `✅ Task #${task_id}${status}`;
} catch (e) {
return `${e.message}`;
}
}
async _loadTasks() {
try { return await fs.readJson(TASKS_FILE); } catch { return []; }
}
async _saveTasks(tasks) {
await fs.ensureDir(path.dirname(TASKS_FILE));
await fs.writeJson(TASKS_FILE, tasks, { spaces: 2 });
}
}

53
src/tools/WebFetchTool.js Normal file
View File

@@ -0,0 +1,53 @@
import { logger } from '../utils/logger.js';
import axios from 'axios';
export class WebFetchTool {
constructor() {
this.name = 'web_fetch';
this.description = 'Fetch content from a URL and return text/markdown';
}
async execute(args) {
const { url, max_length = 15000 } = args;
try {
logger.info(`🌐 Fetching: ${url}`);
const response = await axios.get(url, {
timeout: 30000,
maxRedirects: 5,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; zCode-Bot/1.0)',
'Accept': 'text/html,application/json,text/plain,*/*',
},
});
let content = '';
const contentType = response.headers['content-type'] || '';
if (contentType.includes('application/json')) {
content = JSON.stringify(response.data, null, 2);
} else {
content = typeof response.data === 'string'
? response.data
: JSON.stringify(response.data, null, 2);
}
// Strip HTML tags for cleaner output
if (contentType.includes('text/html')) {
content = content
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
if (content.length > max_length) {
content = content.slice(0, max_length) + '\n\n... (truncated)';
}
return `🌐 ${url}\n${content}`;
} catch (e) {
return `❌ Fetch error: ${e.message}`;
}
}
}

View File

@@ -3,40 +3,60 @@ import { BashTool } from './BashTool.js';
import { FileEditTool } from './FileEditTool.js';
import { WebSearchTool } from './WebSearchTool.js';
import { GitTool } from './GitTool.js';
import { FileReadTool } from './FileReadTool.js';
import { FileWriteTool } from './FileWriteTool.js';
import { GlobTool } from './GlobTool.js';
import { GrepTool } from './GrepTool.js';
import { WebFetchTool } from './WebFetchTool.js';
import { TaskCreateTool } from './TaskCreateTool.js';
import { TaskUpdateTool } from './TaskUpdateTool.js';
import { TaskListTool } from './TaskListTool.js';
import { SendMessageTool } from './SendMessageTool.js';
import { ScheduleCronTool } from './ScheduleCronTool.js';
// Tool definitions: env toggle flag, factory function
const TOOL_REGISTRY = [
{ env: 'ZCODE_ENABLE_BASH', Tool: BashTool, label: 'Bash' },
{ env: 'ZCODE_ENABLE_FILE_EDIT', Tool: FileEditTool, label: 'File edit' },
{ env: 'ZCODE_ENABLE_WEB_SEARCH', Tool: WebSearchTool, label: 'Web search' },
{ env: 'ZCODE_ENABLE_GIT', Tool: GitTool, label: 'Git' },
{ env: 'ZCODE_ENABLE_FILE_READ', Tool: FileReadTool, label: 'File read' },
{ env: 'ZCODE_ENABLE_FILE_WRITE', Tool: FileWriteTool, label: 'File write' },
{ env: 'ZCODE_ENABLE_GLOB', Tool: GlobTool, label: 'Glob' },
{ env: 'ZCODE_ENABLE_GREP', Tool: GrepTool, label: 'Grep' },
{ env: 'ZCODE_ENABLE_WEB_FETCH', Tool: WebFetchTool, label: 'Web fetch' },
{ env: 'ZCODE_ENABLE_TASKS', Tool: TaskCreateTool, label: 'Task create' },
{ env: null, Tool: TaskUpdateTool, label: 'Task update' }, // bundled with TASKS
{ env: null, Tool: TaskListTool, label: 'Task list' }, // bundled with TASKS
{ env: 'ZCODE_ENABLE_SEND_MSG', Tool: SendMessageTool, label: 'Send message' },
{ env: 'ZCODE_ENABLE_CRON', Tool: ScheduleCronTool, label: 'Schedule cron' },
];
export async function initTools() {
const tools = [];
const taskEnabled = process.env.ZCODE_ENABLE_TASKS !== 'false';
// Bash tool
if (process.env.ZCODE_ENABLE_BASH !== 'false') {
const bashTool = new BashTool();
tools.push(bashTool);
logger.info(`✓ Bash tool loaded`);
}
// File edit tool
if (process.env.ZCODE_ENABLE_FILE_EDIT !== 'false') {
const fileEditTool = new FileEditTool();
tools.push(fileEditTool);
logger.info(`✓ File edit tool loaded`);
}
// Web search tool
if (process.env.ZCODE_ENABLE_WEB_SEARCH !== 'false') {
const webSearchTool = new WebSearchTool();
tools.push(webSearchTool);
logger.info(`✓ Web search tool loaded`);
}
// Git tool
if (process.env.ZCODE_ENABLE_GIT !== 'false') {
const gitTool = new GitTool();
tools.push(gitTool);
logger.info(`✓ Git tool loaded`);
for (const entry of TOOL_REGISTRY) {
// Tasks (create/update/list) share one env flag
const enabled = entry.env
? process.env[entry.env] !== 'false'
: taskEnabled;
if (enabled) {
const instance = new entry.Tool();
tools.push(instance);
logger.info(`${entry.label} tool loaded (${instance.name})`);
}
}
logger.info(`📦 ${tools.length} tools ready`);
return tools;
}
// Export tool classes
export { BashTool, FileEditTool, WebSearchTool, GitTool };
// Export tool classes for direct access
export {
BashTool, FileEditTool, WebSearchTool, GitTool,
FileReadTool, FileWriteTool, GlobTool, GrepTool, WebFetchTool,
TaskCreateTool, TaskUpdateTool, TaskListTool,
SendMessageTool, ScheduleCronTool,
};