Terminal-based AI coding agent with interactive TUI for autonomous code generation.
Features: - Interactive TUI with React/Ink - Autonomous agent with tool calls (bash, read, write, edit, glob, grep) - Permission system with pattern-based rules - Session management with auto-compaction - Dual providers: GitHub Copilot and Ollama - MCP server integration - Todo panel and theme system - Streaming responses - GitHub-compatible project context
This commit is contained in:
21
src/tools/bash.ts
Normal file
21
src/tools/bash.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Bash tool for executing shell commands
|
||||
*
|
||||
* Output is captured and returned in the result - NOT streamed to stdout.
|
||||
* This allows the TUI to remain interactive during command execution.
|
||||
*/
|
||||
|
||||
export { bashParams, type BashParamsSchema } from "@tools/bash/params";
|
||||
export {
|
||||
truncateOutput,
|
||||
createOutputHandler,
|
||||
updateRunningStatus,
|
||||
} from "@tools/bash/output";
|
||||
export {
|
||||
killProcess,
|
||||
createTimeoutHandler,
|
||||
createAbortHandler,
|
||||
setupAbortListener,
|
||||
removeAbortListener,
|
||||
} from "@tools/bash/process";
|
||||
export { executeBash, bashTool } from "@tools/bash/execute";
|
||||
188
src/tools/bash/execute.ts
Normal file
188
src/tools/bash/execute.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Bash command execution
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
|
||||
import {
|
||||
BASH_DEFAULTS,
|
||||
BASH_MESSAGES,
|
||||
BASH_DESCRIPTION,
|
||||
} from "@constants/bash";
|
||||
import { promptPermission } from "@services/permissions";
|
||||
import { bashParams } from "@tools/bash/params";
|
||||
import {
|
||||
truncateOutput,
|
||||
createOutputHandler,
|
||||
updateRunningStatus,
|
||||
} from "@tools/bash/output";
|
||||
import {
|
||||
createTimeoutHandler,
|
||||
createAbortHandler,
|
||||
setupAbortListener,
|
||||
removeAbortListener,
|
||||
} from "@tools/bash/process";
|
||||
import type {
|
||||
ToolDefinition,
|
||||
ToolContext,
|
||||
ToolResult,
|
||||
BashParams,
|
||||
} from "@/types/tools";
|
||||
|
||||
const createDeniedResult = (description: string): ToolResult => ({
|
||||
success: false,
|
||||
title: description,
|
||||
output: "",
|
||||
error: BASH_MESSAGES.PERMISSION_DENIED,
|
||||
});
|
||||
|
||||
const createTimeoutResult = (
|
||||
description: string,
|
||||
output: string,
|
||||
timeout: number,
|
||||
code: number | null,
|
||||
): ToolResult => ({
|
||||
success: false,
|
||||
title: description,
|
||||
output: truncateOutput(output),
|
||||
error: BASH_MESSAGES.TIMED_OUT(timeout),
|
||||
metadata: {
|
||||
exitCode: code,
|
||||
timedOut: true,
|
||||
},
|
||||
});
|
||||
|
||||
const createAbortedResult = (
|
||||
description: string,
|
||||
output: string,
|
||||
code: number | null,
|
||||
): ToolResult => ({
|
||||
success: false,
|
||||
title: description,
|
||||
output: truncateOutput(output),
|
||||
error: BASH_MESSAGES.ABORTED,
|
||||
metadata: {
|
||||
exitCode: code,
|
||||
aborted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const createCompletedResult = (
|
||||
description: string,
|
||||
output: string,
|
||||
code: number | null,
|
||||
): ToolResult => ({
|
||||
success: code === 0,
|
||||
title: description,
|
||||
output: truncateOutput(output),
|
||||
error: code !== 0 ? BASH_MESSAGES.EXIT_CODE(code ?? -1) : undefined,
|
||||
metadata: {
|
||||
exitCode: code,
|
||||
},
|
||||
});
|
||||
|
||||
const createErrorResult = (
|
||||
description: string,
|
||||
output: string,
|
||||
error: Error,
|
||||
): ToolResult => ({
|
||||
success: false,
|
||||
title: description,
|
||||
output,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
const checkPermission = async (
|
||||
command: string,
|
||||
description: string,
|
||||
autoApprove: boolean,
|
||||
): Promise<boolean> => {
|
||||
if (autoApprove) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = await promptPermission(command, description);
|
||||
return result.allowed;
|
||||
};
|
||||
|
||||
const executeCommand = (
|
||||
args: BashParams,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> => {
|
||||
const {
|
||||
command,
|
||||
description,
|
||||
workdir,
|
||||
timeout = BASH_DEFAULTS.TIMEOUT,
|
||||
} = args;
|
||||
const cwd = workdir ?? ctx.workingDir;
|
||||
|
||||
updateRunningStatus(ctx, description);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(command, {
|
||||
shell: process.env.SHELL ?? "/bin/bash",
|
||||
cwd,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const outputRef = { value: "" };
|
||||
const timedOutRef = { value: false };
|
||||
|
||||
const appendOutput = createOutputHandler(ctx, description, outputRef);
|
||||
proc.stdout?.on("data", appendOutput);
|
||||
proc.stderr?.on("data", appendOutput);
|
||||
|
||||
const timeoutId = createTimeoutHandler(proc, timeout, timedOutRef);
|
||||
const abortHandler = createAbortHandler(proc);
|
||||
setupAbortListener(ctx, abortHandler);
|
||||
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
removeAbortListener(ctx, abortHandler);
|
||||
|
||||
if (timedOutRef.value) {
|
||||
resolve(
|
||||
createTimeoutResult(description, outputRef.value, timeout, code),
|
||||
);
|
||||
} else if (ctx.abort.signal.aborted) {
|
||||
resolve(createAbortedResult(description, outputRef.value, code));
|
||||
} else {
|
||||
resolve(createCompletedResult(description, outputRef.value, code));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
removeAbortListener(ctx, abortHandler);
|
||||
resolve(createErrorResult(description, outputRef.value, error));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const executeBash = async (
|
||||
args: BashParams,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> => {
|
||||
const { command, description } = args;
|
||||
|
||||
const allowed = await checkPermission(
|
||||
command,
|
||||
description,
|
||||
ctx.autoApprove ?? false,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
return createDeniedResult(description);
|
||||
}
|
||||
|
||||
return executeCommand(args, ctx);
|
||||
};
|
||||
|
||||
export const bashTool: ToolDefinition<typeof bashParams> = {
|
||||
name: "bash",
|
||||
description: BASH_DESCRIPTION,
|
||||
parameters: bashParams,
|
||||
execute: executeBash,
|
||||
};
|
||||
39
src/tools/bash/output.ts
Normal file
39
src/tools/bash/output.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Bash output handling utilities
|
||||
*/
|
||||
|
||||
import { BASH_DEFAULTS, BASH_MESSAGES } from "@constants/bash";
|
||||
import type { ToolContext } from "@/types/tools";
|
||||
|
||||
export const truncateOutput = (output: string): string =>
|
||||
output.length > BASH_DEFAULTS.MAX_OUTPUT_LENGTH
|
||||
? output.slice(0, BASH_DEFAULTS.MAX_OUTPUT_LENGTH) + BASH_MESSAGES.TRUNCATED
|
||||
: output;
|
||||
|
||||
export const createOutputHandler = (
|
||||
ctx: ToolContext,
|
||||
description: string,
|
||||
outputRef: { value: string },
|
||||
) => {
|
||||
return (data: Buffer): void => {
|
||||
const chunk = data.toString();
|
||||
outputRef.value += chunk;
|
||||
|
||||
ctx.onMetadata?.({
|
||||
title: description,
|
||||
status: "running",
|
||||
output: truncateOutput(outputRef.value),
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const updateRunningStatus = (
|
||||
ctx: ToolContext,
|
||||
description: string,
|
||||
): void => {
|
||||
ctx.onMetadata?.({
|
||||
title: description,
|
||||
status: "running",
|
||||
output: "",
|
||||
});
|
||||
};
|
||||
24
src/tools/bash/params.ts
Normal file
24
src/tools/bash/params.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Bash tool parameter schema
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const bashParams = z.object({
|
||||
command: z.string().describe("The bash command to execute"),
|
||||
description: z
|
||||
.string()
|
||||
.describe("A brief description of what this command does"),
|
||||
workdir: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Working directory for the command (defaults to current directory)",
|
||||
),
|
||||
timeout: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Timeout in milliseconds (default: 120000)"),
|
||||
});
|
||||
|
||||
export type BashParamsSchema = typeof bashParams;
|
||||
40
src/tools/bash/process.ts
Normal file
40
src/tools/bash/process.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Bash process management utilities
|
||||
*/
|
||||
|
||||
import type { ChildProcess } from "child_process";
|
||||
|
||||
import { BASH_DEFAULTS, BASH_SIGNALS } from "@constants/bash";
|
||||
|
||||
export const killProcess = (proc: ChildProcess): void => {
|
||||
proc.kill(BASH_SIGNALS.TERMINATE);
|
||||
setTimeout(() => proc.kill(BASH_SIGNALS.KILL), BASH_DEFAULTS.KILL_DELAY);
|
||||
};
|
||||
|
||||
export const createTimeoutHandler = (
|
||||
proc: ChildProcess,
|
||||
timeout: number,
|
||||
timedOutRef: { value: boolean },
|
||||
): NodeJS.Timeout =>
|
||||
setTimeout(() => {
|
||||
timedOutRef.value = true;
|
||||
killProcess(proc);
|
||||
}, timeout);
|
||||
|
||||
export const createAbortHandler = (proc: ChildProcess) => (): void => {
|
||||
killProcess(proc);
|
||||
};
|
||||
|
||||
export const setupAbortListener = (
|
||||
ctx: { abort: AbortController },
|
||||
handler: () => void,
|
||||
): void => {
|
||||
ctx.abort.signal.addEventListener("abort", handler, { once: true });
|
||||
};
|
||||
|
||||
export const removeAbortListener = (
|
||||
ctx: { abort: AbortController },
|
||||
handler: () => void,
|
||||
): void => {
|
||||
ctx.abort.signal.removeEventListener("abort", handler);
|
||||
};
|
||||
11
src/tools/edit.ts
Normal file
11
src/tools/edit.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Edit tool for modifying files
|
||||
*/
|
||||
|
||||
export { editParams, type EditParamsSchema } from "@tools/edit/params";
|
||||
export {
|
||||
validateTextExists,
|
||||
validateUniqueness,
|
||||
countOccurrences,
|
||||
} from "@tools/edit/validate";
|
||||
export { executeEdit, editTool } from "@tools/edit/execute";
|
||||
154
src/tools/edit/execute.ts
Normal file
154
src/tools/edit/execute.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Edit tool execution
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
import { EDIT_MESSAGES, EDIT_TITLES, EDIT_DESCRIPTION } from "@constants/edit";
|
||||
import { isFileOpAllowed, promptFilePermission } from "@services/permissions";
|
||||
import { formatDiff, generateDiff } from "@utils/diff";
|
||||
import { editParams } from "@tools/edit/params";
|
||||
import {
|
||||
validateTextExists,
|
||||
validateUniqueness,
|
||||
countOccurrences,
|
||||
} from "@tools/edit/validate";
|
||||
import type {
|
||||
ToolDefinition,
|
||||
ToolContext,
|
||||
ToolResult,
|
||||
EditParams,
|
||||
} from "@/types/tools";
|
||||
|
||||
const createDeniedResult = (relativePath: string): ToolResult => ({
|
||||
success: false,
|
||||
title: EDIT_TITLES.CANCELLED(relativePath),
|
||||
output: "",
|
||||
error: EDIT_MESSAGES.PERMISSION_DENIED,
|
||||
});
|
||||
|
||||
const createErrorResult = (relativePath: string, error: Error): ToolResult => ({
|
||||
success: false,
|
||||
title: EDIT_TITLES.FAILED(relativePath),
|
||||
output: "",
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
const createSuccessResult = (
|
||||
relativePath: string,
|
||||
fullPath: string,
|
||||
diffOutput: string,
|
||||
replacements: number,
|
||||
additions: number,
|
||||
deletions: number,
|
||||
): ToolResult => ({
|
||||
success: true,
|
||||
title: EDIT_TITLES.SUCCESS(relativePath),
|
||||
output: diffOutput,
|
||||
metadata: {
|
||||
filepath: fullPath,
|
||||
replacements,
|
||||
additions,
|
||||
deletions,
|
||||
},
|
||||
});
|
||||
|
||||
const resolvePath = (
|
||||
filePath: string,
|
||||
workingDir: string,
|
||||
): { fullPath: string; relativePath: string } => {
|
||||
const fullPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.join(workingDir, filePath);
|
||||
const relativePath = path.relative(workingDir, fullPath);
|
||||
return { fullPath, relativePath };
|
||||
};
|
||||
|
||||
const checkPermission = async (
|
||||
fullPath: string,
|
||||
relativePath: string,
|
||||
autoApprove: boolean,
|
||||
): Promise<boolean> => {
|
||||
if (autoApprove || isFileOpAllowed("Edit", fullPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { allowed } = await promptFilePermission(
|
||||
"Edit",
|
||||
fullPath,
|
||||
`Edit file: ${relativePath}`,
|
||||
);
|
||||
return allowed;
|
||||
};
|
||||
|
||||
const applyEdit = (
|
||||
content: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll: boolean,
|
||||
): string =>
|
||||
replaceAll
|
||||
? content.split(oldString).join(newString)
|
||||
: content.replace(oldString, newString);
|
||||
|
||||
export const executeEdit = async (
|
||||
args: EditParams,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> => {
|
||||
const { filePath, oldString, newString, replaceAll = false } = args;
|
||||
const { fullPath, relativePath } = resolvePath(filePath, ctx.workingDir);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(fullPath, "utf-8");
|
||||
|
||||
const existsError = validateTextExists(content, oldString, relativePath);
|
||||
if (existsError) return existsError;
|
||||
|
||||
const uniqueError = validateUniqueness(
|
||||
content,
|
||||
oldString,
|
||||
replaceAll,
|
||||
relativePath,
|
||||
);
|
||||
if (uniqueError) return uniqueError;
|
||||
|
||||
const allowed = await checkPermission(
|
||||
fullPath,
|
||||
relativePath,
|
||||
ctx.autoApprove ?? false,
|
||||
);
|
||||
if (!allowed) return createDeniedResult(relativePath);
|
||||
|
||||
ctx.onMetadata?.({
|
||||
title: EDIT_TITLES.EDITING(path.basename(filePath)),
|
||||
status: "running",
|
||||
});
|
||||
|
||||
const newContent = applyEdit(content, oldString, newString, replaceAll);
|
||||
const diff = generateDiff(content, newContent);
|
||||
const diffOutput = formatDiff(diff, relativePath);
|
||||
|
||||
await fs.writeFile(fullPath, newContent, "utf-8");
|
||||
|
||||
const replacements = replaceAll ? countOccurrences(content, oldString) : 1;
|
||||
|
||||
return createSuccessResult(
|
||||
relativePath,
|
||||
fullPath,
|
||||
diffOutput,
|
||||
replacements,
|
||||
diff.additions,
|
||||
diff.deletions,
|
||||
);
|
||||
} catch (error) {
|
||||
return createErrorResult(relativePath, error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
export const editTool: ToolDefinition<typeof editParams> = {
|
||||
name: "edit",
|
||||
description: EDIT_DESCRIPTION,
|
||||
parameters: editParams,
|
||||
execute: executeEdit,
|
||||
};
|
||||
17
src/tools/edit/params.ts
Normal file
17
src/tools/edit/params.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Edit tool parameter schema
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const editParams = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to edit"),
|
||||
oldString: z.string().describe("The exact text to find and replace"),
|
||||
newString: z.string().describe("The text to replace it with"),
|
||||
replaceAll: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Replace all occurrences (default: false)"),
|
||||
});
|
||||
|
||||
export type EditParamsSchema = typeof editParams;
|
||||
47
src/tools/edit/validate.ts
Normal file
47
src/tools/edit/validate.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Edit tool validation utilities
|
||||
*/
|
||||
|
||||
import { EDIT_MESSAGES, EDIT_TITLES } from "@constants/edit";
|
||||
import type { ToolResult } from "@/types/tools";
|
||||
|
||||
export const validateTextExists = (
|
||||
content: string,
|
||||
oldString: string,
|
||||
relativePath: string,
|
||||
): ToolResult | null => {
|
||||
if (!content.includes(oldString)) {
|
||||
return {
|
||||
success: false,
|
||||
title: EDIT_TITLES.FAILED(relativePath),
|
||||
output: "",
|
||||
error: EDIT_MESSAGES.NOT_FOUND,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validateUniqueness = (
|
||||
content: string,
|
||||
oldString: string,
|
||||
replaceAll: boolean,
|
||||
relativePath: string,
|
||||
): ToolResult | null => {
|
||||
if (replaceAll) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const occurrences = content.split(oldString).length - 1;
|
||||
if (occurrences > 1) {
|
||||
return {
|
||||
success: false,
|
||||
title: EDIT_TITLES.FAILED(relativePath),
|
||||
output: "",
|
||||
error: EDIT_MESSAGES.MULTIPLE_OCCURRENCES(occurrences),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const countOccurrences = (content: string, search: string): number =>
|
||||
content.split(search).length - 1;
|
||||
30
src/tools/glob.ts
Normal file
30
src/tools/glob.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Glob tool - list files matching patterns (functional)
|
||||
*/
|
||||
|
||||
export {
|
||||
executeGlob,
|
||||
listFiles,
|
||||
findByExtension,
|
||||
findByName,
|
||||
listDirectories,
|
||||
} from "@tools/glob/execute";
|
||||
|
||||
import {
|
||||
executeGlob,
|
||||
listFiles,
|
||||
findByExtension,
|
||||
findByName,
|
||||
listDirectories,
|
||||
} from "@tools/glob/execute";
|
||||
|
||||
/**
|
||||
* Glob tool object for backward compatibility
|
||||
*/
|
||||
export const globTool = {
|
||||
execute: executeGlob,
|
||||
list: listFiles,
|
||||
findByExtension,
|
||||
findByName,
|
||||
listDirectories,
|
||||
};
|
||||
44
src/tools/glob/definition.ts
Normal file
44
src/tools/glob/definition.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Glob Tool Definition - File pattern matching
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { executeGlob } from "@tools/glob/execute";
|
||||
import type { ToolDefinition, ToolContext, ToolResult } from "@/types/tools";
|
||||
|
||||
export const globParams = z.object({
|
||||
pattern: z.string().describe("The glob pattern to match files against (e.g., '**/*.ts', 'src/**/*.tsx')"),
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("The directory to search in. Defaults to current working directory."),
|
||||
});
|
||||
|
||||
type GlobParams = z.infer<typeof globParams>;
|
||||
|
||||
const executeGlobTool = async (
|
||||
args: GlobParams,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> => {
|
||||
const result = await executeGlob(args.pattern, {
|
||||
cwd: args.path || ctx.workingDir,
|
||||
});
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
title: result.title,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
};
|
||||
};
|
||||
|
||||
export const globToolDefinition: ToolDefinition<typeof globParams> = {
|
||||
name: "glob",
|
||||
description: `Fast file pattern matching tool that works with any codebase size.
|
||||
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
|
||||
- Returns matching file paths sorted by modification time
|
||||
- Use this when you need to find files by name patterns
|
||||
- For searching file contents, use grep instead`,
|
||||
parameters: globParams,
|
||||
execute: executeGlobTool,
|
||||
};
|
||||
107
src/tools/glob/execute.ts
Normal file
107
src/tools/glob/execute.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Glob tool execution (functional)
|
||||
*/
|
||||
|
||||
import fg from "fast-glob";
|
||||
|
||||
import {
|
||||
GLOB_DEFAULTS,
|
||||
GLOB_IGNORE_PATTERNS,
|
||||
GLOB_MESSAGES,
|
||||
} from "@constants/glob";
|
||||
import type { GlobOptions, GlobResult } from "@/types/tools";
|
||||
|
||||
const createSuccessResult = (files: string[]): GlobResult => ({
|
||||
success: true,
|
||||
title: "Glob",
|
||||
output: files.join("\n"),
|
||||
files,
|
||||
});
|
||||
|
||||
const createErrorResult = (error: unknown): GlobResult => ({
|
||||
success: false,
|
||||
title: "Glob failed",
|
||||
output: "",
|
||||
error: GLOB_MESSAGES.FAILED(error),
|
||||
});
|
||||
|
||||
export const executeGlob = async (
|
||||
patterns: string | string[],
|
||||
options?: GlobOptions,
|
||||
): Promise<GlobResult> => {
|
||||
try {
|
||||
const files = await fg(patterns, {
|
||||
cwd: options?.cwd ?? process.cwd(),
|
||||
ignore: [...GLOB_IGNORE_PATTERNS, ...(options?.ignore ?? [])],
|
||||
onlyFiles: options?.onlyFiles ?? GLOB_DEFAULTS.ONLY_FILES,
|
||||
onlyDirectories:
|
||||
options?.onlyDirectories ?? GLOB_DEFAULTS.ONLY_DIRECTORIES,
|
||||
dot: GLOB_DEFAULTS.DOT,
|
||||
});
|
||||
|
||||
return createSuccessResult(files);
|
||||
} catch (error) {
|
||||
return createErrorResult(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const listFiles = async (
|
||||
directory: string = ".",
|
||||
options?: {
|
||||
recursive?: boolean;
|
||||
extensions?: string[];
|
||||
},
|
||||
): Promise<GlobResult> => {
|
||||
try {
|
||||
const pattern = options?.recursive ? "**/*" : "*";
|
||||
const patterns = options?.extensions
|
||||
? options.extensions.map((ext) => `${pattern}.${ext}`)
|
||||
: [pattern];
|
||||
|
||||
return executeGlob(patterns, {
|
||||
cwd: directory,
|
||||
onlyFiles: true,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
title: "List failed",
|
||||
output: "",
|
||||
error: GLOB_MESSAGES.LIST_FAILED(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const findByExtension = async (
|
||||
extension: string,
|
||||
directory: string = ".",
|
||||
): Promise<string[]> => {
|
||||
const result = await executeGlob(`**/*.${extension}`, {
|
||||
cwd: directory,
|
||||
onlyFiles: true,
|
||||
});
|
||||
|
||||
return result.files ?? [];
|
||||
};
|
||||
|
||||
export const findByName = async (
|
||||
name: string,
|
||||
directory: string = ".",
|
||||
): Promise<string[]> => {
|
||||
const result = await executeGlob(`**/${name}`, {
|
||||
cwd: directory,
|
||||
});
|
||||
|
||||
return result.files ?? [];
|
||||
};
|
||||
|
||||
export const listDirectories = async (
|
||||
directory: string = ".",
|
||||
): Promise<string[]> => {
|
||||
const result = await executeGlob("*", {
|
||||
cwd: directory,
|
||||
onlyDirectories: true,
|
||||
});
|
||||
|
||||
return result.files ?? [];
|
||||
};
|
||||
17
src/tools/grep.ts
Normal file
17
src/tools/grep.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Grep tool - search files for patterns (functional)
|
||||
*/
|
||||
|
||||
export { searchLines, formatMatches } from "@tools/grep/search";
|
||||
export { executeGrep, searchInFile, executeRipgrep } from "@tools/grep/execute";
|
||||
|
||||
import { executeGrep, searchInFile, executeRipgrep } from "@tools/grep/execute";
|
||||
|
||||
/**
|
||||
* Grep tool object for backward compatibility
|
||||
*/
|
||||
export const grepTool = {
|
||||
execute: executeGrep,
|
||||
searchInFile,
|
||||
ripgrep: executeRipgrep,
|
||||
};
|
||||
58
src/tools/grep/definition.ts
Normal file
58
src/tools/grep/definition.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Grep Tool Definition - Search file contents
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { executeRipgrep } from "@tools/grep/execute";
|
||||
import type { ToolDefinition, ToolContext, ToolResult } from "@/types/tools";
|
||||
|
||||
export const grepParams = z.object({
|
||||
pattern: z
|
||||
.string()
|
||||
.describe("The regular expression pattern to search for in file contents"),
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("File or directory to search in. Defaults to current working directory."),
|
||||
glob: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Glob pattern to filter files (e.g., '*.ts', '**/*.tsx')"),
|
||||
case_insensitive: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Case insensitive search"),
|
||||
context_lines: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Number of context lines to show before and after each match"),
|
||||
});
|
||||
|
||||
type GrepParams = z.infer<typeof grepParams>;
|
||||
|
||||
const executeGrepTool = async (
|
||||
args: GrepParams,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> => {
|
||||
// Execute ripgrep search
|
||||
const directory = args.path || ctx.workingDir;
|
||||
const result = await executeRipgrep(args.pattern, directory);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
title: result.title,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
};
|
||||
};
|
||||
|
||||
export const grepToolDefinition: ToolDefinition<typeof grepParams> = {
|
||||
name: "grep",
|
||||
description: `A powerful search tool built on ripgrep for searching file contents.
|
||||
- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
|
||||
- Filter files with glob parameter (e.g., "*.js", "**/*.tsx")
|
||||
- Use this when you need to find code patterns, function definitions, or specific text
|
||||
- For finding files by name, use glob instead`,
|
||||
parameters: grepParams,
|
||||
execute: executeGrepTool,
|
||||
};
|
||||
117
src/tools/grep/execute.ts
Normal file
117
src/tools/grep/execute.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Grep tool execution (functional)
|
||||
*/
|
||||
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fg from "fast-glob";
|
||||
import fs from "fs/promises";
|
||||
|
||||
import {
|
||||
GREP_DEFAULTS,
|
||||
GREP_IGNORE_PATTERNS,
|
||||
GREP_MESSAGES,
|
||||
GREP_COMMANDS,
|
||||
} from "@constants/grep";
|
||||
import { searchLines, formatMatches } from "@tools/grep/search";
|
||||
import type { GrepMatch, GrepOptions, GrepResult } from "@/types/tools";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const createSuccessResult = (output: string, files: string[]): GrepResult => ({
|
||||
success: true,
|
||||
title: "Grep",
|
||||
output: output || GREP_MESSAGES.NO_MATCHES,
|
||||
files,
|
||||
});
|
||||
|
||||
const createErrorResult = (error: unknown): GrepResult => ({
|
||||
success: false,
|
||||
title: "Grep failed",
|
||||
output: "",
|
||||
error: GREP_MESSAGES.SEARCH_FAILED(error),
|
||||
});
|
||||
|
||||
const searchFile = async (
|
||||
file: string,
|
||||
pattern: string,
|
||||
options?: GrepOptions,
|
||||
): Promise<GrepMatch[]> => {
|
||||
try {
|
||||
const content = await fs.readFile(file, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
return searchLines(lines, pattern, file, options);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const executeGrep = async (
|
||||
pattern: string,
|
||||
files: string[] = [GREP_DEFAULTS.DEFAULT_PATTERN],
|
||||
options?: GrepOptions,
|
||||
): Promise<GrepResult> => {
|
||||
try {
|
||||
const fileList = await fg(files, {
|
||||
ignore: [...GREP_IGNORE_PATTERNS],
|
||||
});
|
||||
|
||||
const matches: GrepMatch[] = [];
|
||||
const maxResults = options?.maxResults ?? GREP_DEFAULTS.MAX_RESULTS;
|
||||
|
||||
for (const file of fileList) {
|
||||
if (matches.length >= maxResults) break;
|
||||
|
||||
const fileMatches = await searchFile(file, pattern, options);
|
||||
const remaining = maxResults - matches.length;
|
||||
matches.push(...fileMatches.slice(0, remaining));
|
||||
}
|
||||
|
||||
const output = formatMatches(matches);
|
||||
const uniqueFiles = [...new Set(matches.map((m) => m.file))];
|
||||
|
||||
return createSuccessResult(output, uniqueFiles);
|
||||
} catch (error) {
|
||||
return createErrorResult(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const searchInFile = async (
|
||||
filePath: string,
|
||||
pattern: string,
|
||||
options?: GrepOptions,
|
||||
): Promise<GrepMatch[]> => searchFile(filePath, pattern, options);
|
||||
|
||||
export const executeRipgrep = async (
|
||||
pattern: string,
|
||||
directory: string = ".",
|
||||
): Promise<GrepResult> => {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
GREP_COMMANDS.RIPGREP(pattern, directory),
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
title: "Ripgrep",
|
||||
output: stdout || GREP_MESSAGES.NO_MATCHES,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const execError = error as { code?: number; message?: string };
|
||||
|
||||
if (execError.code === GREP_DEFAULTS.NO_MATCHES_EXIT_CODE) {
|
||||
return {
|
||||
success: true,
|
||||
title: "Ripgrep",
|
||||
output: GREP_MESSAGES.NO_MATCHES,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
title: "Ripgrep failed",
|
||||
output: "",
|
||||
error: GREP_MESSAGES.RIPGREP_FAILED(execError.message ?? "Unknown error"),
|
||||
};
|
||||
}
|
||||
};
|
||||
52
src/tools/grep/search.ts
Normal file
52
src/tools/grep/search.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Grep search utilities
|
||||
*/
|
||||
|
||||
import type { GrepMatch, GrepOptions } from "@/types/tools";
|
||||
|
||||
const normalizeForSearch = (text: string, ignoreCase: boolean): string =>
|
||||
ignoreCase ? text.toLowerCase() : text;
|
||||
|
||||
const matchesPattern = (
|
||||
line: string,
|
||||
pattern: string,
|
||||
options?: GrepOptions,
|
||||
): boolean => {
|
||||
if (options?.regex) {
|
||||
const regex = new RegExp(pattern, options.ignoreCase ? "i" : "");
|
||||
return regex.test(line);
|
||||
}
|
||||
|
||||
const searchPattern = normalizeForSearch(
|
||||
pattern,
|
||||
options?.ignoreCase ?? false,
|
||||
);
|
||||
const searchLine = normalizeForSearch(line, options?.ignoreCase ?? false);
|
||||
return searchLine.includes(searchPattern);
|
||||
};
|
||||
|
||||
export const searchLines = (
|
||||
lines: string[],
|
||||
pattern: string,
|
||||
file: string,
|
||||
options?: GrepOptions,
|
||||
): GrepMatch[] => {
|
||||
const matches: GrepMatch[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (matchesPattern(line, pattern, options)) {
|
||||
matches.push({
|
||||
file,
|
||||
line: i + 1,
|
||||
content: line.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
export const formatMatches = (matches: GrepMatch[]): string =>
|
||||
matches.map((m) => `${m.file}:${m.line}: ${m.content}`).join("\n");
|
||||
183
src/tools/index.ts
Normal file
183
src/tools/index.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Tool registry - exports all available tools
|
||||
*/
|
||||
|
||||
export * from "@tools/types";
|
||||
export { bashTool } from "@tools/bash";
|
||||
export { readTool } from "@tools/read";
|
||||
export { writeTool } from "@tools/write";
|
||||
export { editTool } from "@tools/edit";
|
||||
export { todoWriteTool } from "@tools/todo-write";
|
||||
export { todoReadTool } from "@tools/todo-read";
|
||||
export { globToolDefinition } from "@tools/glob/definition";
|
||||
export { grepToolDefinition } from "@tools/grep/definition";
|
||||
|
||||
import type { ToolDefinition, FunctionDefinition } from "@tools/types";
|
||||
import { toolToFunction } from "@tools/types";
|
||||
import { bashTool } from "@tools/bash";
|
||||
import { readTool } from "@tools/read";
|
||||
import { writeTool } from "@tools/write";
|
||||
import { editTool } from "@tools/edit";
|
||||
import { todoWriteTool } from "@tools/todo-write";
|
||||
import { todoReadTool } from "@tools/todo-read";
|
||||
import { globToolDefinition } from "@tools/glob/definition";
|
||||
import { grepToolDefinition } from "@tools/grep/definition";
|
||||
import {
|
||||
isMCPTool,
|
||||
executeMCPTool,
|
||||
getMCPToolsForApi,
|
||||
} from "@services/mcp/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
// All available tools
|
||||
export const tools: ToolDefinition[] = [
|
||||
bashTool,
|
||||
readTool,
|
||||
writeTool,
|
||||
editTool,
|
||||
globToolDefinition,
|
||||
grepToolDefinition,
|
||||
todoWriteTool,
|
||||
todoReadTool,
|
||||
];
|
||||
|
||||
// Tools that are read-only (allowed in chat mode)
|
||||
const READ_ONLY_TOOLS = new Set([
|
||||
"read",
|
||||
"glob",
|
||||
"grep",
|
||||
"todo_read",
|
||||
]);
|
||||
|
||||
// Map of tools by name
|
||||
export const toolMap: Map<string, ToolDefinition> = new Map(
|
||||
tools.map((t) => [t.name, t]),
|
||||
);
|
||||
|
||||
// Cached MCP tools
|
||||
let mcpToolsCache: Awaited<ReturnType<typeof getMCPToolsForApi>> | null = null;
|
||||
|
||||
/**
|
||||
* Get tool by name (including MCP tools)
|
||||
*/
|
||||
export function getTool(name: string): ToolDefinition | undefined {
|
||||
// Check built-in tools first
|
||||
const builtInTool = toolMap.get(name);
|
||||
if (builtInTool) {
|
||||
return builtInTool;
|
||||
}
|
||||
|
||||
// Check if it's an MCP tool
|
||||
if (isMCPTool(name)) {
|
||||
// Return a wrapper tool definition for MCP tools
|
||||
return {
|
||||
name,
|
||||
description: `MCP tool: ${name}`,
|
||||
parameters: z.object({}).passthrough(),
|
||||
execute: async (args) => {
|
||||
const result = await executeMCPTool(
|
||||
name,
|
||||
args as Record<string, unknown>,
|
||||
);
|
||||
return {
|
||||
success: result.success,
|
||||
title: name,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get all tools as OpenAI function definitions
|
||||
export function getToolFunctions(): FunctionDefinition[] {
|
||||
return tools.map(toolToFunction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tools based on chat mode (read-only vs full access)
|
||||
*/
|
||||
const filterToolsForMode = (
|
||||
toolList: ToolDefinition[],
|
||||
chatMode: boolean,
|
||||
): ToolDefinition[] => {
|
||||
if (!chatMode) return toolList;
|
||||
return toolList.filter((t) => READ_ONLY_TOOLS.has(t.name));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get tools as format expected by Copilot/OpenAI API
|
||||
* This includes both built-in tools and MCP tools
|
||||
* @param chatMode - If true, only return read-only tools (no file modifications)
|
||||
*/
|
||||
export async function getToolsForApiAsync(
|
||||
chatMode = false,
|
||||
): Promise<
|
||||
{
|
||||
type: "function";
|
||||
function: FunctionDefinition;
|
||||
}[]
|
||||
> {
|
||||
const filteredTools = filterToolsForMode(tools, chatMode);
|
||||
const builtInTools = filteredTools.map((t) => ({
|
||||
type: "function" as const,
|
||||
function: toolToFunction(t),
|
||||
}));
|
||||
|
||||
// In chat mode, don't include MCP tools (they might modify files)
|
||||
if (chatMode) {
|
||||
return builtInTools;
|
||||
}
|
||||
|
||||
// Get MCP tools (uses cache if available)
|
||||
try {
|
||||
mcpToolsCache = await getMCPToolsForApi();
|
||||
return [...builtInTools, ...mcpToolsCache];
|
||||
} catch {
|
||||
// If MCP tools fail to load, just return built-in tools
|
||||
return builtInTools;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools synchronously (uses cached MCP tools if available)
|
||||
* @param chatMode - If true, only return read-only tools (no file modifications)
|
||||
*/
|
||||
export function getToolsForApi(
|
||||
chatMode = false,
|
||||
): {
|
||||
type: "function";
|
||||
function: FunctionDefinition;
|
||||
}[] {
|
||||
const filteredTools = filterToolsForMode(tools, chatMode);
|
||||
const builtInTools = filteredTools.map((t) => ({
|
||||
type: "function" as const,
|
||||
function: toolToFunction(t),
|
||||
}));
|
||||
|
||||
// In chat mode, don't include MCP tools
|
||||
if (chatMode) {
|
||||
return builtInTools;
|
||||
}
|
||||
|
||||
// Include cached MCP tools if available
|
||||
if (mcpToolsCache) {
|
||||
return [...builtInTools, ...mcpToolsCache];
|
||||
}
|
||||
|
||||
return builtInTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh MCP tools cache
|
||||
*/
|
||||
export async function refreshMCPTools(): Promise<void> {
|
||||
try {
|
||||
mcpToolsCache = await getMCPToolsForApi();
|
||||
} catch {
|
||||
mcpToolsCache = null;
|
||||
}
|
||||
}
|
||||
12
src/tools/read.ts
Normal file
12
src/tools/read.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Read tool for reading files
|
||||
*/
|
||||
|
||||
export { readParams, type ReadParamsSchema } from "@tools/read/params";
|
||||
export {
|
||||
truncateLine,
|
||||
formatLineWithNumber,
|
||||
calculateLineSize,
|
||||
processLines,
|
||||
} from "@tools/read/format";
|
||||
export { executeRead, readTool } from "@tools/read/execute";
|
||||
139
src/tools/read/execute.ts
Normal file
139
src/tools/read/execute.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Read tool execution
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
import {
|
||||
READ_DEFAULTS,
|
||||
READ_MESSAGES,
|
||||
READ_TITLES,
|
||||
READ_DESCRIPTION,
|
||||
} from "@constants/read";
|
||||
import { isFileOpAllowed, promptFilePermission } from "@services/permissions";
|
||||
import { readParams } from "@tools/read/params";
|
||||
import { processLines } from "@tools/read/format";
|
||||
import type {
|
||||
ToolDefinition,
|
||||
ToolContext,
|
||||
ToolResult,
|
||||
ReadParams,
|
||||
} from "@/types/tools";
|
||||
|
||||
const createDeniedResult = (filePath: string): ToolResult => ({
|
||||
success: false,
|
||||
title: READ_TITLES.DENIED(filePath),
|
||||
output: "",
|
||||
error: READ_MESSAGES.PERMISSION_DENIED,
|
||||
});
|
||||
|
||||
const createErrorResult = (filePath: string, error: Error): ToolResult => ({
|
||||
success: false,
|
||||
title: READ_TITLES.FAILED(filePath),
|
||||
output: "",
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
const createDirectoryResult = (
|
||||
filePath: string,
|
||||
files: string[],
|
||||
): ToolResult => ({
|
||||
success: true,
|
||||
title: READ_TITLES.DIRECTORY(filePath),
|
||||
output: "Directory contents:\n" + files.join("\n"),
|
||||
metadata: {
|
||||
isDirectory: true,
|
||||
fileCount: files.length,
|
||||
},
|
||||
});
|
||||
|
||||
const createFileResult = (
|
||||
filePath: string,
|
||||
fullPath: string,
|
||||
output: string,
|
||||
totalLines: number,
|
||||
linesRead: number,
|
||||
truncated: boolean,
|
||||
offset: number,
|
||||
): ToolResult => ({
|
||||
success: true,
|
||||
title: path.basename(filePath),
|
||||
output,
|
||||
metadata: {
|
||||
filepath: fullPath,
|
||||
totalLines,
|
||||
linesRead,
|
||||
truncated,
|
||||
offset,
|
||||
},
|
||||
});
|
||||
|
||||
const resolvePath = (filePath: string, workingDir: string): string =>
|
||||
path.isAbsolute(filePath) ? filePath : path.join(workingDir, filePath);
|
||||
|
||||
const checkPermission = async (
|
||||
fullPath: string,
|
||||
autoApprove: boolean,
|
||||
): Promise<boolean> => {
|
||||
if (autoApprove || isFileOpAllowed("Read", fullPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { allowed } = await promptFilePermission("Read", fullPath);
|
||||
return allowed;
|
||||
};
|
||||
|
||||
const readDirectory = async (fullPath: string): Promise<string[]> =>
|
||||
fs.readdir(fullPath);
|
||||
|
||||
const readFileContent = async (fullPath: string): Promise<string> =>
|
||||
fs.readFile(fullPath, "utf-8");
|
||||
|
||||
export const executeRead = async (
|
||||
args: ReadParams,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> => {
|
||||
const { filePath, offset = 0, limit = READ_DEFAULTS.MAX_LINES } = args;
|
||||
const fullPath = resolvePath(filePath, ctx.workingDir);
|
||||
|
||||
const allowed = await checkPermission(fullPath, ctx.autoApprove ?? false);
|
||||
if (!allowed) return createDeniedResult(filePath);
|
||||
|
||||
ctx.onMetadata?.({
|
||||
title: READ_TITLES.READING(path.basename(filePath)),
|
||||
status: "running",
|
||||
});
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const files = await readDirectory(fullPath);
|
||||
return createDirectoryResult(filePath, files);
|
||||
}
|
||||
|
||||
const content = await readFileContent(fullPath);
|
||||
const lines = content.split("\n");
|
||||
const { output, truncated } = processLines(lines, offset, limit);
|
||||
|
||||
return createFileResult(
|
||||
filePath,
|
||||
fullPath,
|
||||
output.join("\n"),
|
||||
lines.length,
|
||||
output.length,
|
||||
truncated,
|
||||
offset,
|
||||
);
|
||||
} catch (error) {
|
||||
return createErrorResult(filePath, error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
export const readTool: ToolDefinition<typeof readParams> = {
|
||||
name: "read",
|
||||
description: READ_DESCRIPTION,
|
||||
parameters: readParams,
|
||||
execute: executeRead,
|
||||
};
|
||||
45
src/tools/read/format.ts
Normal file
45
src/tools/read/format.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Read tool formatting utilities
|
||||
*/
|
||||
|
||||
import { READ_DEFAULTS } from "@constants/read";
|
||||
|
||||
export const truncateLine = (line: string): string =>
|
||||
line.length > READ_DEFAULTS.MAX_LINE_LENGTH
|
||||
? line.substring(0, READ_DEFAULTS.MAX_LINE_LENGTH) + "..."
|
||||
: line;
|
||||
|
||||
export const formatLineWithNumber = (
|
||||
line: string,
|
||||
lineNumber: number,
|
||||
): string =>
|
||||
String(lineNumber).padStart(READ_DEFAULTS.LINE_NUMBER_PAD, " ") + "\t" + line;
|
||||
|
||||
export const calculateLineSize = (line: string): number =>
|
||||
Buffer.byteLength(line, "utf-8") + 1;
|
||||
|
||||
export const processLines = (
|
||||
lines: string[],
|
||||
offset: number,
|
||||
limit: number,
|
||||
): { output: string[]; truncated: boolean } => {
|
||||
const result: string[] = [];
|
||||
let bytes = 0;
|
||||
const maxLine = Math.min(lines.length, offset + limit);
|
||||
|
||||
for (let i = offset; i < maxLine; i++) {
|
||||
const truncatedLine = truncateLine(lines[i]);
|
||||
const lineWithNumber = formatLineWithNumber(truncatedLine, i + 1);
|
||||
const size = calculateLineSize(lineWithNumber);
|
||||
|
||||
if (bytes + size > READ_DEFAULTS.MAX_BYTES) break;
|
||||
|
||||
result.push(lineWithNumber);
|
||||
bytes += size;
|
||||
}
|
||||
|
||||
return {
|
||||
output: result,
|
||||
truncated: result.length < lines.length - offset,
|
||||
};
|
||||
};
|
||||
23
src/tools/read/params.ts
Normal file
23
src/tools/read/params.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Read tool parameter schema
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { READ_DEFAULTS } from "@constants/read";
|
||||
|
||||
export const readParams = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to read"),
|
||||
offset: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Line number to start reading from (0-indexed)"),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
`Maximum number of lines to read (default: ${READ_DEFAULTS.MAX_LINES})`,
|
||||
),
|
||||
});
|
||||
|
||||
export type ReadParamsSchema = typeof readParams;
|
||||
42
src/tools/schema/clean.ts
Normal file
42
src/tools/schema/clean.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* JSON Schema cleaning utilities for OpenAI/Copilot API compatibility
|
||||
*/
|
||||
|
||||
import {
|
||||
SCHEMA_SKIP_KEYS,
|
||||
SCHEMA_SKIP_VALUES,
|
||||
type SchemaSkipKey,
|
||||
} from "@constants/tools";
|
||||
|
||||
const shouldSkipKey = (key: string): boolean =>
|
||||
SCHEMA_SKIP_KEYS.includes(key as SchemaSkipKey);
|
||||
|
||||
const shouldSkipValue = (key: string, value: unknown): boolean =>
|
||||
SCHEMA_SKIP_VALUES[key] === value;
|
||||
|
||||
const isNestedObject = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
export const cleanJsonSchema = (
|
||||
schema: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
if (shouldSkipKey(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldSkipValue(key, value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isNestedObject(value)) {
|
||||
result[key] = cleanJsonSchema(value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
22
src/tools/schema/convert.ts
Normal file
22
src/tools/schema/convert.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Tool to function conversion utilities
|
||||
*/
|
||||
|
||||
import { cleanJsonSchema } from "@tools/schema/clean";
|
||||
import type { ToolDefinition, FunctionDefinition } from "@/types/tools";
|
||||
|
||||
export const toolToFunction = (tool: ToolDefinition): FunctionDefinition => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const jsonSchema = (tool.parameters as any).toJSONSchema() as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const cleanSchema = cleanJsonSchema(jsonSchema);
|
||||
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: cleanSchema as FunctionDefinition["parameters"],
|
||||
};
|
||||
};
|
||||
64
src/tools/todo-read.ts
Normal file
64
src/tools/todo-read.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* TodoRead Tool - Allows agent to read current task list
|
||||
*
|
||||
* The agent calls this tool to see current progress and pending tasks.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { todoStore } from "@stores/todo-store";
|
||||
import type { ToolDefinition } from "@tools/types";
|
||||
|
||||
const parametersSchema = z.object({});
|
||||
|
||||
export const todoReadTool: ToolDefinition = {
|
||||
name: "todoread",
|
||||
description: `Read the current todo list to see task progress.
|
||||
|
||||
Use this tool to:
|
||||
- Check what tasks are pending
|
||||
- See which task is currently in progress
|
||||
- Review completed tasks
|
||||
- Plan next steps based on remaining work
|
||||
|
||||
Returns the complete todo list with status for each item.`,
|
||||
parameters: parametersSchema,
|
||||
execute: async () => {
|
||||
const plan = todoStore.getPlan();
|
||||
|
||||
if (!plan || plan.items.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
title: "No todos",
|
||||
output: "No tasks in the todo list. Use todowrite to create tasks.",
|
||||
};
|
||||
}
|
||||
|
||||
const progress = todoStore.getProgress();
|
||||
|
||||
const items = plan.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
}));
|
||||
|
||||
const summary = items
|
||||
.map((item) => {
|
||||
const icon =
|
||||
item.status === "completed"
|
||||
? "✓"
|
||||
: item.status === "in_progress"
|
||||
? "→"
|
||||
: item.status === "failed"
|
||||
? "✗"
|
||||
: "○";
|
||||
return `${icon} [${item.id}] ${item.title} (${item.status})`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
title: `Todos: ${progress.completed}/${progress.total}`,
|
||||
output: `Progress: ${progress.completed}/${progress.total} (${progress.percentage}%)\n\n${summary}\n\nTodos JSON:\n${JSON.stringify(items, null, 2)}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
134
src/tools/todo-write.ts
Normal file
134
src/tools/todo-write.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* TodoWrite Tool - Allows agent to create and update task lists
|
||||
*
|
||||
* The agent calls this tool to track progress through multi-step tasks.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { todoStore } from "@stores/todo-store";
|
||||
import type { ToolDefinition } from "@tools/types";
|
||||
import type { TodoStatus } from "@/types/todo";
|
||||
|
||||
const TodoItemSchema = z.object({
|
||||
id: z.string().describe("Unique identifier for the todo item"),
|
||||
title: z.string().describe("Brief description of the task"),
|
||||
status: z
|
||||
.enum(["pending", "in_progress", "completed", "failed"])
|
||||
.describe("Current status of the task"),
|
||||
});
|
||||
|
||||
const parametersSchema = z.object({
|
||||
todos: z
|
||||
.array(TodoItemSchema)
|
||||
.describe(
|
||||
"Complete list of todo items. Include all items, not just changes.",
|
||||
),
|
||||
});
|
||||
|
||||
type TodoWriteParams = z.infer<typeof parametersSchema>;
|
||||
|
||||
export const todoWriteTool: ToolDefinition = {
|
||||
name: "todowrite",
|
||||
description: `Update the todo list to track progress through multi-step tasks.
|
||||
|
||||
Use this tool to:
|
||||
- Create a task list when starting complex work
|
||||
- Update task status as you complete each step
|
||||
- Add new tasks discovered during work
|
||||
- Mark tasks as completed or failed
|
||||
|
||||
Always include the COMPLETE todo list, not just changes. The list will replace the current todos.
|
||||
|
||||
Example:
|
||||
{
|
||||
"todos": [
|
||||
{ "id": "1", "title": "Read the source file", "status": "completed" },
|
||||
{ "id": "2", "title": "Identify the bug", "status": "in_progress" },
|
||||
{ "id": "3", "title": "Apply the fix", "status": "pending" },
|
||||
{ "id": "4", "title": "Verify the build", "status": "pending" }
|
||||
]
|
||||
}`,
|
||||
parameters: parametersSchema,
|
||||
execute: async (args: TodoWriteParams) => {
|
||||
const { todos } = args;
|
||||
|
||||
// Check if we have an existing plan or need to create one
|
||||
const existingPlan = todoStore.getPlan();
|
||||
|
||||
if (!existingPlan) {
|
||||
// Create new plan from todos
|
||||
const tasks = todos.map((t) => ({
|
||||
title: t.title,
|
||||
description: undefined,
|
||||
}));
|
||||
|
||||
if (tasks.length > 0) {
|
||||
todoStore.createPlan("Task Plan", tasks);
|
||||
|
||||
// Update statuses after creation
|
||||
const plan = todoStore.getPlan();
|
||||
if (plan) {
|
||||
todos.forEach((todo, index) => {
|
||||
if (plan.items[index]) {
|
||||
todoStore.updateItemStatus(
|
||||
plan.items[index].id,
|
||||
todo.status as TodoStatus,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Update existing plan - sync with provided todos
|
||||
const currentItems = existingPlan.items;
|
||||
|
||||
// Update existing items
|
||||
todos.forEach((todo) => {
|
||||
const existing = currentItems.find(
|
||||
(item) => item.id === todo.id || item.title === todo.title,
|
||||
);
|
||||
if (existing) {
|
||||
todoStore.updateItemStatus(existing.id, todo.status as TodoStatus);
|
||||
} else {
|
||||
// Add new item
|
||||
const newId = todoStore.addItem(todo.title);
|
||||
if (newId) {
|
||||
todoStore.updateItemStatus(newId, todo.status as TodoStatus);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get final state
|
||||
const plan = todoStore.getPlan();
|
||||
const progress = todoStore.getProgress();
|
||||
|
||||
if (!plan) {
|
||||
return {
|
||||
success: true,
|
||||
title: "Todos cleared",
|
||||
output: "Todo list is now empty.",
|
||||
};
|
||||
}
|
||||
|
||||
const summary = plan.items
|
||||
.map((item) => {
|
||||
const icon =
|
||||
item.status === "completed"
|
||||
? "✓"
|
||||
: item.status === "in_progress"
|
||||
? "→"
|
||||
: item.status === "failed"
|
||||
? "✗"
|
||||
: "○";
|
||||
return `${icon} ${item.title}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
title: "Todos updated",
|
||||
output: `Progress: ${progress.completed}/${progress.total} (${progress.percentage}%)\n\n${summary}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
32
src/tools/types.ts
Normal file
32
src/tools/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Tool system types and utilities
|
||||
*/
|
||||
|
||||
export type {
|
||||
ToolContext,
|
||||
ToolMetadata,
|
||||
ToolResult,
|
||||
ToolDefinition,
|
||||
ToolCall,
|
||||
ToolCallResult,
|
||||
FunctionDefinition,
|
||||
ToolStatus,
|
||||
BashParams,
|
||||
BashResultMetadata,
|
||||
EditParams,
|
||||
EditResultMetadata,
|
||||
GlobOptions,
|
||||
GlobResult,
|
||||
GrepMatch,
|
||||
GrepOptions,
|
||||
GrepResult,
|
||||
ReadParams,
|
||||
ReadResultMetadata,
|
||||
WriteParams,
|
||||
WriteResultMetadata,
|
||||
ViewResult,
|
||||
FileStat,
|
||||
} from "@/types/tools";
|
||||
|
||||
export { cleanJsonSchema } from "@tools/schema/clean";
|
||||
export { toolToFunction } from "@tools/schema/convert";
|
||||
16
src/tools/view.ts
Normal file
16
src/tools/view.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* File viewing tool - read file contents (functional)
|
||||
*/
|
||||
|
||||
export { executeView, fileExists, getFileStat } from "@tools/view/execute";
|
||||
|
||||
import { executeView, fileExists, getFileStat } from "@tools/view/execute";
|
||||
|
||||
/**
|
||||
* View tool object for backward compatibility
|
||||
*/
|
||||
export const viewTool = {
|
||||
execute: executeView,
|
||||
exists: fileExists,
|
||||
stat: getFileStat,
|
||||
};
|
||||
76
src/tools/view/execute.ts
Normal file
76
src/tools/view/execute.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* View tool execution (functional)
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
import { VIEW_MESSAGES, VIEW_DEFAULTS } from "@constants/view";
|
||||
import type { ViewResult, FileStat } from "@/types/tools";
|
||||
|
||||
const createSuccessResult = (output: string): ViewResult => ({
|
||||
success: true,
|
||||
title: "View",
|
||||
output,
|
||||
});
|
||||
|
||||
const createErrorResult = (error: unknown): ViewResult => ({
|
||||
success: false,
|
||||
title: "View failed",
|
||||
output: "",
|
||||
error: VIEW_MESSAGES.FAILED(error),
|
||||
});
|
||||
|
||||
const extractLines = (
|
||||
content: string,
|
||||
startLine?: number,
|
||||
endLine?: number,
|
||||
): string => {
|
||||
if (startLine === undefined && endLine === undefined) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
const start = (startLine ?? VIEW_DEFAULTS.START_LINE) - 1;
|
||||
const end = endLine ?? lines.length;
|
||||
return lines.slice(start, end).join("\n");
|
||||
};
|
||||
|
||||
export const executeView = async (
|
||||
filePath: string,
|
||||
startLine?: number,
|
||||
endLine?: number,
|
||||
): Promise<ViewResult> => {
|
||||
try {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const content = await fs.readFile(absolutePath, "utf-8");
|
||||
const output = extractLines(content, startLine, endLine);
|
||||
|
||||
return createSuccessResult(output);
|
||||
} catch (error) {
|
||||
return createErrorResult(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const fileExists = async (filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.access(path.resolve(filePath));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getFileStat = async (
|
||||
filePath: string,
|
||||
): Promise<FileStat | null> => {
|
||||
try {
|
||||
const stats = await fs.stat(path.resolve(filePath));
|
||||
return {
|
||||
size: stats.size,
|
||||
modified: stats.mtime,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
6
src/tools/write.ts
Normal file
6
src/tools/write.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Write tool for creating/overwriting files
|
||||
*/
|
||||
|
||||
export { writeParams, type WriteParamsSchema } from "@tools/write/params";
|
||||
export { executeWrite, writeTool } from "@tools/write/execute";
|
||||
170
src/tools/write/execute.ts
Normal file
170
src/tools/write/execute.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Write tool execution
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
import {
|
||||
WRITE_MESSAGES,
|
||||
WRITE_TITLES,
|
||||
WRITE_DESCRIPTION,
|
||||
} from "@constants/write";
|
||||
import { isFileOpAllowed, promptFilePermission } from "@services/permissions";
|
||||
import { formatDiff, generateDiff } from "@utils/diff";
|
||||
import { writeParams } from "@tools/write/params";
|
||||
import type {
|
||||
ToolDefinition,
|
||||
ToolContext,
|
||||
ToolResult,
|
||||
WriteParams,
|
||||
} from "@/types/tools";
|
||||
|
||||
const createDeniedResult = (relativePath: string): ToolResult => ({
|
||||
success: false,
|
||||
title: WRITE_TITLES.CANCELLED(relativePath),
|
||||
output: "",
|
||||
error: WRITE_MESSAGES.PERMISSION_DENIED,
|
||||
});
|
||||
|
||||
const createErrorResult = (relativePath: string, error: Error): ToolResult => ({
|
||||
success: false,
|
||||
title: WRITE_TITLES.FAILED(relativePath),
|
||||
output: "",
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
const createSuccessResult = (
|
||||
relativePath: string,
|
||||
fullPath: string,
|
||||
diffOutput: string,
|
||||
exists: boolean,
|
||||
content: string,
|
||||
additions: number,
|
||||
deletions: number,
|
||||
): ToolResult => ({
|
||||
success: true,
|
||||
title: exists
|
||||
? WRITE_TITLES.OVERWROTE(relativePath)
|
||||
: WRITE_TITLES.CREATED(relativePath),
|
||||
output: diffOutput,
|
||||
metadata: {
|
||||
filepath: fullPath,
|
||||
exists,
|
||||
bytes: Buffer.byteLength(content, "utf-8"),
|
||||
lines: content.split("\n").length,
|
||||
additions,
|
||||
deletions,
|
||||
},
|
||||
});
|
||||
|
||||
const resolvePaths = (
|
||||
filePath: string,
|
||||
workingDir: string,
|
||||
): { fullPath: string; relativePath: string } => {
|
||||
const fullPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.join(workingDir, filePath);
|
||||
const relativePath = path.relative(workingDir, fullPath);
|
||||
return { fullPath, relativePath };
|
||||
};
|
||||
|
||||
const readExistingContent = async (
|
||||
fullPath: string,
|
||||
): Promise<{ exists: boolean; content: string }> => {
|
||||
try {
|
||||
const content = await fs.readFile(fullPath, "utf-8");
|
||||
return { exists: true, content };
|
||||
} catch {
|
||||
return { exists: false, content: "" };
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionDescription = (
|
||||
exists: boolean,
|
||||
relativePath: string,
|
||||
): string =>
|
||||
exists
|
||||
? WRITE_TITLES.OVERWRITE_DESC(relativePath)
|
||||
: WRITE_TITLES.CREATE_DESC(relativePath);
|
||||
|
||||
const checkPermission = async (
|
||||
fullPath: string,
|
||||
relativePath: string,
|
||||
exists: boolean,
|
||||
autoApprove: boolean,
|
||||
): Promise<boolean> => {
|
||||
if (autoApprove || isFileOpAllowed("Write", fullPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const description = getPermissionDescription(exists, relativePath);
|
||||
const { allowed } = await promptFilePermission(
|
||||
"Write",
|
||||
fullPath,
|
||||
description,
|
||||
);
|
||||
return allowed;
|
||||
};
|
||||
|
||||
const ensureDirectory = async (fullPath: string): Promise<void> => {
|
||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||
};
|
||||
|
||||
const writeContent = async (
|
||||
fullPath: string,
|
||||
content: string,
|
||||
): Promise<void> => {
|
||||
await fs.writeFile(fullPath, content, "utf-8");
|
||||
};
|
||||
|
||||
export const executeWrite = async (
|
||||
args: WriteParams,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> => {
|
||||
const { filePath, content } = args;
|
||||
const { fullPath, relativePath } = resolvePaths(filePath, ctx.workingDir);
|
||||
|
||||
const { exists, content: oldContent } = await readExistingContent(fullPath);
|
||||
|
||||
const allowed = await checkPermission(
|
||||
fullPath,
|
||||
relativePath,
|
||||
exists,
|
||||
ctx.autoApprove ?? false,
|
||||
);
|
||||
if (!allowed) return createDeniedResult(relativePath);
|
||||
|
||||
ctx.onMetadata?.({
|
||||
title: WRITE_TITLES.WRITING(path.basename(filePath)),
|
||||
status: "running",
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureDirectory(fullPath);
|
||||
|
||||
const diff = generateDiff(oldContent, content);
|
||||
const diffOutput = formatDiff(diff, relativePath);
|
||||
|
||||
await writeContent(fullPath, content);
|
||||
|
||||
return createSuccessResult(
|
||||
relativePath,
|
||||
fullPath,
|
||||
diffOutput,
|
||||
exists,
|
||||
content,
|
||||
diff.additions,
|
||||
diff.deletions,
|
||||
);
|
||||
} catch (error) {
|
||||
return createErrorResult(relativePath, error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
export const writeTool: ToolDefinition<typeof writeParams> = {
|
||||
name: "write",
|
||||
description: WRITE_DESCRIPTION,
|
||||
parameters: writeParams,
|
||||
execute: executeWrite,
|
||||
};
|
||||
12
src/tools/write/params.ts
Normal file
12
src/tools/write/params.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Write tool parameter schema
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const writeParams = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to write"),
|
||||
content: z.string().describe("The content to write to the file"),
|
||||
});
|
||||
|
||||
export type WriteParamsSchema = typeof writeParams;
|
||||
Reference in New Issue
Block a user