- tools/*.ts: Replace fs.readFileSync config reads with getConfig() + resolveSecret() (pubmed, legal, scholar, weather, email, web, files — 7 files) - tools: Remove direct getVault() calls in email.ts and weather.ts, use resolveSecret() - gateway: Extract 21 /api/settings/* routes from server-v2.ts into routes/settings.ts (server-v2.ts reduced by ~375 lines) - types: Add missing config fields (legal, semantic_scholar, openweather, weather, email, search.pubmed_api_key) - web-ui: Split index.html (13,879 lines) into index.html (1,934) + styles.css (2,530) + app.js (9,416) - web-ui: Allow static assets to bypass auth middleware - Remove empty index2.html template stub Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
716 lines
23 KiB
TypeScript
716 lines
23 KiB
TypeScript
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import fsSync from 'fs';
|
|
import os from 'os';
|
|
import { execFile } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import { getConfig } from '../config/config.js';
|
|
import { ToolResult } from '../types.js';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const PATCH_OUTPUT_MAX_CHARS = 8000;
|
|
|
|
// Helper function to check if path is allowed
|
|
function resolveWorkspacePath(targetPath: string, workspacePath?: string): string {
|
|
const workspace = workspacePath || getConfig().getConfig().workspace.path;
|
|
if (path.isAbsolute(targetPath)) return targetPath;
|
|
return path.join(workspace, targetPath);
|
|
}
|
|
|
|
function getSessionWorkspace(args: any): string {
|
|
return args?._workspacePath || args?._workspace || getConfig().getConfig().workspace.path;
|
|
}
|
|
|
|
function normalizePathForCompare(p: string): string {
|
|
const resolved = path.resolve(String(p || ''));
|
|
if (process.platform === 'win32') return resolved.toLowerCase();
|
|
return resolved;
|
|
}
|
|
|
|
function isPathInside(basePath: string, targetPath: string): boolean {
|
|
const base = normalizePathForCompare(basePath);
|
|
const target = normalizePathForCompare(targetPath);
|
|
if (!base || !target) return false;
|
|
const rel = path.relative(base, target);
|
|
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
}
|
|
|
|
function isPathAllowed(targetPath: string): { allowed: boolean; reason?: string } {
|
|
const config = getConfig().getConfig();
|
|
const permissions = config.tools.permissions.files;
|
|
const absPath = path.resolve(String(targetPath || ''));
|
|
|
|
// Check blocked paths
|
|
for (const blocked of permissions.blocked_paths) {
|
|
if (isPathInside(blocked, absPath)) {
|
|
return {
|
|
allowed: false,
|
|
reason: `Path is in blocked directory: ${blocked}`
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check allowed paths
|
|
const isInAllowedPath = permissions.allowed_paths.some(allowed =>
|
|
isPathInside(allowed, absPath)
|
|
);
|
|
|
|
if (!isInAllowedPath) {
|
|
return {
|
|
allowed: false,
|
|
reason: `Path is not in any allowed directory. Allowed: ${permissions.allowed_paths.join(', ')}`
|
|
};
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|
|
|
|
function truncateOutput(text: string): string {
|
|
const t = String(text || '').trim();
|
|
if (!t) return '';
|
|
if (t.length <= PATCH_OUTPUT_MAX_CHARS) return t;
|
|
return `${t.slice(0, PATCH_OUTPUT_MAX_CHARS)} ...[truncated]`;
|
|
}
|
|
|
|
function countSkippedPatches(text: string): number {
|
|
const src = String(text || '')
|
|
.replace(/\x1b\[[0-9;]*m/g, '');
|
|
if (!src) return 0;
|
|
const matches = src.match(/Skipped patch\b/gi);
|
|
return matches ? matches.length : 0;
|
|
}
|
|
|
|
function parsePatchPathToken(raw: string): string {
|
|
const trimmed = String(raw || '').trim();
|
|
if (!trimmed) return '';
|
|
if (trimmed.startsWith('"')) {
|
|
const m = trimmed.match(/^"([^"]+)"/);
|
|
return m?.[1] || '';
|
|
}
|
|
return trimmed.split(/\s+/)[0] || '';
|
|
}
|
|
|
|
function normalizePatchPath(rawPath: string): string {
|
|
let p = String(rawPath || '').trim();
|
|
if (!p || p === '/dev/null') return '';
|
|
if (p.startsWith('a/') || p.startsWith('b/')) p = p.slice(2);
|
|
return p;
|
|
}
|
|
|
|
function extractPatchTargetPaths(patchText: string): string[] {
|
|
const paths = new Set<string>();
|
|
const lines = String(patchText || '').split('\n');
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('diff --git ')) {
|
|
const m = line.match(/^diff --git\s+(?:"([^"]+)"|(\S+))\s+(?:"([^"]+)"|(\S+))/);
|
|
const left = normalizePatchPath(m?.[1] || m?.[2] || '');
|
|
const right = normalizePatchPath(m?.[3] || m?.[4] || '');
|
|
if (left) paths.add(left);
|
|
if (right) paths.add(right);
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('--- ') || line.startsWith('+++ ')) {
|
|
const token = parsePatchPathToken(line.slice(4));
|
|
const normalized = normalizePatchPath(token);
|
|
if (normalized) paths.add(normalized);
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('rename from ')) {
|
|
const fromPath = normalizePatchPath(line.slice('rename from '.length));
|
|
if (fromPath) paths.add(fromPath);
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('rename to ')) {
|
|
const toPath = normalizePatchPath(line.slice('rename to '.length));
|
|
if (toPath) paths.add(toPath);
|
|
}
|
|
}
|
|
|
|
return Array.from(paths);
|
|
}
|
|
|
|
function validatePatchPaths(paths: string[]): { ok: true; relativePaths: string[] } | { ok: false; error: string } {
|
|
if (!Array.isArray(paths) || paths.length === 0) {
|
|
return { ok: false, error: 'No target paths found in patch. Include standard unified diff headers (---/+++).' };
|
|
}
|
|
|
|
const unique = Array.from(new Set(paths.map(p => String(p || '').trim()).filter(Boolean)));
|
|
for (const relPath of unique) {
|
|
if (path.isAbsolute(relPath)) {
|
|
return { ok: false, error: `Patch path must be relative: ${relPath}` };
|
|
}
|
|
const absPath = resolveWorkspacePath(relPath);
|
|
const pathCheck = isPathAllowed(absPath);
|
|
if (!pathCheck.allowed) {
|
|
return { ok: false, error: `Patch path not allowed (${relPath}): ${pathCheck.reason}` };
|
|
}
|
|
}
|
|
|
|
return { ok: true, relativePaths: unique };
|
|
}
|
|
|
|
async function runGitApply(workspacePath: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
const out = await execFileAsync('git', args, {
|
|
cwd: workspacePath,
|
|
windowsHide: true,
|
|
maxBuffer: 8 * 1024 * 1024,
|
|
encoding: 'utf8',
|
|
} as any);
|
|
return {
|
|
stdout: String((out as any)?.stdout || ''),
|
|
stderr: String((out as any)?.stderr || ''),
|
|
};
|
|
}
|
|
|
|
// READ TOOL
|
|
export interface ReadToolArgs {
|
|
path: string;
|
|
start_line?: number;
|
|
num_lines?: number;
|
|
_workspacePath?: string;
|
|
_workspace?: string;
|
|
}
|
|
|
|
type RetrievalMode = 'fast' | 'standard' | 'deep';
|
|
|
|
let _retrievalModeCache: { mode: RetrievalMode; expiresAt: number } | null = null;
|
|
|
|
function getRetrievalMode(): RetrievalMode {
|
|
const now = Date.now();
|
|
if (_retrievalModeCache && now < _retrievalModeCache.expiresAt) return _retrievalModeCache.mode;
|
|
let mode: RetrievalMode = 'standard';
|
|
try {
|
|
const cfg = getConfig().getConfig();
|
|
const m = String(cfg?.agent_policy?.retrieval_mode || 'standard').toLowerCase();
|
|
if (m === 'fast' || m === 'deep') mode = m;
|
|
} catch {}
|
|
_retrievalModeCache = { mode, expiresAt: now + 60_000 };
|
|
return mode;
|
|
}
|
|
|
|
function retrievalMaxLines(mode: RetrievalMode): number {
|
|
if (mode === 'fast') return 120;
|
|
if (mode === 'deep') return 480;
|
|
return 240;
|
|
}
|
|
|
|
export async function executeRead(args: ReadToolArgs): Promise<ToolResult> {
|
|
try {
|
|
const workspacePath = getSessionWorkspace(args);
|
|
const absPath = resolveWorkspacePath(args.path, workspacePath);
|
|
const pathCheck = isPathAllowed(absPath);
|
|
if (!pathCheck.allowed) {
|
|
return {
|
|
success: false,
|
|
error: pathCheck.reason
|
|
};
|
|
}
|
|
const content = await fs.readFile(absPath, 'utf-8');
|
|
const allLines = content.split('\n');
|
|
const mode = getRetrievalMode();
|
|
const cap = retrievalMaxLines(mode);
|
|
const startLine = Math.max(1, Number(args.start_line || 1) || 1);
|
|
const requested = Math.max(1, Number(args.num_lines || cap) || cap);
|
|
const window = Math.min(requested, cap);
|
|
const startIdx = Math.max(0, startLine - 1);
|
|
const selected = allLines.slice(startIdx, startIdx + window);
|
|
const outContent = selected.join('\n');
|
|
const endLine = startLine + selected.length - 1;
|
|
const truncated = (allLines.length > selected.length) || startLine > 1 || requested > cap;
|
|
return {
|
|
success: true,
|
|
data: {
|
|
path: absPath,
|
|
content: outContent,
|
|
size: outContent.length,
|
|
lines: allLines.length,
|
|
window: {
|
|
retrieval_mode: mode,
|
|
start_line: startLine,
|
|
end_line: endLine,
|
|
returned_lines: selected.length,
|
|
max_lines_cap: cap,
|
|
truncated,
|
|
},
|
|
}
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
error: `Failed to read file: ${error.message}`
|
|
};
|
|
}
|
|
}
|
|
|
|
// WRITE TOOL
|
|
export interface WriteToolArgs {
|
|
path: string;
|
|
content: string;
|
|
_workspacePath?: string;
|
|
_workspace?: string;
|
|
}
|
|
|
|
export async function executeWrite(args: WriteToolArgs): Promise<ToolResult> {
|
|
try {
|
|
if (!args || typeof args.path !== 'string' || !args.path.trim()) {
|
|
return {
|
|
success: false,
|
|
error: 'path is required'
|
|
};
|
|
}
|
|
if (typeof (args as any).content !== 'string') {
|
|
return {
|
|
success: false,
|
|
error: 'content must be a string'
|
|
};
|
|
}
|
|
const workspacePath = getSessionWorkspace(args);
|
|
const absPath = resolveWorkspacePath(args.path, workspacePath);
|
|
const pathCheck = isPathAllowed(absPath);
|
|
if (!pathCheck.allowed) {
|
|
return {
|
|
success: false,
|
|
error: pathCheck.reason
|
|
};
|
|
}
|
|
// Ensure directory exists
|
|
const dir = path.dirname(absPath);
|
|
await fs.mkdir(dir, { recursive: true });
|
|
// Write file
|
|
await fs.writeFile(absPath, args.content, 'utf-8');
|
|
return {
|
|
success: true,
|
|
data: {
|
|
path: absPath,
|
|
size: args.content.length,
|
|
lines: args.content.split('\n').length
|
|
}
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
error: `Failed to write file: ${error.message}`
|
|
};
|
|
}
|
|
}
|
|
|
|
// EDIT TOOL (find and replace)
|
|
export interface EditToolArgs {
|
|
path: string;
|
|
old_str: string;
|
|
new_str: string;
|
|
_workspacePath?: string;
|
|
_workspace?: string;
|
|
}
|
|
|
|
export async function executeEdit(args: EditToolArgs): Promise<ToolResult> {
|
|
try {
|
|
const workspacePath = getSessionWorkspace(args);
|
|
const absPath = resolveWorkspacePath(args.path, workspacePath);
|
|
const pathCheck = isPathAllowed(absPath);
|
|
if (!pathCheck.allowed) {
|
|
return {
|
|
success: false,
|
|
error: pathCheck.reason
|
|
};
|
|
}
|
|
// Read current content
|
|
const content = await fs.readFile(absPath, 'utf-8');
|
|
// Check if old_str exists
|
|
if (!content.includes(args.old_str)) {
|
|
return {
|
|
success: false,
|
|
error: `String not found in file: "${args.old_str.slice(0, 50)}..."`
|
|
};
|
|
}
|
|
// Count occurrences
|
|
const occurrences = (content.match(new RegExp(args.old_str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
|
if (occurrences > 1) {
|
|
return {
|
|
success: false,
|
|
error: `String appears ${occurrences} times in file. For safety, it must appear exactly once. Please be more specific.`
|
|
};
|
|
}
|
|
// Perform replacement
|
|
const newContent = content.replace(args.old_str, args.new_str);
|
|
// Write back
|
|
await fs.writeFile(absPath, newContent, 'utf-8');
|
|
return {
|
|
success: true,
|
|
data: {
|
|
path: absPath,
|
|
replacements: 1,
|
|
old_length: content.length,
|
|
new_length: newContent.length,
|
|
diff: newContent.length - content.length
|
|
}
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
error: `Failed to edit file: ${error.message}`
|
|
};
|
|
}
|
|
}
|
|
|
|
// LIST DIRECTORY TOOL
|
|
export interface ListToolArgs {
|
|
path: string;
|
|
_workspacePath?: string;
|
|
_workspace?: string;
|
|
}
|
|
|
|
export async function executeList(args: ListToolArgs): Promise<ToolResult> {
|
|
try {
|
|
const workspacePath = getSessionWorkspace(args);
|
|
const absPath = resolveWorkspacePath(args.path, workspacePath);
|
|
const pathCheck = isPathAllowed(absPath);
|
|
if (!pathCheck.allowed) {
|
|
return {
|
|
success: false,
|
|
error: pathCheck.reason
|
|
};
|
|
}
|
|
|
|
const entries = await fs.readdir(absPath, { withFileTypes: true });
|
|
|
|
const files = entries
|
|
.filter(e => e.isFile())
|
|
.map(e => e.name);
|
|
|
|
const directories = entries
|
|
.filter(e => e.isDirectory())
|
|
.map(e => e.name);
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
path: absPath,
|
|
files,
|
|
directories,
|
|
total: entries.length
|
|
}
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
error: `Failed to list directory: ${error.message}`
|
|
};
|
|
}
|
|
}
|
|
|
|
// Tool exports
|
|
export const readTool = {
|
|
name: 'read',
|
|
description: 'Read file contents (snippet-windowed by retrieval mode caps)',
|
|
execute: executeRead,
|
|
schema: {
|
|
path: 'string (required) - Path to the file to read',
|
|
start_line: 'number (optional) - 1-based starting line (default 1)',
|
|
num_lines: 'number (optional) - number of lines to return (capped by retrieval mode)',
|
|
}
|
|
};
|
|
|
|
export const writeTool = {
|
|
name: 'write',
|
|
description: 'Create or overwrite a file',
|
|
execute: executeWrite,
|
|
schema: {
|
|
path: 'string (required) - Path to the file',
|
|
content: 'string (required) - File contents'
|
|
}
|
|
};
|
|
|
|
export const editTool = {
|
|
name: 'edit',
|
|
description: 'Edit a file by replacing text (string must appear exactly once)',
|
|
execute: executeEdit,
|
|
schema: {
|
|
path: 'string (required) - Path to the file',
|
|
old_str: 'string (required) - Text to find (must appear exactly once)',
|
|
new_str: 'string (required) - Replacement text'
|
|
}
|
|
};
|
|
|
|
export const listTool = {
|
|
name: 'list',
|
|
description: 'List files and directories',
|
|
execute: executeList,
|
|
schema: {
|
|
path: 'string (required) - Path to directory'
|
|
}
|
|
};
|
|
|
|
// ── DELETE ────────────────────────────────────────────────────────────────────
|
|
import { rmSync, existsSync } from 'fs';
|
|
|
|
async function executeDelete(args: { path: string; recursive?: boolean; _workspacePath?: string; _workspace?: string }): Promise<ToolResult> {
|
|
if (!args.path?.trim()) return { success: false, error: 'path is required' };
|
|
const workspacePath = getSessionWorkspace(args);
|
|
const absPath = resolveWorkspacePath(args.path, workspacePath);
|
|
if (!existsSync(absPath)) return { success: false, error: `Path does not exist: ${absPath}` };
|
|
try {
|
|
rmSync(absPath, { recursive: args.recursive ?? false, force: true });
|
|
return { success: true, stdout: `Deleted: ${absPath}` };
|
|
} catch (err: any) {
|
|
return { success: false, error: `Delete failed: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
export const deleteTool = {
|
|
name: 'delete',
|
|
description: 'Delete a file or directory',
|
|
execute: executeDelete,
|
|
schema: {
|
|
path: 'string (required) - Path to delete',
|
|
recursive: 'boolean (optional) - Delete directories recursively (default false)'
|
|
}
|
|
};
|
|
|
|
// ── RENAME / MOVE ───────────────────────────────────────────────────────────
|
|
export interface RenameArgs {
|
|
path: string;
|
|
new_path: string;
|
|
_workspacePath?: string;
|
|
_workspace?: string;
|
|
}
|
|
export async function executeRename(args: RenameArgs): Promise<ToolResult> {
|
|
try {
|
|
const workspacePath = getSessionWorkspace(args);
|
|
const src = resolveWorkspacePath(args.path, workspacePath);
|
|
const dest = resolveWorkspacePath(args.new_path, workspacePath);
|
|
const srcCheck = isPathAllowed(src);
|
|
const destCheck = isPathAllowed(dest);
|
|
if (!srcCheck.allowed) return { success: false, error: srcCheck.reason };
|
|
if (!destCheck.allowed) return { success: false, error: destCheck.reason };
|
|
// Ensure source exists
|
|
if (!(await fs.stat(src).catch(() => null))) {
|
|
return { success: false, error: `Source does not exist: ${src}` };
|
|
}
|
|
// Ensure destination dir
|
|
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
await fs.rename(src, dest);
|
|
return { success: true, data: { from: src, to: dest } };
|
|
} catch (err: any) {
|
|
return { success: false, error: `Rename failed: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
export const renameTool = {
|
|
name: 'rename',
|
|
description: 'Rename or move a file/directory',
|
|
execute: executeRename,
|
|
schema: {
|
|
path: 'string (required) - Existing path',
|
|
new_path: 'string (required) - New path'
|
|
}
|
|
};
|
|
|
|
// ── COPY ─────────────────────────────────────────────────────────────────────
|
|
export interface CopyArgs {
|
|
path: string;
|
|
dest: string;
|
|
_workspacePath?: string;
|
|
_workspace?: string;
|
|
}
|
|
export async function executeCopy(args: CopyArgs): Promise<ToolResult> {
|
|
try {
|
|
const workspacePath = getSessionWorkspace(args);
|
|
const src = resolveWorkspacePath(args.path, workspacePath);
|
|
const dest = resolveWorkspacePath(args.dest, workspacePath);
|
|
const srcCheck = isPathAllowed(src);
|
|
const destCheck = isPathAllowed(dest);
|
|
if (!srcCheck.allowed) return { success: false, error: srcCheck.reason };
|
|
if (!destCheck.allowed) return { success: false, error: destCheck.reason };
|
|
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
await fs.copyFile(src, dest);
|
|
return { success: true, data: { from: src, to: dest } };
|
|
} catch (err: any) {
|
|
return { success: false, error: `Copy failed: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
export const copyTool = {
|
|
name: 'copy',
|
|
description: 'Copy a file',
|
|
execute: executeCopy,
|
|
schema: {
|
|
path: 'string (required) - Source file',
|
|
dest: 'string (required) - Destination path'
|
|
}
|
|
};
|
|
|
|
// ── MKDIR ────────────────────────────────────────────────────────────────────
|
|
export interface MkdirArgs {
|
|
path: string;
|
|
recursive?: boolean;
|
|
_workspacePath?: string;
|
|
_workspace?: string;
|
|
}
|
|
export async function executeMkdir(args: MkdirArgs & { _workspacePath?: string; _workspace?: string }): Promise<ToolResult> {
|
|
try {
|
|
const workspacePath = getSessionWorkspace(args);
|
|
const abs = resolveWorkspacePath(args.path, workspacePath);
|
|
const pathCheck = isPathAllowed(abs);
|
|
if (!pathCheck.allowed) return { success: false, error: pathCheck.reason };
|
|
await fs.mkdir(abs, { recursive: args.recursive ?? true });
|
|
return { success: true, data: { path: abs } };
|
|
} catch (err: any) {
|
|
return { success: false, error: `Mkdir failed: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
export const mkdirTool = {
|
|
name: 'mkdir',
|
|
description: 'Create a directory',
|
|
execute: executeMkdir,
|
|
schema: {
|
|
path: 'string (required) - Directory path',
|
|
recursive: 'boolean (optional) - Create parents'
|
|
}
|
|
};
|
|
|
|
// ── STAT / INFO ──────────────────────────────────────────────────────────────
|
|
export interface StatArgs {
|
|
path: string;
|
|
_workspacePath?: string;
|
|
_workspace?: string;
|
|
}
|
|
export async function executeStat(args: StatArgs): Promise<ToolResult> {
|
|
try {
|
|
const workspacePath = getSessionWorkspace(args);
|
|
const abs = resolveWorkspacePath(args.path, workspacePath);
|
|
const pathCheck = isPathAllowed(abs);
|
|
if (!pathCheck.allowed) return { success: false, error: pathCheck.reason };
|
|
const st = await fs.stat(abs);
|
|
return { success: true, data: { path: abs, size: st.size, mtime: st.mtime, isFile: st.isFile(), isDirectory: st.isDirectory() } };
|
|
} catch (err: any) {
|
|
return { success: false, error: `Stat failed: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
export const statTool = {
|
|
name: 'stat',
|
|
description: 'Get file info',
|
|
execute: executeStat,
|
|
schema: {
|
|
path: 'string (required) - Path to file or directory'
|
|
}
|
|
};
|
|
|
|
// ── APPEND ───────────────────────────────────────────────────────────────────
|
|
export interface AppendArgs {
|
|
path: string;
|
|
content: string;
|
|
_workspacePath?: string;
|
|
_workspace?: string;
|
|
}
|
|
export async function executeAppend(args: AppendArgs): Promise<ToolResult> {
|
|
try {
|
|
const workspacePath = getSessionWorkspace(args);
|
|
const abs = resolveWorkspacePath(args.path, workspacePath);
|
|
const pathCheck = isPathAllowed(abs);
|
|
if (!pathCheck.allowed) return { success: false, error: pathCheck.reason };
|
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
await fs.appendFile(abs, args.content, 'utf-8');
|
|
return { success: true, data: { path: abs } };
|
|
} catch (err: any) {
|
|
return { success: false, error: `Append failed: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
export const appendTool = {
|
|
name: 'append',
|
|
description: 'Append text to a file (creates file if missing)',
|
|
execute: executeAppend,
|
|
schema: {
|
|
path: 'string (required) - Path to file',
|
|
content: 'string (required) - Text to append'
|
|
}
|
|
};
|
|
|
|
// ── APPLY PATCH ────────────────────────────────────────────────────────────────
|
|
export interface ApplyPatchArgs {
|
|
patch: string;
|
|
check?: boolean;
|
|
_workspacePath?: string;
|
|
_workspace?: string;
|
|
}
|
|
|
|
export async function executeApplyPatch(args: ApplyPatchArgs): Promise<ToolResult> {
|
|
const patchText = String(args?.patch || '');
|
|
if (!patchText.trim()) {
|
|
return { success: false, error: 'patch is required (unified diff string).' };
|
|
}
|
|
|
|
const targetPaths = extractPatchTargetPaths(patchText);
|
|
const validation = validatePatchPaths(targetPaths);
|
|
if (!validation.ok) return { success: false, error: validation.error };
|
|
|
|
const workspacePath = args._workspacePath || args._workspace || getConfig().getConfig().workspace.path;
|
|
const tempPatchPath = path.join(
|
|
os.tmpdir(),
|
|
`smallclaw-apply-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`
|
|
);
|
|
|
|
try {
|
|
await fs.writeFile(tempPatchPath, patchText, 'utf-8');
|
|
const checked = await runGitApply(workspacePath, ['apply', '--check', '--whitespace=nowarn', '--recount', '--verbose', tempPatchPath]);
|
|
const checkedOutput = [checked.stdout, checked.stderr].filter(Boolean).join('\n');
|
|
const skippedOnCheck = countSkippedPatches(checkedOutput);
|
|
if (skippedOnCheck >= validation.relativePaths.length) {
|
|
const msg = truncateOutput(checkedOutput) || 'Patch check skipped all target files.';
|
|
return { success: false, error: `apply_patch check failed: ${msg}` };
|
|
}
|
|
|
|
if (args.check === true) {
|
|
return {
|
|
success: true,
|
|
data: {
|
|
checked_only: true,
|
|
files: validation.relativePaths,
|
|
file_count: validation.relativePaths.length,
|
|
},
|
|
stdout: `Patch check passed for ${validation.relativePaths.length} file(s).`,
|
|
};
|
|
}
|
|
|
|
const applied = await runGitApply(workspacePath, ['apply', '--whitespace=nowarn', '--recount', '--verbose', tempPatchPath]);
|
|
const rawOutput = [applied.stdout, applied.stderr].filter(Boolean).join('\n');
|
|
const skippedOnApply = countSkippedPatches(rawOutput);
|
|
if (skippedOnApply >= validation.relativePaths.length) {
|
|
const msg = truncateOutput(rawOutput) || 'Patch apply skipped all target files.';
|
|
return { success: false, error: `apply_patch failed: ${msg}` };
|
|
}
|
|
const output = truncateOutput(rawOutput);
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
files: validation.relativePaths,
|
|
file_count: validation.relativePaths.length,
|
|
},
|
|
stdout: output || `Patch applied to ${validation.relativePaths.length} file(s).`,
|
|
};
|
|
} catch (err: any) {
|
|
const details = truncateOutput(String(err?.stderr || err?.stdout || err?.message || err || 'unknown error'));
|
|
return { success: false, error: `apply_patch failed: ${details}` };
|
|
} finally {
|
|
await fs.unlink(tempPatchPath).catch(() => {});
|
|
}
|
|
}
|
|
|
|
export const applyPatchTool = {
|
|
name: 'apply_patch',
|
|
description: 'Apply a unified diff patch to workspace files',
|
|
execute: executeApplyPatch,
|
|
schema: {
|
|
patch: 'string (required) - Unified diff patch text',
|
|
check: 'boolean (optional) - Validate patch only without applying it',
|
|
}
|
|
};
|