novacode 0.5.5 → 0.7.0

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.
package/src/tools/git.ts DELETED
@@ -1,99 +0,0 @@
1
- /**
2
- * Git tools for executing safe repository operations programmatically.
3
- */
4
- import { spawn } from "node:child_process"
5
- import type { Tool, ToolResult } from "../types.ts"
6
- import { textPart } from "../util.ts"
7
-
8
- export function gitTool(cwd: string): Tool {
9
- return {
10
- def: {
11
- name: "git",
12
- description:
13
- "Execute safe, non-interactive git commands (status, diff, log, add, commit) in the repository.",
14
- parameters: {
15
- type: "object",
16
- properties: {
17
- action: {
18
- type: "string",
19
- enum: ["status", "diff", "log", "add", "commit"],
20
- description: "The git action to execute",
21
- },
22
- args: {
23
- type: "array",
24
- description: "Optional additional arguments or file paths for the git action",
25
- items: { type: "string" },
26
- },
27
- },
28
- required: ["action"],
29
- },
30
- },
31
- async execute(args, signal): Promise<ToolResult> {
32
- const action = args.action as string
33
- const extraArgs = (args.args as string[]) || []
34
-
35
- const allowed = new Set(["status", "diff", "log", "add", "commit"])
36
- if (!allowed.has(action)) {
37
- return {
38
- content: [textPart(`Error: Git action '${action}' is not supported.`)],
39
- isError: true,
40
- }
41
- }
42
-
43
- try {
44
- const cmd = ["git", action, ...extraArgs]
45
- const proc = spawn(cmd[0]!, cmd.slice(1), {
46
- cwd,
47
- stdio: ["ignore", "pipe", "pipe"],
48
- env: { ...process.env, PAGER: "cat" },
49
- })
50
-
51
- let stdout = ""
52
- let stderr = ""
53
- proc.stdout.on("data", (chunk: Buffer) => {
54
- stdout += chunk.toString()
55
- })
56
- proc.stderr.on("data", (chunk: Buffer) => {
57
- stderr += chunk.toString()
58
- })
59
-
60
- const onAbort = () => {
61
- proc.kill("SIGKILL")
62
- proc.stdout.destroy()
63
- proc.stderr.destroy()
64
- }
65
- signal?.addEventListener("abort", onAbort, { once: true })
66
-
67
- let exitCode: number
68
- try {
69
- exitCode = await new Promise<number>((resolve, reject) => {
70
- proc.on("error", reject)
71
- proc.on("close", (code) => resolve(code ?? -1))
72
- })
73
- } finally {
74
- signal?.removeEventListener("abort", onAbort)
75
- }
76
-
77
- // Prevent context window blowout by truncating very large outputs
78
- const MAX = 50_000
79
- let out = ""
80
- if (stdout) out += stdout.slice(0, MAX)
81
- if (stderr) {
82
- if (out) out += "\n"
83
- out += stderr.slice(0, MAX - out.length)
84
- }
85
- if (out.length >= MAX) out += "\n…truncated"
86
-
87
- return {
88
- content: [textPart(out || "(no output)")],
89
- isError: exitCode !== 0,
90
- }
91
- } catch (e) {
92
- return {
93
- content: [textPart(`Error running git: ${(e as Error).message}`)],
94
- isError: true,
95
- }
96
- }
97
- },
98
- }
99
- }
@@ -1,33 +0,0 @@
1
- import type { Tool } from "../types.ts"
2
- import { editTool, readTool, writeTool } from "./fs.ts"
3
- import { gitTool } from "./git.ts"
4
- import { globTool, grepTool, lsTool, treeTool } from "./search.ts"
5
- import { bashTool } from "./shell.ts"
6
- import { webFetchTool, webSearchTool } from "./web.ts"
7
-
8
- export function getAllTools(cwd: string): Tool[] {
9
- return [
10
- readTool(cwd),
11
- writeTool(cwd),
12
- editTool(cwd),
13
- bashTool(cwd),
14
- globTool(cwd),
15
- grepTool(cwd),
16
- lsTool(cwd),
17
- treeTool(cwd),
18
- gitTool(cwd),
19
- webSearchTool(),
20
- webFetchTool(),
21
- ]
22
- }
23
-
24
- export function getDefaultTools(cwd: string): Tool[] {
25
- return [
26
- readTool(cwd),
27
- writeTool(cwd),
28
- editTool(cwd),
29
- bashTool(cwd),
30
- webSearchTool(),
31
- webFetchTool(),
32
- ]
33
- }
@@ -1,274 +0,0 @@
1
- /**
2
- * Search tools for finding files and content.
3
- * Uses 'rg' (ripgrep) if available, falling back to a pure JS implementation.
4
- */
5
-
6
- import { spawn } from "node:child_process"
7
- import { readdir, readFile } from "node:fs/promises"
8
- import { relative, resolve } from "node:path"
9
- import { glob } from "glob"
10
- import type { Tool, ToolResult } from "../types.ts"
11
-
12
- import { textPart } from "../util.ts"
13
-
14
- /**
15
- * Tool for finding files by glob pattern.
16
- */
17
- export function globTool(cwd: string): Tool {
18
- return {
19
- def: {
20
- name: "glob",
21
- description: "Find files by glob pattern (e.g. **/*.ts, src/**/*.test.ts).",
22
- parameters: {
23
- type: "object",
24
- properties: {
25
- pattern: { type: "string", description: "Glob pattern (e.g. **/*.ts)" },
26
- path: { type: "string", description: "Directory to search in (default .)" },
27
- nocase: { type: "boolean", description: "Case-insensitive search (default false)" },
28
- },
29
- required: ["pattern"],
30
- },
31
- },
32
- async execute(args): Promise<ToolResult> {
33
- try {
34
- const rawPath = (args.path as string) || "."
35
- const dir = resolve(cwd, rawPath)
36
- if (dir !== cwd && !dir.startsWith(`${cwd}/`)) {
37
- throw new Error(`Path outside project: ${rawPath}`)
38
- }
39
-
40
- const pattern = args.pattern as string
41
- const nocase = !!args.nocase
42
- const files = await glob(pattern, { cwd: dir, nocase })
43
- const sliced = files.slice(0, 500)
44
- const relSearchPath = relative(cwd, dir)
45
- const prefix = relSearchPath ? `${relSearchPath}/` : ""
46
- const relFiles = sliced.map((f) => prefix + f)
47
- const out = relFiles.length > 0 ? relFiles.join("\n") : "No files found"
48
- return { content: [textPart(out)], isError: false }
49
- } catch (e) {
50
- return {
51
- content: [textPart(`Error: ${(e as Error).message}`)],
52
- isError: true,
53
- }
54
- }
55
- },
56
- }
57
- }
58
-
59
- /**
60
- * Tool for searching file contents using regex.
61
- */
62
- export function grepTool(cwd: string): Tool {
63
- return {
64
- def: {
65
- name: "grep",
66
- description:
67
- "Search file contents with a regex pattern. Returns matching lines with file paths and line numbers.",
68
- parameters: {
69
- type: "object",
70
- properties: {
71
- pattern: { type: "string", description: "Regex pattern to search for" },
72
- path: { type: "string", description: "Directory or file to search in (default .)" },
73
- glob: { type: "string", description: "File filter glob (e.g. *.ts)" },
74
- },
75
- required: ["pattern"],
76
- },
77
- },
78
- async execute(args, signal): Promise<ToolResult> {
79
- try {
80
- const rawPath = (args.path as string) || "."
81
- const dir = resolve(cwd, rawPath)
82
- if (dir !== cwd && !dir.startsWith(`${cwd}/`)) {
83
- throw new Error(`Path outside project: ${rawPath}`)
84
- }
85
-
86
- const pattern = args.pattern as string
87
- const globFilter = args.glob as string | undefined
88
- const relSearchPath = relative(cwd, dir) || "."
89
-
90
- // rg is 10-100x faster than our JS fallback, but isn't always installed
91
- try {
92
- const cmd = ["rg", "--line-number", "--max-count", "200"]
93
- if (globFilter) cmd.push(`--glob=${globFilter}`)
94
- cmd.push("--", pattern, relSearchPath)
95
-
96
- const proc = spawn(cmd[0]!, cmd.slice(1), {
97
- cwd,
98
- stdio: ["ignore", "pipe", "pipe"],
99
- })
100
-
101
- const onAbort = () => {
102
- proc.kill()
103
- proc.stdout.destroy()
104
- proc.stderr.destroy()
105
- }
106
- signal?.addEventListener("abort", onAbort, { once: true })
107
-
108
- let stdout = ""
109
- proc.stdout.on("data", (chunk: Buffer) => {
110
- stdout += chunk.toString()
111
- })
112
-
113
- let exitCode: number
114
- try {
115
- exitCode = await new Promise<number>((resolve, reject) => {
116
- proc.on("error", reject)
117
- proc.on("close", (code) => resolve(code ?? -1))
118
- })
119
- } finally {
120
- signal?.removeEventListener("abort", onAbort)
121
- }
122
-
123
- if (exitCode === 0) {
124
- const lines = stdout.split("\n").slice(0, 200).join("\n")
125
- return { content: [textPart(lines || "No matches")], isError: false }
126
- }
127
- } catch {
128
- // rg not available, fall through
129
- }
130
-
131
- // Pure JS fallback when rg is not available
132
- const files = await glob(globFilter || "**/*", {
133
- cwd: dir,
134
- ignore: ["**/node_modules/**", "**/.git/**"],
135
- })
136
- const prefix = relSearchPath === "." ? "" : `${relSearchPath}/`
137
- const re = new RegExp(pattern, "i")
138
- const matches: string[] = []
139
- for (const file of files.slice(0, 500)) {
140
- if (signal?.aborted) break
141
- try {
142
- const content = await readFile(resolve(dir, file), "utf-8")
143
- const lines = content.split("\n")
144
- for (let i = 0; i < lines.length && matches.length < 200; i++) {
145
- const line = lines[i]
146
- if (line && re.test(line)) matches.push(`${prefix}${file}:${i + 1}:${line}`)
147
- }
148
- } catch {
149
- // Skip binary/unreadable files silently
150
- }
151
- }
152
- return {
153
- content: [textPart(matches.join("\n") || "No matches")],
154
- isError: false,
155
- }
156
- } catch (e) {
157
- return {
158
- content: [textPart(`Error: ${(e as Error).message}`)],
159
- isError: true,
160
- }
161
- }
162
- },
163
- }
164
- }
165
-
166
- /**
167
- * Tool for listing directory entries.
168
- */
169
- export function lsTool(cwd: string): Tool {
170
- return {
171
- def: {
172
- name: "ls",
173
- description: "List directory contents.",
174
- parameters: {
175
- type: "object",
176
- properties: {
177
- path: { type: "string", description: "Directory to list (default .)" },
178
- },
179
- required: [],
180
- },
181
- },
182
- async execute(args): Promise<ToolResult> {
183
- try {
184
- const dir = resolve(cwd, (args.path as string) || ".")
185
- const entries = await readdir(dir, { withFileTypes: true })
186
- const lines = entries.map((e) => {
187
- const suffix = e.isDirectory() ? "/" : e.isSymbolicLink() ? "@" : ""
188
- return `${e.name}${suffix}`
189
- })
190
- return { content: [textPart(lines.join("\n") || "(empty)")], isError: false }
191
- } catch (e) {
192
- return {
193
- content: [textPart(`Error: ${(e as Error).message}`)],
194
- isError: true,
195
- }
196
- }
197
- },
198
- }
199
- }
200
-
201
- /**
202
- * Tool for visualizing a truncated directory tree.
203
- */
204
- export function treeTool(cwd: string): Tool {
205
- return {
206
- def: {
207
- name: "tree",
208
- description:
209
- "Print a visual directory tree structure, ignoring common ignored folders like node_modules and .git.",
210
- parameters: {
211
- type: "object",
212
- properties: {
213
- path: { type: "string", description: "Directory to start tree from (default .)" },
214
- depth: { type: "number", description: "Maximum depth to traverse (default 3)" },
215
- },
216
- required: [],
217
- },
218
- },
219
- async execute(args): Promise<ToolResult> {
220
- try {
221
- const startDir = resolve(cwd, (args.path as string) || ".")
222
- const maxDepth = Number(args.depth ?? 3) || 3
223
-
224
- const ignoreList = new Set([
225
- ".git",
226
- "node_modules",
227
- "dist",
228
- "build",
229
- ".svelte-kit",
230
- ".next",
231
- "out",
232
- ".scannerwork",
233
- "coverage",
234
- ])
235
-
236
- async function walk(dir: string, currentDepth: number, prefix: string): Promise<string> {
237
- if (currentDepth > maxDepth) return ""
238
- let result = ""
239
-
240
- const entries = await readdir(dir, { withFileTypes: true })
241
- const sorted = entries
242
- .filter((e) => !ignoreList.has(e.name))
243
- .sort((a, b) => {
244
- if (a.isDirectory() && !b.isDirectory()) return -1
245
- if (!a.isDirectory() && b.isDirectory()) return 1
246
- return a.name.localeCompare(b.name)
247
- })
248
-
249
- for (let i = 0; i < sorted.length; i++) {
250
- const entry = sorted[i]!
251
- const isLast = i === sorted.length - 1
252
- const connector = isLast ? "└── " : "├── "
253
- const childPrefix = prefix + (isLast ? " " : "│ ")
254
-
255
- result += `${prefix}${connector}${entry.name}${entry.isDirectory() ? "/" : ""}\n`
256
-
257
- if (entry.isDirectory()) {
258
- result += await walk(resolve(dir, entry.name), currentDepth + 1, childPrefix)
259
- }
260
- }
261
- return result
262
- }
263
-
264
- const treeText = await walk(startDir, 1, "")
265
- return { content: [textPart(treeText || "(empty)")], isError: false }
266
- } catch (e) {
267
- return {
268
- content: [textPart(`Error: ${(e as Error).message}`)],
269
- isError: true,
270
- }
271
- }
272
- },
273
- }
274
- }
@@ -1,90 +0,0 @@
1
- /**
2
- * Tool for executing shell commands within the project root.
3
- * Supports timeouts and output truncation to protect the context window.
4
- */
5
- import { spawn } from "node:child_process"
6
- import type { Tool, ToolResult } from "../types.ts"
7
-
8
- import { textPart } from "../util.ts"
9
-
10
- export function bashTool(cwd: string): Tool {
11
- return {
12
- def: {
13
- name: "bash",
14
- description:
15
- "Execute a shell command. Returns stdout and stderr. Timeout after N seconds (default 120).",
16
- parameters: {
17
- type: "object",
18
- properties: {
19
- command: { type: "string", description: "Shell command to run" },
20
- timeout: { type: "number", description: "Timeout in seconds (default 120)" },
21
- },
22
- required: ["command"],
23
- },
24
- },
25
- async execute(args, signal): Promise<ToolResult> {
26
- const command = args.command as string
27
- const timeoutMs = (Number(args.timeout) || 120) * 1000
28
-
29
- try {
30
- const proc = spawn("sh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] })
31
-
32
- let stdout = ""
33
- let stderr = ""
34
- proc.stdout.on("data", (chunk: Buffer) => {
35
- stdout += chunk.toString()
36
- })
37
- proc.stderr.on("data", (chunk: Buffer) => {
38
- stderr += chunk.toString()
39
- })
40
-
41
- let killed = false
42
- const timer = setTimeout(() => {
43
- killed = true
44
- proc.kill("SIGKILL")
45
- proc.stdout.destroy()
46
- proc.stderr.destroy()
47
- }, timeoutMs)
48
-
49
- const onAbort = () => {
50
- killed = true
51
- proc.kill("SIGKILL")
52
- proc.stdout.destroy()
53
- proc.stderr.destroy()
54
- }
55
- signal?.addEventListener("abort", onAbort, { once: true })
56
-
57
- let exitCode: number
58
- try {
59
- exitCode = await new Promise<number>((resolve, reject) => {
60
- proc.on("error", reject)
61
- proc.on("close", (code) => resolve(code ?? -1))
62
- })
63
- } finally {
64
- clearTimeout(timer)
65
- signal?.removeEventListener("abort", onAbort)
66
- }
67
-
68
- // Prevent context-window blowout from noisy commands
69
- const MAX = 50_000
70
- let out = ""
71
- if (stdout) out += stdout.slice(0, MAX)
72
- if (stderr) {
73
- if (out) out += "\n"
74
- out += stderr.slice(0, MAX - out.length)
75
- }
76
- if (out.length >= MAX) out += `\n…truncated`
77
-
78
- if (killed) out += `\n[timeout after ${timeoutMs / 1000}s]`
79
- out += `\n[exit ${exitCode}]`
80
-
81
- return { content: [textPart(out)], isError: exitCode !== 0 || killed }
82
- } catch (e) {
83
- return {
84
- content: [textPart(`Error: ${(e as Error).message}`)],
85
- isError: true,
86
- }
87
- }
88
- },
89
- }
90
- }