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:
2026-01-27 23:33:06 -05:00
commit 0062e5d9d9
521 changed files with 66418 additions and 0 deletions

21
src/tools/bash.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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
View 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,
};

View 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
View 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
View 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,
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;