rowboat/apps/cli/src/application/lib/builtin-tools.ts

441 lines
18 KiB
TypeScript
Raw Normal View History

2025-11-15 01:51:22 +05:30
import { z, ZodType } from "zod";
import * as fs from "fs/promises";
import * as path from "path";
2025-12-02 13:24:58 +05:30
import { WorkDir as BASE_DIR } from "../../config/config.js";
2025-11-15 01:51:22 +05:30
import { executeCommand } from "./command-executor.js";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
2025-12-02 13:24:58 +05:30
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
import container from "../../di/container.js";
import { IMcpConfigRepo } from "../..//mcp/repo.js";
2025-12-16 14:48:04 +05:30
import { McpServerDefinition } from "../../mcp/schema.js";
2025-11-15 01:51:22 +05:30
const BuiltinToolsSchema = z.record(z.string(), z.object({
description: z.string(),
inputSchema: z.custom<ZodType>(),
execute: z.function({
input: z.any(),
output: z.promise(z.any()),
}),
}));
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
loadSkill: {
description: "Load a Rowboat skill definition into context by fetching its guidance string",
inputSchema: z.object({
skillName: z.string().describe("Skill identifier or path (e.g., 'workflow-run-ops' or 'src/application/assistant/skills/workflow-run-ops/skill.ts')"),
}),
execute: async ({ skillName }: { skillName: string }) => {
const resolved = resolveSkill(skillName);
if (!resolved) {
return {
success: false,
message: `Skill '${skillName}' not found. Available skills: ${availableSkills.join(", ")}`,
};
}
return {
success: true,
skillName: resolved.id,
path: resolved.catalogPath,
content: resolved.content,
};
},
},
2025-11-15 01:51:22 +05:30
exploreDirectory: {
2025-11-17 23:27:00 +05:30
description: 'Recursively explore directory structure to understand existing agents and file organization',
2025-11-15 01:51:22 +05:30
inputSchema: z.object({
subdirectory: z.string().optional().describe('Subdirectory to explore (optional, defaults to root)'),
maxDepth: z.number().optional().describe('Maximum depth to traverse (default: 3)'),
}),
execute: async ({ subdirectory, maxDepth = 3 }: { subdirectory?: string, maxDepth?: number }) => {
async function explore(dir: string, depth: number = 0): Promise<any> {
if (depth > maxDepth) return null;
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
const result: any = { files: [], directories: {} };
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile()) {
const ext = path.extname(entry.name);
const size = (await fs.stat(fullPath)).size;
result.files.push({
name: entry.name,
type: ext || 'no-extension',
size: size,
relativePath: path.relative(BASE_DIR, fullPath),
});
} else if (entry.isDirectory()) {
result.directories[entry.name] = await explore(fullPath, depth + 1);
}
}
return result;
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
}
const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR;
const structure = await explore(dirPath);
return {
success: true,
basePath: path.relative(BASE_DIR, dirPath) || '.',
structure,
};
},
},
readFile: {
description: 'Read and parse file contents. For JSON files, provides parsed structure.',
inputSchema: z.object({
filename: z.string().describe('The name of the file to read (relative to .rowboat directory)'),
}),
execute: async ({ filename }: { filename: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
const content = await fs.readFile(filePath, 'utf-8');
let parsed = null;
let fileType = path.extname(filename);
if (fileType === '.json') {
try {
parsed = JSON.parse(content);
} catch {
parsed = { error: 'Invalid JSON' };
}
}
return {
success: true,
filename,
fileType,
content,
parsed,
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
createFile: {
description: 'Create a new file with content. Automatically creates parent directories if needed.',
inputSchema: z.object({
filename: z.string().describe('The name of the file to create (relative to .rowboat directory)'),
content: z.string().describe('The content to write to the file'),
description: z.string().optional().describe('Optional description of why this file is being created'),
}),
execute: async ({ filename, content, description }: { filename: string, content: string, description?: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
const dir = path.dirname(filePath);
// Ensure directory exists
await fs.mkdir(dir, { recursive: true });
// Write file
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
message: `File '${filename}' created successfully`,
description: description || 'No description provided',
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
updateFile: {
description: 'Update or overwrite the contents of an existing file',
inputSchema: z.object({
filename: z.string().describe('The name of the file to update (relative to .rowboat directory)'),
content: z.string().describe('The new content to write to the file'),
reason: z.string().optional().describe('Optional reason for the update'),
}),
execute: async ({ filename, content, reason }: { filename: string, content: string, reason?: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
// Check if file exists
await fs.access(filePath);
// Update file
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
message: `File '${filename}' updated successfully`,
reason: reason || 'No reason provided',
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to update file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
deleteFile: {
description: 'Delete a file from the .rowboat directory',
inputSchema: z.object({
filename: z.string().describe('The name of the file to delete (relative to .rowboat directory)'),
}),
execute: async ({ filename }: { filename: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
await fs.unlink(filePath);
return {
success: true,
message: `File '${filename}' deleted successfully`,
path: filePath,
};
} catch (error) {
return {
success: false,
message: `Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
listFiles: {
description: 'List all files and directories in the .rowboat directory or subdirectory',
inputSchema: z.object({
subdirectory: z.string().optional().describe('Optional subdirectory to list (relative to .rowboat directory)'),
}),
execute: async ({ subdirectory }: { subdirectory?: string }) => {
try {
const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR;
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const files = entries
.filter(entry => entry.isFile())
.map(entry => ({
name: entry.name,
type: path.extname(entry.name) || 'no-extension',
relativePath: path.relative(BASE_DIR, path.join(dirPath, entry.name)),
}));
const directories = entries
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
return {
success: true,
path: dirPath,
relativePath: path.relative(BASE_DIR, dirPath) || '.',
files,
directories,
totalFiles: files.length,
totalDirectories: directories.length,
};
} catch (error) {
return {
success: false,
message: `Failed to list files: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
2025-11-17 23:27:00 +05:30
analyzeAgent: {
description: 'Read and analyze an agent file to understand its structure, tools, and configuration',
2025-11-15 01:51:22 +05:30
inputSchema: z.object({
2025-11-17 23:27:00 +05:30
agentName: z.string().describe('Name of the agent file to analyze (with or without .json extension)'),
2025-11-15 01:51:22 +05:30
}),
2025-11-17 23:27:00 +05:30
execute: async ({ agentName }: { agentName: string }) => {
2025-11-15 01:51:22 +05:30
try {
2025-11-17 23:27:00 +05:30
const filename = agentName.endsWith('.json') ? agentName : `${agentName}.json`;
const filePath = path.join(BASE_DIR, 'agents', filename);
2025-11-15 01:51:22 +05:30
const content = await fs.readFile(filePath, 'utf-8');
2025-11-17 23:27:00 +05:30
const agent = JSON.parse(content);
2025-11-15 01:51:22 +05:30
// Extract key information
2025-11-17 23:27:00 +05:30
const toolsList = agent.tools ? Object.keys(agent.tools) : [];
const agentTools = agent.tools ? Object.entries(agent.tools).map(([key, tool]: [string, any]) => ({
key,
type: tool.type,
name: tool.name || key,
})) : [];
2025-11-15 01:51:22 +05:30
const analysis = {
2025-11-17 23:27:00 +05:30
name: agent.name,
description: agent.description || 'No description',
model: agent.model || 'Not specified',
toolCount: toolsList.length,
tools: agentTools,
hasOtherAgents: agentTools.some((t: any) => t.type === 'agent'),
structure: agent,
2025-11-15 01:51:22 +05:30
};
return {
success: true,
filePath: path.relative(BASE_DIR, filePath),
analysis,
};
} catch (error) {
return {
success: false,
2025-11-17 23:27:00 +05:30
message: `Failed to analyze agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
2025-11-15 01:51:22 +05:30
};
}
},
},
addMcpServer: {
description: 'Add or update an MCP server in the configuration with validation. This ensures the server definition is valid before saving.',
inputSchema: z.object({
serverName: z.string().describe('Name/alias for the MCP server'),
2025-12-02 13:24:58 +05:30
config: McpServerDefinition,
}),
2025-12-02 13:24:58 +05:30
execute: async ({ serverName, config }: {
serverName: string;
2025-12-02 13:24:58 +05:30
config: z.infer<typeof McpServerDefinition>;
}) => {
try {
2025-12-02 13:24:58 +05:30
const validationResult = McpServerDefinition.safeParse(config);
if (!validationResult.success) {
return {
success: false,
message: 'Server definition failed validation. Check the errors below.',
validationErrors: validationResult.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`),
2025-12-02 13:24:58 +05:30
providedDefinition: config,
};
}
2025-12-02 13:24:58 +05:30
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
await repo.upsert(serverName, config);
return {
success: true,
serverName,
};
} catch (error) {
return {
2025-12-02 13:24:58 +05:30
error: `Failed to update MCP server: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
2025-11-15 01:51:22 +05:30
listMcpServers: {
description: 'List all available MCP servers from the configuration',
inputSchema: z.object({}),
2025-12-02 13:24:58 +05:30
execute: async () => {
2025-11-15 01:51:22 +05:30
try {
2025-12-02 13:24:58 +05:30
const result = await listServers();
2025-11-15 01:51:22 +05:30
return {
2025-12-02 13:24:58 +05:30
result,
count: Object.keys(result.mcpServers).length,
2025-11-15 01:51:22 +05:30
};
} catch (error) {
return {
2025-12-02 13:24:58 +05:30
error: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`,
2025-11-15 01:51:22 +05:30
};
}
},
},
listMcpTools: {
description: 'List all available tools from a specific MCP server',
inputSchema: z.object({
serverName: z.string().describe('Name of the MCP server to query'),
2025-12-02 13:24:58 +05:30
cursor: z.string().optional(),
2025-11-15 01:51:22 +05:30
}),
2025-12-02 13:24:58 +05:30
execute: async ({ serverName, cursor }: { serverName: string, cursor?: string }) => {
2025-11-15 01:51:22 +05:30
try {
2025-12-02 13:24:58 +05:30
const result = await listTools(serverName, cursor);
2025-11-15 01:51:22 +05:30
return {
serverName,
2025-12-02 13:24:58 +05:30
result,
count: result.tools.length,
2025-11-15 01:51:22 +05:30
};
} catch (error) {
return {
2025-12-02 13:24:58 +05:30
error: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`,
2025-11-15 01:51:22 +05:30
};
}
},
},
executeMcpTool: {
description: 'Execute a specific tool from an MCP server. Use this to run MCP tools on behalf of the user. IMPORTANT: Always use listMcpTools first to get the tool\'s inputSchema, then match the required parameters exactly in the arguments field.',
inputSchema: z.object({
serverName: z.string().describe('Name of the MCP server that provides the tool'),
toolName: z.string().describe('Name of the tool to execute'),
arguments: z.record(z.string(), z.any()).optional().describe('Arguments to pass to the tool (as key-value pairs matching the tool\'s input schema). MUST include all required parameters from the tool\'s inputSchema.'),
}),
execute: async ({ serverName, toolName, arguments: args = {} }: { serverName: string, toolName: string, arguments?: Record<string, any> }) => {
try {
2025-12-02 13:24:58 +05:30
const result = await executeTool(serverName, toolName, args);
return {
success: true,
serverName,
toolName,
2025-12-02 13:24:58 +05:30
result,
message: `Successfully executed tool '${toolName}' from server '${serverName}'`,
};
} catch (error) {
return {
success: false,
2025-12-02 13:24:58 +05:30
error: `Failed to execute MCP tool: ${error instanceof Error ? error.message : 'Unknown error'}`,
hint: 'Use listMcpTools to verify the tool exists and check its schema. Ensure all required parameters are provided in the arguments field.',
};
}
},
},
2025-11-15 01:51:22 +05:30
executeCommand: {
description: 'Execute a shell command and return the output. Use this to run bash/shell commands.',
inputSchema: z.object({
command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'),
cwd: z.string().optional().describe('Working directory to execute the command in (defaults to .rowboat directory)'),
}),
execute: async ({ command, cwd }: { command: string, cwd?: string }) => {
try {
const workingDir = cwd ? path.join(BASE_DIR, cwd) : BASE_DIR;
const result = await executeCommand(command, { cwd: workingDir });
return {
success: result.exitCode === 0,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
command,
workingDir,
};
} catch (error) {
return {
success: false,
message: `Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}`,
command,
};
}
},
},
};