feat(tools): add reusable execution contexts for shell, filesystem, and browser tools
This commit is contained in:
+21
-4
@@ -1,7 +1,24 @@
|
||||
export { filesystemTool } from './tool.filesystem.js';
|
||||
export type { IFilesystemToolOptions } from './tool.filesystem.js';
|
||||
export { shellTool } from './tool.shell.js';
|
||||
export type { IShellToolOptions } from './tool.shell.js';
|
||||
export { createBrowserTools } from './tool.browser.js';
|
||||
export type { ICreateBrowserToolsOptions } from './tool.browser.js';
|
||||
export { createFilesystemTools, filesystemTool } from './tool.filesystem.js';
|
||||
export type { ICreateFilesystemToolsOptions, IFilesystemToolOptions } from './tool.filesystem.js';
|
||||
export { createShellTools, shellTool } from './tool.shell.js';
|
||||
export type { ICreateShellToolsOptions, IShellToolOptions } from './tool.shell.js';
|
||||
export { createLocalToolExecutionContext, formatShellResult, formatToolOutput } from './tool.context.js';
|
||||
export type {
|
||||
IBrowserToolInput,
|
||||
ILocalToolExecutionContextOptions,
|
||||
IToolBrowserContext,
|
||||
IToolExecutionContext,
|
||||
IToolFilesystemContext,
|
||||
IToolFilesystemListOptions,
|
||||
IToolFilesystemReadOptions,
|
||||
IToolPermissionRequest,
|
||||
IToolRunOptions,
|
||||
IToolShellContext,
|
||||
IToolShellResult,
|
||||
TBrowserToolAction,
|
||||
} from './tool.context.js';
|
||||
export { httpTool } from './tool.http.js';
|
||||
export { jsonTool } from './tool.json.js';
|
||||
export { truncateOutput } from './plugins.js';
|
||||
|
||||
+4
-3
@@ -1,8 +1,9 @@
|
||||
// node native
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as childProcess from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export { path, fs };
|
||||
export { childProcess, fs, path };
|
||||
|
||||
// zod
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import {
|
||||
formatToolOutput,
|
||||
type IBrowserToolInput,
|
||||
type IToolExecutionContext,
|
||||
type TBrowserToolAction,
|
||||
} from './tool.context.js';
|
||||
|
||||
export interface ICreateBrowserToolsOptions {
|
||||
/** Maximum output lines before truncating. */
|
||||
maxLines?: number;
|
||||
/** Maximum output bytes before truncating. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
const browserActions = new Set<TBrowserToolAction>(['navigate', 'snapshot', 'screenshot', 'click', 'fill', 'press', 'evaluate', 'close']);
|
||||
|
||||
export function createBrowserTools(context: IToolExecutionContext, options: ICreateBrowserToolsOptions = {}): plugins.ToolSet {
|
||||
return {
|
||||
browser: plugins.tool({
|
||||
description: [
|
||||
'Control a browser supplied by the host execution context for web UI inspection and interaction.',
|
||||
'Actions: navigate, snapshot, screenshot, click, fill, press, evaluate, close.',
|
||||
'Use snapshot after navigation or interaction to inspect page text and interactive selectors before choosing the next action.',
|
||||
'Actions that navigate or modify page state require host permission when configured.',
|
||||
].join(' '),
|
||||
inputSchema: plugins.z.object({
|
||||
action: plugins.z.string().default('snapshot').describe('Action: navigate, snapshot, screenshot, click, fill, press, evaluate, or close'),
|
||||
url: plugins.z.string().optional().describe('URL for navigate'),
|
||||
selector: plugins.z.string().optional().describe('CSS or Playwright selector for click/fill'),
|
||||
text: plugins.z.string().optional().describe('Text for fill, key name for press, or screenshot mode/full-page hint'),
|
||||
script: plugins.z.string().optional().describe('JavaScript expression or function body for evaluate'),
|
||||
timeoutMs: plugins.z.number().optional().describe('Optional action timeout in milliseconds'),
|
||||
}),
|
||||
execute: async (input: IBrowserToolInput) => {
|
||||
if (!context.browser) {
|
||||
throw new Error('Browser tool is not available in this execution context.');
|
||||
}
|
||||
const action = normalizeBrowserAction(input.action);
|
||||
await requestBrowserPermission(context, { ...input, action });
|
||||
const result = await context.browser.execute({ ...input, action }, {
|
||||
timeoutMs: input.timeoutMs,
|
||||
abortSignal: context.abortSignal,
|
||||
});
|
||||
return plugins.truncateOutput(formatToolOutput(result), {
|
||||
maxLines: options.maxLines,
|
||||
maxBytes: options.maxBytes,
|
||||
}).content;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const normalizeBrowserAction = (input: unknown): TBrowserToolAction => {
|
||||
const action = typeof input === 'string' && input.trim()
|
||||
? input.trim().toLowerCase()
|
||||
: 'snapshot';
|
||||
if (browserActions.has(action as TBrowserToolAction)) {
|
||||
return action as TBrowserToolAction;
|
||||
}
|
||||
throw new Error(`Unsupported browser action: ${String(input)}. Use one of: ${[...browserActions].join(', ')}.`);
|
||||
};
|
||||
|
||||
const requestBrowserPermission = async (context: IToolExecutionContext, input: IBrowserToolInput & { action: TBrowserToolAction }): Promise<void> => {
|
||||
if (!context.requestPermission) return;
|
||||
if (input.action === 'snapshot' || input.action === 'screenshot') return;
|
||||
const titleByAction: Record<TBrowserToolAction, string> = {
|
||||
navigate: 'Navigate browser',
|
||||
snapshot: 'Inspect browser',
|
||||
screenshot: 'Capture browser screenshot',
|
||||
click: 'Click browser element',
|
||||
fill: 'Fill browser element',
|
||||
press: 'Press browser key',
|
||||
evaluate: 'Evaluate browser JavaScript',
|
||||
close: 'Close browser session',
|
||||
};
|
||||
await context.requestPermission({
|
||||
type: 'browser',
|
||||
title: titleByAction[input.action],
|
||||
metadata: {
|
||||
action: input.action,
|
||||
url: input.url,
|
||||
selector: input.selector,
|
||||
key: input.action === 'press' ? input.text : undefined,
|
||||
textLength: input.action === 'fill' ? input.text?.length ?? 0 : undefined,
|
||||
scriptPreview: input.action === 'evaluate' && input.script ? compactMetadataText(input.script) : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const compactMetadataText = (text: string): string => {
|
||||
const compacted = text.replace(/\s+/g, ' ').trim();
|
||||
return compacted.length > 160 ? `${compacted.slice(0, 157)}...` : compacted;
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export interface IToolPermissionRequest {
|
||||
type: string;
|
||||
title: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IToolRunOptions {
|
||||
cwd?: string;
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface IToolShellResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr?: string;
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
export interface IToolShellContext {
|
||||
run(command: string, options?: IToolRunOptions): Promise<IToolShellResult | string>;
|
||||
}
|
||||
|
||||
export interface IToolFilesystemReadOptions {
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}
|
||||
|
||||
export interface IToolFilesystemListOptions {
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
export interface IToolFilesystemContext {
|
||||
readFile(filePath: string, options?: IToolFilesystemReadOptions): Promise<string>;
|
||||
writeFile(filePath: string, content: string): Promise<string | void>;
|
||||
listDirectory(directoryPath: string, options?: IToolFilesystemListOptions): Promise<string[] | string>;
|
||||
deletePath?(targetPath: string): Promise<string | void>;
|
||||
}
|
||||
|
||||
export type TBrowserToolAction = 'navigate' | 'snapshot' | 'screenshot' | 'click' | 'fill' | 'press' | 'evaluate' | 'close';
|
||||
|
||||
export interface IBrowserToolInput {
|
||||
action?: TBrowserToolAction | string;
|
||||
url?: string;
|
||||
selector?: string;
|
||||
text?: string;
|
||||
script?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface IToolBrowserContext {
|
||||
execute(input: IBrowserToolInput, options?: { timeoutMs?: number; abortSignal?: AbortSignal }): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface IToolExecutionContext {
|
||||
cwd?: string;
|
||||
rootDir?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
shell?: IToolShellContext;
|
||||
fs?: IToolFilesystemContext;
|
||||
browser?: IToolBrowserContext;
|
||||
requestPermission?: (request: IToolPermissionRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ILocalToolExecutionContextOptions {
|
||||
cwd?: string;
|
||||
rootDir?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
requestPermission?: (request: IToolPermissionRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export const createLocalToolExecutionContext = (options: ILocalToolExecutionContextOptions = {}): IToolExecutionContext => {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const rootDir = options.rootDir;
|
||||
return {
|
||||
cwd,
|
||||
rootDir,
|
||||
abortSignal: options.abortSignal,
|
||||
requestPermission: options.requestPermission,
|
||||
shell: {
|
||||
run: (command, runOptions) => runLocalShellCommand(command, {
|
||||
cwd: resolveLocalPath(runOptions?.cwd ?? cwd, rootDir),
|
||||
timeoutMs: runOptions?.timeoutMs,
|
||||
abortSignal: runOptions?.abortSignal ?? options.abortSignal,
|
||||
}),
|
||||
},
|
||||
fs: {
|
||||
readFile: async (filePath, readOptions) => {
|
||||
const resolved = resolveLocalPath(filePath, rootDir, cwd);
|
||||
const content = await plugins.fs.promises.readFile(resolved, 'utf8');
|
||||
if (readOptions?.startLine !== undefined || readOptions?.endLine !== undefined) {
|
||||
const lines = content.split('\n');
|
||||
const start = Math.max((readOptions.startLine ?? 1) - 1, 0);
|
||||
const end = Math.max(readOptions.endLine ?? lines.length, start);
|
||||
return lines.slice(start, end).join('\n');
|
||||
}
|
||||
return content;
|
||||
},
|
||||
writeFile: async (filePath, content) => {
|
||||
const resolved = resolveLocalPath(filePath, rootDir, cwd);
|
||||
await plugins.fs.promises.mkdir(plugins.path.dirname(resolved), { recursive: true });
|
||||
await plugins.fs.promises.writeFile(resolved, content, 'utf8');
|
||||
return `Written ${Buffer.byteLength(content, 'utf8')} bytes to ${filePath}`;
|
||||
},
|
||||
listDirectory: async (directoryPath, listOptions) => {
|
||||
const resolved = resolveLocalPath(directoryPath, rootDir, cwd);
|
||||
return listLocalDirectory(resolved, !!listOptions?.recursive);
|
||||
},
|
||||
deletePath: async (targetPath) => {
|
||||
const resolved = resolveLocalPath(targetPath, rootDir, cwd);
|
||||
await plugins.fs.promises.rm(resolved, { recursive: false, force: false });
|
||||
return `Deleted ${targetPath}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const formatShellResult = (result: IToolShellResult | string): string => {
|
||||
if (typeof result === 'string') return result;
|
||||
if (result.exitCode === 0) return result.stdout;
|
||||
return [
|
||||
`Exit code: ${result.exitCode}`,
|
||||
result.signal ? `Signal: ${result.signal}` : '',
|
||||
`stdout:\n${result.stdout}`,
|
||||
`stderr:\n${result.stderr ?? ''}`,
|
||||
].filter(Boolean).join('\n');
|
||||
};
|
||||
|
||||
export const formatToolOutput = (output: unknown): string => {
|
||||
if (typeof output === 'string') return output;
|
||||
try {
|
||||
return JSON.stringify(output, undefined, 2);
|
||||
} catch {
|
||||
return String(output);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveLocalPath = (targetPath: string, rootDir?: string, baseDir?: string): string => {
|
||||
const base = rootDir ?? baseDir ?? process.cwd();
|
||||
const resolved = plugins.path.isAbsolute(targetPath)
|
||||
? plugins.path.resolve(targetPath)
|
||||
: plugins.path.resolve(base, targetPath);
|
||||
if (rootDir) {
|
||||
const resolvedRoot = plugins.path.resolve(rootDir);
|
||||
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + plugins.path.sep)) {
|
||||
throw new Error(`Access denied: "${targetPath}" is outside allowed root "${rootDir}"`);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const listLocalDirectory = async (directoryPath: string, recursive: boolean): Promise<string[]> => {
|
||||
const entries = await plugins.fs.promises.readdir(directoryPath, { withFileTypes: true });
|
||||
const result: string[] = [];
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const relativePath = entry.name + (entry.isDirectory() ? '/' : '');
|
||||
result.push(relativePath);
|
||||
if (recursive && entry.isDirectory()) {
|
||||
const childEntries = await listLocalDirectory(plugins.path.join(directoryPath, entry.name), true);
|
||||
result.push(...childEntries.map((childEntry) => `${entry.name}/${childEntry}`));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const runLocalShellCommand = async (command: string, options: IToolRunOptions): Promise<IToolShellResult> => {
|
||||
return new Promise<IToolShellResult>((resolve) => {
|
||||
const child = plugins.childProcess.spawn('bash', ['-lc', command], {
|
||||
cwd: options.cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
const timeout = options.timeoutMs && options.timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
}, options.timeoutMs)
|
||||
: undefined;
|
||||
const abort = () => child.kill('SIGTERM');
|
||||
options.abortSignal?.addEventListener('abort', abort, { once: true });
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
options.abortSignal?.removeEventListener('abort', abort);
|
||||
resolve({ exitCode: 1, stdout, stderr: `${stderr}${error.message}` });
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
options.abortSignal?.removeEventListener('abort', abort);
|
||||
resolve({
|
||||
exitCode: code ?? (timedOut ? 124 : 1),
|
||||
stdout,
|
||||
stderr: timedOut ? `${stderr}\nCommand timed out after ${options.timeoutMs}ms.`.trim() : stderr,
|
||||
signal: signal ?? undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
+80
-78
@@ -1,30 +1,43 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import {
|
||||
createLocalToolExecutionContext,
|
||||
formatToolOutput,
|
||||
type IToolExecutionContext,
|
||||
} from './tool.context.js';
|
||||
|
||||
export interface IFilesystemToolOptions {
|
||||
/** Restrict file access to this directory. Default: process.cwd() */
|
||||
rootDir?: string;
|
||||
/** Execution context. Defaults to a local Node.js context. */
|
||||
context?: IToolExecutionContext;
|
||||
/** Include delete_file. Default: true for compatibility. */
|
||||
includeDelete?: boolean;
|
||||
/** Maximum output lines before truncating. */
|
||||
maxLines?: number;
|
||||
/** Maximum output bytes before truncating. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
function validatePath(filePath: string, rootDir?: string): string {
|
||||
const resolved = plugins.path.resolve(filePath);
|
||||
if (rootDir) {
|
||||
const resolvedRoot = plugins.path.resolve(rootDir);
|
||||
if (!resolved.startsWith(resolvedRoot + plugins.path.sep) && resolved !== resolvedRoot) {
|
||||
throw new Error(`Access denied: "${filePath}" is outside allowed root "${rootDir}"`);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
export interface ICreateFilesystemToolsOptions {
|
||||
/** Include delete_file. Default: true. */
|
||||
includeDelete?: boolean;
|
||||
/** Maximum output lines before truncating. */
|
||||
maxLines?: number;
|
||||
/** Maximum output bytes before truncating. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSet {
|
||||
const rootDir = options?.rootDir;
|
||||
|
||||
return {
|
||||
export function createFilesystemTools(context: IToolExecutionContext, options: ICreateFilesystemToolsOptions = {}): plugins.ToolSet {
|
||||
const truncate = (output: unknown) => plugins.truncateOutput(formatToolOutput(output), {
|
||||
maxLines: options.maxLines,
|
||||
maxBytes: options.maxBytes,
|
||||
}).content;
|
||||
const tools: plugins.ToolSet = {
|
||||
read_file: plugins.tool({
|
||||
description:
|
||||
'Read file contents. Returns the full text or a specified line range.',
|
||||
'Read a UTF-8 file in the active workspace. Paths may be absolute or relative to the workspace root.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Absolute path to the file'),
|
||||
path: plugins.z.string().describe('File path'),
|
||||
startLine: plugins.z
|
||||
.number()
|
||||
.optional()
|
||||
@@ -43,89 +56,78 @@ export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSe
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}) => {
|
||||
const resolved = validatePath(filePath, rootDir);
|
||||
const content = plugins.fs.readFileSync(resolved, 'utf-8');
|
||||
|
||||
if (startLine !== undefined || endLine !== undefined) {
|
||||
const lines = content.split('\n');
|
||||
const start = (startLine ?? 1) - 1;
|
||||
const end = endLine ?? lines.length;
|
||||
const sliced = lines.slice(start, end).join('\n');
|
||||
return plugins.truncateOutput(sliced).content;
|
||||
if (!context.fs) {
|
||||
throw new Error('Filesystem tools are not available in this execution context.');
|
||||
}
|
||||
|
||||
return plugins.truncateOutput(content).content;
|
||||
return truncate(await context.fs.readFile(filePath, { startLine, endLine }));
|
||||
},
|
||||
}),
|
||||
|
||||
write_file: plugins.tool({
|
||||
description:
|
||||
'Write content to a file (creates parent dirs if needed, overwrites existing).',
|
||||
'Write UTF-8 content to a file in the active workspace. Creates parent directories and overwrites existing content. Requires host permission when configured.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Absolute path to the file'),
|
||||
content: plugins.z.string().describe('Content to write'),
|
||||
path: plugins.z.string().describe('File path'),
|
||||
content: plugins.z.string().describe('Complete file content to write'),
|
||||
}),
|
||||
execute: async ({ path: filePath, content }: { path: string; content: string }) => {
|
||||
const resolved = validatePath(filePath, rootDir);
|
||||
const dir = plugins.path.dirname(resolved);
|
||||
plugins.fs.mkdirSync(dir, { recursive: true });
|
||||
plugins.fs.writeFileSync(resolved, content, 'utf-8');
|
||||
return `Written ${content.length} characters to ${filePath}`;
|
||||
if (!context.fs) {
|
||||
throw new Error('Filesystem tools are not available in this execution context.');
|
||||
}
|
||||
await context.requestPermission?.({
|
||||
type: 'write',
|
||||
title: 'Write file',
|
||||
metadata: { path: filePath, bytes: Buffer.byteLength(content, 'utf8') },
|
||||
});
|
||||
const result = await context.fs.writeFile(filePath, content);
|
||||
return truncate(result ?? `Written ${Buffer.byteLength(content, 'utf8')} bytes to ${filePath}`);
|
||||
},
|
||||
}),
|
||||
|
||||
list_directory: plugins.tool({
|
||||
description: 'List files and directories at the given path.',
|
||||
description: 'List files and directories in the active workspace. Paths may be absolute or relative to the workspace root.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Directory path to list'),
|
||||
path: plugins.z.string().default('.').describe('Directory path to list'),
|
||||
recursive: plugins.z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('List recursively (default: false)'),
|
||||
.describe('List recursively. Default: false'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: dirPath,
|
||||
recursive,
|
||||
}: {
|
||||
path: string;
|
||||
recursive?: boolean;
|
||||
}) => {
|
||||
const resolved = validatePath(dirPath, rootDir);
|
||||
|
||||
function listDir(dir: string, prefix: string = ''): string[] {
|
||||
const entries = plugins.fs.readdirSync(dir, { withFileTypes: true });
|
||||
const result: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const indicator = entry.isDirectory() ? '/' : '';
|
||||
result.push(`${rel}${indicator}`);
|
||||
if (recursive && entry.isDirectory()) {
|
||||
result.push(...listDir(plugins.path.join(dir, entry.name), rel));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
execute: async ({ path: directoryPath, recursive }: { path: string; recursive?: boolean }) => {
|
||||
if (!context.fs) {
|
||||
throw new Error('Filesystem tools are not available in this execution context.');
|
||||
}
|
||||
|
||||
const entries = listDir(resolved);
|
||||
return plugins.truncateOutput(entries.join('\n')).content;
|
||||
},
|
||||
}),
|
||||
|
||||
delete_file: plugins.tool({
|
||||
description: 'Delete a file or empty directory.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Path to delete'),
|
||||
}),
|
||||
execute: async ({ path: filePath }: { path: string }) => {
|
||||
const resolved = validatePath(filePath, rootDir);
|
||||
const stat = plugins.fs.statSync(resolved);
|
||||
if (stat.isDirectory()) {
|
||||
plugins.fs.rmdirSync(resolved);
|
||||
} else {
|
||||
plugins.fs.unlinkSync(resolved);
|
||||
}
|
||||
return `Deleted ${filePath}`;
|
||||
const result = await context.fs.listDirectory(directoryPath, { recursive });
|
||||
return truncate(Array.isArray(result) ? result.join('\n') : result);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
if (options.includeDelete !== false) {
|
||||
tools.delete_file = plugins.tool({
|
||||
description: 'Delete a file or empty directory in the active workspace. Requires host permission when configured.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Path to delete'),
|
||||
}),
|
||||
execute: async ({ path: targetPath }: { path: string }) => {
|
||||
if (!context.fs?.deletePath) {
|
||||
throw new Error('Deleting files is not available in this execution context.');
|
||||
}
|
||||
await context.requestPermission?.({
|
||||
type: 'delete',
|
||||
title: 'Delete file',
|
||||
metadata: { path: targetPath },
|
||||
});
|
||||
const result = await context.fs.deletePath(targetPath);
|
||||
return truncate(result ?? `Deleted ${targetPath}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSet {
|
||||
const context = options?.context ?? createLocalToolExecutionContext({ rootDir: options?.rootDir });
|
||||
return createFilesystemTools(context, options);
|
||||
}
|
||||
|
||||
+51
-17
@@ -1,19 +1,37 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import {
|
||||
createLocalToolExecutionContext,
|
||||
formatShellResult,
|
||||
type IToolExecutionContext,
|
||||
} from './tool.context.js';
|
||||
|
||||
export interface IShellToolOptions {
|
||||
/** Allowed commands whitelist. If empty, all commands are allowed. */
|
||||
allowedCommands?: string[];
|
||||
/** Working directory for shell execution */
|
||||
cwd?: string;
|
||||
/** Execution context. Defaults to a local Node.js context. */
|
||||
context?: IToolExecutionContext;
|
||||
/** Maximum output lines before truncating. */
|
||||
maxLines?: number;
|
||||
/** Maximum output bytes before truncating. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
|
||||
const smartshell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
export interface ICreateShellToolsOptions {
|
||||
/** Allowed commands whitelist. If empty, all commands are allowed. */
|
||||
allowedCommands?: string[];
|
||||
/** Maximum output lines before truncating. */
|
||||
maxLines?: number;
|
||||
/** Maximum output bytes before truncating. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export function createShellTools(context: IToolExecutionContext, options: ICreateShellToolsOptions = {}): plugins.ToolSet {
|
||||
return {
|
||||
run_command: plugins.tool({
|
||||
description:
|
||||
'Execute a shell command. Provide the full command string. stdout and stderr are returned.',
|
||||
'Execute a shell command in the active workspace. Provide the full command string. stdout and stderr are returned.',
|
||||
inputSchema: plugins.z.object({
|
||||
command: plugins.z.string().describe('The shell command to execute'),
|
||||
cwd: plugins.z
|
||||
@@ -24,39 +42,55 @@ export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Timeout in milliseconds'),
|
||||
timeoutMs: plugins.z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Timeout in milliseconds'),
|
||||
}),
|
||||
execute: async ({
|
||||
command,
|
||||
cwd,
|
||||
timeout,
|
||||
timeoutMs,
|
||||
}: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
timeoutMs?: number;
|
||||
}) => {
|
||||
// Validate against allowed commands whitelist
|
||||
if (options?.allowedCommands?.length) {
|
||||
if (!context.shell) {
|
||||
throw new Error('Shell tool is not available in this execution context.');
|
||||
}
|
||||
|
||||
if (options.allowedCommands?.length) {
|
||||
const baseCommand = command.split(/\s+/)[0];
|
||||
if (!options.allowedCommands.includes(baseCommand)) {
|
||||
return `Command "${baseCommand}" is not in the allowed commands list: ${options.allowedCommands.join(', ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build full command string with cd prefix if cwd specified
|
||||
const effectiveCwd = cwd ?? options?.cwd;
|
||||
const fullCommand = effectiveCwd
|
||||
? `cd ${JSON.stringify(effectiveCwd)} && ${command}`
|
||||
: command;
|
||||
await context.requestPermission?.({
|
||||
type: 'shell',
|
||||
title: 'Run shell command',
|
||||
metadata: { command, cwd: cwd ?? context.cwd },
|
||||
});
|
||||
|
||||
const execResult = await smartshell.exec(fullCommand);
|
||||
const execResult = await context.shell.run(command, {
|
||||
cwd: cwd ?? context.cwd,
|
||||
timeoutMs: timeoutMs ?? timeout,
|
||||
abortSignal: context.abortSignal,
|
||||
});
|
||||
|
||||
const output =
|
||||
execResult.exitCode === 0
|
||||
? execResult.stdout
|
||||
: `Exit code: ${execResult.exitCode}\nstdout:\n${execResult.stdout}\nstderr:\n${execResult.stderr ?? ''}`;
|
||||
|
||||
return plugins.truncateOutput(output).content;
|
||||
return plugins.truncateOutput(formatShellResult(execResult), {
|
||||
maxLines: options.maxLines,
|
||||
maxBytes: options.maxBytes,
|
||||
}).content;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
|
||||
const context = options?.context ?? createLocalToolExecutionContext({ cwd: options?.cwd });
|
||||
return createShellTools(context, options);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user