specvector 0.0.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Grep Search Tool - Search codebase for patterns.
3
+ *
4
+ * Uses execFile with array arguments to prevent command injection.
5
+ */
6
+
7
+ import { execFile } from "child_process";
8
+ import { promisify } from "util";
9
+ import type { Tool, ToolResult } from "../types";
10
+ import { ok, err } from "../../types/result";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+
14
+ /** Maximum number of results to return */
15
+ const DEFAULT_MAX_RESULTS = 50;
16
+
17
+ /** Timeout for grep command in ms */
18
+ const GREP_TIMEOUT_MS = 30_000;
19
+
20
+ /** Configuration for grep tool */
21
+ export interface GrepConfig {
22
+ /** Working directory for search */
23
+ workingDir: string;
24
+ /** Maximum results to return (default: 50) */
25
+ maxResults?: number;
26
+ /** Timeout in ms (default: 30s) */
27
+ timeoutMs?: number;
28
+ }
29
+
30
+ /**
31
+ * Create the grep tool.
32
+ */
33
+ export function createGrepTool(config: GrepConfig): Tool {
34
+ const maxResults = config.maxResults ?? DEFAULT_MAX_RESULTS;
35
+ const timeoutMs = config.timeoutMs ?? GREP_TIMEOUT_MS;
36
+
37
+ return {
38
+ name: "grep",
39
+ description: "Search for a pattern in files. Returns matching lines with file paths and line numbers. Use this to find usages of functions, imports, or specific code patterns.",
40
+ parameters: {
41
+ type: "object",
42
+ properties: {
43
+ pattern: {
44
+ type: "string",
45
+ description: "Search pattern (supports regex)",
46
+ },
47
+ include: {
48
+ type: "string",
49
+ description: "File glob pattern to include (e.g., '*.ts', '*.py'). Optional.",
50
+ },
51
+ },
52
+ required: ["pattern"],
53
+ },
54
+
55
+ execute: async (args): Promise<ToolResult> => {
56
+ const pattern = args.pattern;
57
+ const include = args.include;
58
+
59
+ // Validate pattern
60
+ if (typeof pattern !== "string" || !pattern.trim()) {
61
+ return err({
62
+ code: "INVALID_PATTERN",
63
+ message: "Pattern must be a non-empty string",
64
+ });
65
+ }
66
+
67
+ try {
68
+ // Build grep arguments as array (prevents command injection)
69
+ const grepArgs: string[] = [
70
+ "-r", // Recursive
71
+ "-n", // Line numbers
72
+ "-I", // Ignore binary files
73
+ "--color=never",
74
+ ];
75
+
76
+ if (include && typeof include === "string") {
77
+ // Validate include pattern (only allow safe glob characters)
78
+ if (!/^[\w.*?\[\]/-]+$/.test(include)) {
79
+ return err({
80
+ code: "INVALID_INCLUDE",
81
+ message: "Include pattern contains invalid characters",
82
+ });
83
+ }
84
+ grepArgs.push(`--include=${include}`);
85
+ }
86
+
87
+ // Pattern and search path
88
+ grepArgs.push(pattern, ".");
89
+
90
+ const { stdout, stderr } = await execFileAsync("grep", grepArgs, {
91
+ cwd: config.workingDir,
92
+ maxBuffer: 1024 * 1024, // 1MB
93
+ timeout: timeoutMs,
94
+ });
95
+
96
+ if (stderr && !stdout) {
97
+ return err({
98
+ code: "GREP_ERROR",
99
+ message: stderr.trim(),
100
+ });
101
+ }
102
+
103
+ // Parse and limit results
104
+ const lines = stdout.trim().split("\n").filter(Boolean);
105
+ const limitedLines = lines.slice(0, maxResults);
106
+
107
+ if (lines.length === 0) {
108
+ return ok("No matches found.");
109
+ }
110
+
111
+ let result = limitedLines.join("\n");
112
+ if (lines.length > maxResults) {
113
+ result += `\n\n... and ${lines.length - maxResults} more matches (limited to ${maxResults})`;
114
+ }
115
+
116
+ return ok(result);
117
+ } catch (error) {
118
+ // grep returns exit code 1 when no matches found
119
+ if (isExecError(error) && error.code === 1 && !error.stderr) {
120
+ return ok("No matches found.");
121
+ }
122
+
123
+ // Handle timeout
124
+ if (isExecError(error) && error.killed) {
125
+ return err({
126
+ code: "TIMEOUT",
127
+ message: `Search timed out after ${timeoutMs}ms`,
128
+ });
129
+ }
130
+
131
+ return err({
132
+ code: "GREP_ERROR",
133
+ message: `Search failed: ${error instanceof Error ? error.message : "Unknown error"}`,
134
+ });
135
+ }
136
+ },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Type guard for exec errors.
142
+ */
143
+ function isExecError(error: unknown): error is { code: number; stderr: string; killed?: boolean } {
144
+ return (
145
+ error !== null &&
146
+ typeof error === "object" &&
147
+ "code" in error
148
+ );
149
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Agent Tools - Barrel export for all tools.
3
+ */
4
+
5
+ export { createReadFileTool, type ReadFileConfig } from "./read-file";
6
+ export { createGrepTool, type GrepConfig } from "./grep";
7
+ export { createListDirTool, type ListDirConfig } from "./list-dir";
8
+ export { createOutlineTool, type OutlineConfig } from "./outline";
9
+ export { createFindSymbolTool, type FindSymbolConfig } from "./find-symbol";
@@ -0,0 +1,191 @@
1
+ /**
2
+ * List Directory Tool - List files in a directory.
3
+ *
4
+ * Security: Uses path.relative() check for path traversal.
5
+ */
6
+
7
+ import { readdir, stat } from "fs/promises";
8
+ import { resolve, isAbsolute, relative } from "path";
9
+ import type { Tool, ToolResult } from "../types";
10
+ import { ok, err } from "../../types/result";
11
+
12
+ /** Maximum depth for recursive listing */
13
+ const DEFAULT_MAX_DEPTH = 3;
14
+
15
+ /** Maximum files to return */
16
+ const DEFAULT_MAX_FILES = 100;
17
+
18
+ /** Configuration for list directory tool */
19
+ export interface ListDirConfig {
20
+ /** Working directory for relative paths */
21
+ workingDir: string;
22
+ /** Maximum depth for recursive listing (default: 3) */
23
+ maxDepth?: number;
24
+ /** Maximum files to return (default: 100) */
25
+ maxFiles?: number;
26
+ }
27
+
28
+ interface FileInfo {
29
+ path: string;
30
+ type: "file" | "directory";
31
+ }
32
+
33
+ /**
34
+ * Create the list_dir tool.
35
+ */
36
+ export function createListDirTool(config: ListDirConfig): Tool {
37
+ const maxDepth = config.maxDepth ?? DEFAULT_MAX_DEPTH;
38
+ const maxFiles = config.maxFiles ?? DEFAULT_MAX_FILES;
39
+ // Normalize working directory path
40
+ const normalizedWorkingDir = resolve(config.workingDir);
41
+
42
+ return {
43
+ name: "list_dir",
44
+ description: "List files and directories. Use this to explore the project structure and find relevant files.",
45
+ parameters: {
46
+ type: "object",
47
+ properties: {
48
+ path: {
49
+ type: "string",
50
+ description: "Directory path (relative to working directory or absolute). Use '.' for current directory.",
51
+ },
52
+ recursive: {
53
+ type: "boolean",
54
+ description: "If true, list files recursively (up to max depth). Default: false.",
55
+ },
56
+ },
57
+ required: ["path"],
58
+ },
59
+
60
+ execute: async (args): Promise<ToolResult> => {
61
+ const pathArg = args.path;
62
+ const recursive = args.recursive === true;
63
+
64
+ // Validate path argument
65
+ if (typeof pathArg !== "string") {
66
+ return err({
67
+ code: "INVALID_PATH",
68
+ message: "Path must be a string",
69
+ });
70
+ }
71
+
72
+ // Handle empty path or "."
73
+ const targetPath = pathArg.trim() || ".";
74
+
75
+ // Resolve path
76
+ const dirPath = isAbsolute(targetPath)
77
+ ? resolve(targetPath)
78
+ : resolve(normalizedWorkingDir, targetPath);
79
+
80
+ // Security: Check path traversal using relative path
81
+ const relativePath = relative(normalizedWorkingDir, dirPath);
82
+ if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
83
+ return err({
84
+ code: "PATH_TRAVERSAL",
85
+ message: `Path must be within working directory`,
86
+ });
87
+ }
88
+
89
+ try {
90
+ const stats = await stat(dirPath);
91
+
92
+ if (!stats.isDirectory()) {
93
+ return err({
94
+ code: "NOT_A_DIRECTORY",
95
+ message: `Path is not a directory: ${pathArg}`,
96
+ });
97
+ }
98
+
99
+ const files: FileInfo[] = [];
100
+ await listDirectory(dirPath, normalizedWorkingDir, files, 0, recursive ? maxDepth : 0, maxFiles);
101
+
102
+ if (files.length === 0) {
103
+ return ok("Directory is empty.");
104
+ }
105
+
106
+ // Format output
107
+ const output = files
108
+ .map((f) => `${f.type === "directory" ? "📁" : "📄"} ${f.path}`)
109
+ .join("\n");
110
+
111
+ const result = files.length >= maxFiles
112
+ ? `${output}\n\n... limited to ${maxFiles} items`
113
+ : output;
114
+
115
+ return ok(result);
116
+ } catch (error) {
117
+ if (isNodeError(error)) {
118
+ if (error.code === "ENOENT") {
119
+ return err({
120
+ code: "NOT_FOUND",
121
+ message: `Directory not found: ${pathArg}`,
122
+ });
123
+ }
124
+ if (error.code === "EACCES") {
125
+ return err({
126
+ code: "PERMISSION_DENIED",
127
+ message: `Permission denied: ${pathArg}`,
128
+ });
129
+ }
130
+ }
131
+
132
+ return err({
133
+ code: "LIST_ERROR",
134
+ message: `Failed to list directory: ${error instanceof Error ? error.message : "Unknown error"}`,
135
+ });
136
+ }
137
+ },
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Recursively list directory contents.
143
+ */
144
+ async function listDirectory(
145
+ dirPath: string,
146
+ workingDir: string,
147
+ files: FileInfo[],
148
+ depth: number,
149
+ maxDepth: number,
150
+ maxFiles: number
151
+ ): Promise<void> {
152
+ if (files.length >= maxFiles) return;
153
+
154
+ const entries = await readdir(dirPath, { withFileTypes: true });
155
+
156
+ // Sort: directories first, then files, both alphabetically
157
+ entries.sort((a, b) => {
158
+ if (a.isDirectory() && !b.isDirectory()) return -1;
159
+ if (!a.isDirectory() && b.isDirectory()) return 1;
160
+ return a.name.localeCompare(b.name);
161
+ });
162
+
163
+ for (const entry of entries) {
164
+ if (files.length >= maxFiles) break;
165
+
166
+ // Skip hidden files and common non-essential directories
167
+ if (entry.name.startsWith(".") || entry.name === "node_modules") {
168
+ continue;
169
+ }
170
+
171
+ const fullPath = resolve(dirPath, entry.name);
172
+ const relativePath = relative(workingDir, fullPath);
173
+
174
+ files.push({
175
+ path: relativePath,
176
+ type: entry.isDirectory() ? "directory" : "file",
177
+ });
178
+
179
+ // Recurse into directories
180
+ if (entry.isDirectory() && depth < maxDepth) {
181
+ await listDirectory(fullPath, workingDir, files, depth + 1, maxDepth, maxFiles);
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Type guard for Node.js errors with codes.
188
+ */
189
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
190
+ return error instanceof Error && "code" in error;
191
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * File Outline Tool - Get structure of a file (functions, classes).
3
+ *
4
+ * Uses simple regex-based parsing for TypeScript/JavaScript files.
5
+ * This is fast and works without a full AST parser.
6
+ */
7
+
8
+ import { readFile, stat } from "fs/promises";
9
+ import { resolve, isAbsolute, relative, extname } from "path";
10
+ import type { Tool, ToolResult } from "../types";
11
+ import { ok, err } from "../../types/result";
12
+
13
+ /** Maximum file size for outline (200KB) */
14
+ const MAX_FILE_SIZE = 200 * 1024;
15
+
16
+ /** Configuration for outline tool */
17
+ export interface OutlineConfig {
18
+ /** Working directory for relative paths */
19
+ workingDir: string;
20
+ }
21
+
22
+ interface OutlineItem {
23
+ type: "function" | "class" | "method" | "interface" | "type" | "const" | "export";
24
+ name: string;
25
+ line: number;
26
+ signature?: string;
27
+ }
28
+
29
+ /**
30
+ * Create the get_outline tool.
31
+ */
32
+ export function createOutlineTool(config: OutlineConfig): Tool {
33
+ const normalizedWorkingDir = resolve(config.workingDir);
34
+
35
+ return {
36
+ name: "get_outline",
37
+ description: "Get the structure of a source file - functions, classes, interfaces. Use this to understand what's in a file without reading all the code.",
38
+ parameters: {
39
+ type: "object",
40
+ properties: {
41
+ path: {
42
+ type: "string",
43
+ description: "Path to the file to analyze",
44
+ },
45
+ },
46
+ required: ["path"],
47
+ },
48
+
49
+ execute: async (args): Promise<ToolResult> => {
50
+ const pathArg = args.path;
51
+
52
+ if (typeof pathArg !== "string" || !pathArg.trim()) {
53
+ return err({
54
+ code: "INVALID_PATH",
55
+ message: "Path must be a non-empty string",
56
+ });
57
+ }
58
+
59
+ // Resolve and validate path
60
+ const filePath = isAbsolute(pathArg)
61
+ ? resolve(pathArg)
62
+ : resolve(normalizedWorkingDir, pathArg);
63
+
64
+ const relativePath = relative(normalizedWorkingDir, filePath);
65
+ if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
66
+ return err({
67
+ code: "PATH_TRAVERSAL",
68
+ message: "Path must be within working directory",
69
+ });
70
+ }
71
+
72
+ try {
73
+ const stats = await stat(filePath);
74
+
75
+ if (!stats.isFile()) {
76
+ return err({
77
+ code: "NOT_A_FILE",
78
+ message: `Path is not a file: ${pathArg}`,
79
+ });
80
+ }
81
+
82
+ if (stats.size > MAX_FILE_SIZE) {
83
+ return err({
84
+ code: "FILE_TOO_LARGE",
85
+ message: `File too large for outline (max 200KB)`,
86
+ });
87
+ }
88
+
89
+ const content = await readFile(filePath, "utf-8");
90
+ const ext = extname(filePath).toLowerCase();
91
+
92
+ // Only support common code files
93
+ const supportedExts = [".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs"];
94
+ if (!supportedExts.includes(ext)) {
95
+ return ok(`Outline not supported for ${ext} files. Use read_file instead.`);
96
+ }
97
+
98
+ const items = parseOutline(content, ext);
99
+
100
+ if (items.length === 0) {
101
+ return ok("No functions, classes, or interfaces found in this file.");
102
+ }
103
+
104
+ // Format output
105
+ const output = items
106
+ .map(item => {
107
+ const typeIcon = getTypeIcon(item.type);
108
+ const sig = item.signature ? `: ${item.signature}` : "";
109
+ return `${item.line.toString().padStart(4)} │ ${typeIcon} ${item.name}${sig}`;
110
+ })
111
+ .join("\n");
112
+
113
+ return ok(`File: ${pathArg}\n${"─".repeat(50)}\n${output}`);
114
+ } catch (error) {
115
+ if (isNodeError(error) && error.code === "ENOENT") {
116
+ return err({
117
+ code: "NOT_FOUND",
118
+ message: `File not found: ${pathArg}`,
119
+ });
120
+ }
121
+ if (isNodeError(error) && error.code === "EISDIR") {
122
+ return err({
123
+ code: "NOT_A_FILE",
124
+ message: `Path is a directory, not a file: ${pathArg}`,
125
+ });
126
+ }
127
+ return err({
128
+ code: "OUTLINE_ERROR",
129
+ message: `Failed to get outline: ${error instanceof Error ? error.message : "Unknown"}`,
130
+ });
131
+ }
132
+ },
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Parse file content to extract outline items.
138
+ */
139
+ function parseOutline(content: string, ext: string): OutlineItem[] {
140
+ const items: OutlineItem[] = [];
141
+ const lines = content.split("\n");
142
+
143
+ // Extension-specific patterns
144
+ const patterns = getPatterns(ext);
145
+
146
+ // Max line length to prevent ReDoS attacks
147
+ const MAX_LINE_LENGTH = 500;
148
+
149
+ for (let i = 0; i < lines.length; i++) {
150
+ const rawLine = lines[i];
151
+ const lineNum = i + 1;
152
+
153
+ // Skip long lines to prevent ReDoS (catastrophic backtracking)
154
+ if (!rawLine || rawLine.length > MAX_LINE_LENGTH) continue;
155
+
156
+ // Skip comment lines to avoid false positives
157
+ const trimmed = rawLine.trim();
158
+ if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("#")) {
159
+ continue;
160
+ }
161
+
162
+ for (const { regex, type, nameGroup, sigGroup } of patterns) {
163
+ const match = rawLine.match(regex);
164
+ if (match && match[nameGroup]) {
165
+ items.push({
166
+ type,
167
+ name: match[nameGroup],
168
+ line: lineNum,
169
+ signature: sigGroup && match[sigGroup] ? match[sigGroup].trim() : undefined,
170
+ });
171
+ break;
172
+ }
173
+ }
174
+ }
175
+
176
+ return items;
177
+ }
178
+
179
+ interface Pattern {
180
+ regex: RegExp;
181
+ type: OutlineItem["type"];
182
+ nameGroup: number;
183
+ sigGroup?: number;
184
+ }
185
+
186
+ function getPatterns(ext: string): Pattern[] {
187
+ if ([".ts", ".tsx", ".js", ".jsx"].includes(ext)) {
188
+ return [
189
+ // export function name(...)
190
+ { regex: /^export\s+(?:async\s+)?function\s+(\w+)\s*(\([^)]*\))?/, type: "function", nameGroup: 1, sigGroup: 2 },
191
+ // function name(...)
192
+ { regex: /^(?:async\s+)?function\s+(\w+)\s*(\([^)]*\))?/, type: "function", nameGroup: 1, sigGroup: 2 },
193
+ // const name = async (...) =>
194
+ { regex: /^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*(?::\s*\w+)?\s*=>/, type: "function", nameGroup: 1 },
195
+ // export class Name
196
+ { regex: /^export\s+(?:abstract\s+)?class\s+(\w+)/, type: "class", nameGroup: 1 },
197
+ // class Name
198
+ { regex: /^(?:abstract\s+)?class\s+(\w+)/, type: "class", nameGroup: 1 },
199
+ // export interface Name
200
+ { regex: /^export\s+interface\s+(\w+)/, type: "interface", nameGroup: 1 },
201
+ // interface Name
202
+ { regex: /^interface\s+(\w+)/, type: "interface", nameGroup: 1 },
203
+ // export type Name
204
+ { regex: /^export\s+type\s+(\w+)/, type: "type", nameGroup: 1 },
205
+ // type Name
206
+ { regex: /^type\s+(\w+)/, type: "type", nameGroup: 1 },
207
+ // method in class/object: name(...) { or async name(...)
208
+ { regex: /^\s+(?:async\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*\S+)?\s*\{/, type: "method", nameGroup: 1 },
209
+ ];
210
+ }
211
+
212
+ if (ext === ".py") {
213
+ return [
214
+ { regex: /^def\s+(\w+)\s*\(([^)]*)\)/, type: "function", nameGroup: 1, sigGroup: 2 },
215
+ { regex: /^async\s+def\s+(\w+)\s*\(([^)]*)\)/, type: "function", nameGroup: 1, sigGroup: 2 },
216
+ { regex: /^class\s+(\w+)/, type: "class", nameGroup: 1 },
217
+ { regex: /^\s{4}def\s+(\w+)\s*\(([^)]*)\)/, type: "method", nameGroup: 1, sigGroup: 2 },
218
+ ];
219
+ }
220
+
221
+ if (ext === ".go") {
222
+ return [
223
+ { regex: /^func\s+(\w+)\s*\(([^)]*)\)/, type: "function", nameGroup: 1, sigGroup: 2 },
224
+ { regex: /^func\s+\(\w+\s+\*?\w+\)\s+(\w+)\s*\(([^)]*)\)/, type: "method", nameGroup: 1, sigGroup: 2 },
225
+ { regex: /^type\s+(\w+)\s+struct/, type: "class", nameGroup: 1 },
226
+ { regex: /^type\s+(\w+)\s+interface/, type: "interface", nameGroup: 1 },
227
+ ];
228
+ }
229
+
230
+ if (ext === ".rs") {
231
+ return [
232
+ { regex: /^pub\s+fn\s+(\w+)\s*\(([^)]*)\)/, type: "function", nameGroup: 1, sigGroup: 2 },
233
+ { regex: /^fn\s+(\w+)\s*\(([^)]*)\)/, type: "function", nameGroup: 1, sigGroup: 2 },
234
+ { regex: /^pub\s+struct\s+(\w+)/, type: "class", nameGroup: 1 },
235
+ { regex: /^struct\s+(\w+)/, type: "class", nameGroup: 1 },
236
+ { regex: /^pub\s+trait\s+(\w+)/, type: "interface", nameGroup: 1 },
237
+ { regex: /^trait\s+(\w+)/, type: "interface", nameGroup: 1 },
238
+ ];
239
+ }
240
+
241
+ return [];
242
+ }
243
+
244
+ function getTypeIcon(type: OutlineItem["type"]): string {
245
+ switch (type) {
246
+ case "function": return "ƒ";
247
+ case "class": return "◆";
248
+ case "method": return "○";
249
+ case "interface": return "◇";
250
+ case "type": return "τ";
251
+ case "const": return "●";
252
+ case "export": return "→";
253
+ default: return "•";
254
+ }
255
+ }
256
+
257
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
258
+ return error instanceof Error && "code" in error;
259
+ }