@cruxy/cli 0.1.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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/dist/agent/approval.d.ts +41 -0
  4. package/dist/agent/approval.js +179 -0
  5. package/dist/agent/index.d.ts +4 -0
  6. package/dist/agent/index.js +4 -0
  7. package/dist/agent/loop.d.ts +53 -0
  8. package/dist/agent/loop.js +148 -0
  9. package/dist/agent/prompts.d.ts +53 -0
  10. package/dist/agent/prompts.js +99 -0
  11. package/dist/agent/session.d.ts +107 -0
  12. package/dist/agent/session.js +236 -0
  13. package/dist/cli/commands/config.d.ts +2 -0
  14. package/dist/cli/commands/config.js +59 -0
  15. package/dist/cli/commands/run.d.ts +2 -0
  16. package/dist/cli/commands/run.js +85 -0
  17. package/dist/cli/program.d.ts +2 -0
  18. package/dist/cli/program.js +36 -0
  19. package/dist/cli/repl.d.ts +15 -0
  20. package/dist/cli/repl.js +114 -0
  21. package/dist/cli/stream-print.d.ts +14 -0
  22. package/dist/cli/stream-print.js +26 -0
  23. package/dist/config/index.d.ts +4 -0
  24. package/dist/config/index.js +4 -0
  25. package/dist/config/manager.d.ts +34 -0
  26. package/dist/config/manager.js +151 -0
  27. package/dist/config/paths.d.ts +9 -0
  28. package/dist/config/paths.js +31 -0
  29. package/dist/config/project.d.ts +10 -0
  30. package/dist/config/project.js +36 -0
  31. package/dist/config/schema.d.ts +303 -0
  32. package/dist/config/schema.js +100 -0
  33. package/dist/constants.d.ts +11 -0
  34. package/dist/constants.js +31 -0
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.js +13 -0
  37. package/dist/tools/file/apply-patch.d.ts +94 -0
  38. package/dist/tools/file/apply-patch.js +195 -0
  39. package/dist/tools/file/edit-file.d.ts +14 -0
  40. package/dist/tools/file/edit-file.js +81 -0
  41. package/dist/tools/file/glob.d.ts +10 -0
  42. package/dist/tools/file/glob.js +52 -0
  43. package/dist/tools/file/grep-files.d.ts +32 -0
  44. package/dist/tools/file/grep-files.js +113 -0
  45. package/dist/tools/file/index.d.ts +7 -0
  46. package/dist/tools/file/index.js +7 -0
  47. package/dist/tools/file/paths.d.ts +24 -0
  48. package/dist/tools/file/paths.js +65 -0
  49. package/dist/tools/file/read-file.d.ts +8 -0
  50. package/dist/tools/file/read-file.js +52 -0
  51. package/dist/tools/file/write-file.d.ts +10 -0
  52. package/dist/tools/file/write-file.js +56 -0
  53. package/dist/tools/git-status.d.ts +8 -0
  54. package/dist/tools/git-status.js +26 -0
  55. package/dist/tools/index.d.ts +5 -0
  56. package/dist/tools/index.js +5 -0
  57. package/dist/tools/list-files.d.ts +7 -0
  58. package/dist/tools/list-files.js +27 -0
  59. package/dist/tools/registry.d.ts +23 -0
  60. package/dist/tools/registry.js +63 -0
  61. package/dist/tools/shell/index.d.ts +1 -0
  62. package/dist/tools/shell/index.js +1 -0
  63. package/dist/tools/shell/run-command.d.ts +10 -0
  64. package/dist/tools/shell/run-command.js +100 -0
  65. package/dist/tools/types.d.ts +113 -0
  66. package/dist/tools/types.js +1 -0
  67. package/dist/utils/git.d.ts +17 -0
  68. package/dist/utils/git.js +43 -0
  69. package/dist/utils/logger.d.ts +16 -0
  70. package/dist/utils/logger.js +42 -0
  71. package/package.json +52 -0
@@ -0,0 +1,195 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { resolveInRoot } from "./paths.js";
5
+ import { countOccurrences } from "./edit-file.js";
6
+ /** How many leading lines of a created file the approval preview shows. */
7
+ const PREVIEW_LINES = 20;
8
+ const HunkSchema = z.object({
9
+ oldStr: z
10
+ .string()
11
+ .min(1)
12
+ .describe("Exact text to replace; must occur exactly once in the file."),
13
+ newStr: z.string().describe("Replacement text."),
14
+ });
15
+ const OperationSchema = z.discriminatedUnion("type", [
16
+ z.object({
17
+ type: z.literal("update"),
18
+ path: z
19
+ .string()
20
+ .describe("Path to an existing file, relative to the root."),
21
+ hunks: z
22
+ .array(HunkSchema)
23
+ .min(1)
24
+ .describe("Edits applied in order; each oldStr must match exactly once."),
25
+ }),
26
+ z.object({
27
+ type: z.literal("create"),
28
+ path: z.string().describe("Path for a new file; must not already exist."),
29
+ content: z.string().describe("Full UTF-8 contents of the new file."),
30
+ }),
31
+ z.object({
32
+ type: z.literal("delete"),
33
+ path: z.string().describe("Path to an existing file to delete."),
34
+ }),
35
+ ]);
36
+ const parameters = z.object({
37
+ operations: z
38
+ .array(OperationSchema)
39
+ .min(1)
40
+ .describe("The file operations to apply, all-or-nothing."),
41
+ });
42
+ /**
43
+ * Apply a set of file edits across one or more files in a single, atomic,
44
+ * reviewed operation. The entire patch is validated up front — every path
45
+ * resolved inside the project, every `update` hunk confirmed to match exactly
46
+ * once, every `create` confirmed absent and `delete` confirmed present — and if
47
+ * anything fails, nothing is written. A single approval covers the whole patch.
48
+ */
49
+ export const applyPatchTool = {
50
+ name: "apply_patch",
51
+ description: "Apply multiple edits across one or more files in a single, atomic, reviewed change — preferred over many edit_file calls for multi-file or multi-hunk work. " +
52
+ "Input is { operations: [...] } where each operation is one of: " +
53
+ '{ "type":"update", "path", "hunks":[{ "oldStr", "newStr" }] } — replace each oldStr (which must match EXACTLY ONCE in the file, like edit_file; hunks apply in order) with newStr; ' +
54
+ '{ "type":"create", "path", "content" } — create a new file (must not already exist); ' +
55
+ '{ "type":"delete", "path" } — delete an existing file. ' +
56
+ "The whole patch is validated before anything is written: if any operation is invalid, nothing is applied and the failing operation is reported.",
57
+ parameters,
58
+ async execute(input, ctx) {
59
+ const planned = [];
60
+ const seen = new Set();
61
+ for (let i = 0; i < input.operations.length; i++) {
62
+ const op = input.operations[i];
63
+ let abs;
64
+ try {
65
+ abs = await resolveInRoot(ctx, op.path);
66
+ }
67
+ catch (err) {
68
+ return { ok: false, error: opError(i, op, err.message) };
69
+ }
70
+ if (seen.has(abs)) {
71
+ return {
72
+ ok: false,
73
+ error: opError(i, op, "duplicate path in patch"),
74
+ };
75
+ }
76
+ seen.add(abs);
77
+ const planResult = await planOp(i, op, abs, ctx);
78
+ if (!planResult.ok)
79
+ return planResult;
80
+ planned.push(planResult.planned);
81
+ }
82
+ // One approval for the whole patch — denial writes nothing.
83
+ const approved = await ctx.approve({
84
+ kind: "patch",
85
+ preview: { type: "patch", files: planned.map(toPreview) },
86
+ });
87
+ if (!approved) {
88
+ return { ok: false, error: "patch denied" };
89
+ }
90
+ // Validation passed and the user approved; apply everything. A mid-apply I/O
91
+ // failure is rare but reported with what already landed.
92
+ const applied = [];
93
+ for (const p of planned) {
94
+ try {
95
+ if (p.op === "delete") {
96
+ await fs.rm(p.abs);
97
+ }
98
+ else {
99
+ if (p.op === "create") {
100
+ await fs.mkdir(path.dirname(p.abs), { recursive: true });
101
+ }
102
+ await fs.writeFile(p.abs, p.content, "utf8");
103
+ }
104
+ applied.push(`${p.op} ${p.rel}`);
105
+ }
106
+ catch (err) {
107
+ return {
108
+ ok: false,
109
+ error: `patch partially applied then failed at ${p.op} ${p.rel}: ${err.message}. ` +
110
+ `Applied before failure: ${applied.join(", ") || "(none)"}`,
111
+ };
112
+ }
113
+ }
114
+ return { ok: true, output: `applied patch:\n${applied.join("\n")}` };
115
+ },
116
+ };
117
+ /** Validate one operation against the filesystem and compute its final bytes. */
118
+ async function planOp(i, op, abs, ctx) {
119
+ const rel = path.relative(ctx.cwd, abs);
120
+ if (op.type === "create") {
121
+ if (await exists(abs)) {
122
+ return { ok: false, error: opError(i, op, "file already exists") };
123
+ }
124
+ return {
125
+ ok: true,
126
+ planned: { op: "create", abs, rel, content: op.content },
127
+ };
128
+ }
129
+ if (op.type === "delete") {
130
+ if (!(await exists(abs))) {
131
+ return { ok: false, error: opError(i, op, "file not found") };
132
+ }
133
+ return { ok: true, planned: { op: "delete", abs, rel } };
134
+ }
135
+ // update: read, then apply each hunk in order against the running content.
136
+ let content;
137
+ try {
138
+ content = await fs.readFile(abs, "utf8");
139
+ }
140
+ catch (err) {
141
+ if (err.code === "ENOENT") {
142
+ return { ok: false, error: opError(i, op, "file not found") };
143
+ }
144
+ return { ok: false, error: opError(i, op, err.message) };
145
+ }
146
+ for (let h = 0; h < op.hunks.length; h++) {
147
+ const { oldStr, newStr } = op.hunks[h];
148
+ const matches = countOccurrences(content, oldStr);
149
+ if (matches === 0) {
150
+ return {
151
+ ok: false,
152
+ error: opError(i, op, `hunk ${h + 1}: oldStr not found`),
153
+ };
154
+ }
155
+ if (matches > 1) {
156
+ return {
157
+ ok: false,
158
+ error: opError(i, op, `hunk ${h + 1}: oldStr not unique (${matches} matches)`),
159
+ };
160
+ }
161
+ // Replace by index so `$` patterns in newStr aren't interpreted.
162
+ const idx = content.indexOf(oldStr);
163
+ content =
164
+ content.slice(0, idx) + newStr + content.slice(idx + oldStr.length);
165
+ }
166
+ return {
167
+ ok: true,
168
+ planned: { op: "update", abs, rel, content, hunks: op.hunks },
169
+ };
170
+ }
171
+ /** Shape a planned op into its approval-preview form. */
172
+ function toPreview(p) {
173
+ if (p.op === "delete")
174
+ return { op: "delete", path: p.rel };
175
+ if (p.op === "create") {
176
+ const allLines = p.content.split("\n");
177
+ return {
178
+ op: "create",
179
+ path: p.rel,
180
+ lines: allLines.slice(0, PREVIEW_LINES),
181
+ omittedLines: Math.max(0, allLines.length - PREVIEW_LINES),
182
+ };
183
+ }
184
+ return { op: "update", path: p.rel, hunks: p.hunks };
185
+ }
186
+ /** Human-readable prefix for a failing operation. */
187
+ function opError(i, op, reason) {
188
+ return `operation ${i + 1} (${op.type} ${op.path}): ${reason}`;
189
+ }
190
+ async function exists(abs) {
191
+ return fs
192
+ .access(abs)
193
+ .then(() => true)
194
+ .catch(() => false);
195
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../types.js";
3
+ /** Count non-overlapping exact occurrences of `needle` in `haystack`. */
4
+ export declare function countOccurrences(haystack: string, needle: string): number;
5
+ /**
6
+ * Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
7
+ * The uniqueness requirement is checked before approval so the model can fix an
8
+ * ambiguous match without burning a prompt; gated on `ctx.approve` before writing.
9
+ */
10
+ export declare const editFileTool: Tool<z.ZodObject<{
11
+ path: z.ZodString;
12
+ old_str: z.ZodString;
13
+ new_str: z.ZodString;
14
+ }>>;
@@ -0,0 +1,81 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { z } from "zod";
3
+ import { resolveInRoot } from "./paths.js";
4
+ /** Count non-overlapping exact occurrences of `needle` in `haystack`. */
5
+ export function countOccurrences(haystack, needle) {
6
+ let count = 0;
7
+ let i = haystack.indexOf(needle);
8
+ while (i !== -1) {
9
+ count++;
10
+ i = haystack.indexOf(needle, i + needle.length);
11
+ }
12
+ return count;
13
+ }
14
+ /**
15
+ * Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
16
+ * The uniqueness requirement is checked before approval so the model can fix an
17
+ * ambiguous match without burning a prompt; gated on `ctx.approve` before writing.
18
+ */
19
+ export const editFileTool = {
20
+ name: "edit_file",
21
+ description: "Replace an exact, unique string in a file. old_str must match exactly once — include surrounding lines to make it unique. Read the file first.",
22
+ parameters: z.object({
23
+ path: z
24
+ .string()
25
+ .describe("Path to the file, relative to the project root."),
26
+ old_str: z
27
+ .string()
28
+ .min(1)
29
+ .describe("Exact text to replace; must occur exactly once in the file."),
30
+ new_str: z.string().describe("Replacement text."),
31
+ }),
32
+ async execute(input, ctx) {
33
+ let abs;
34
+ try {
35
+ abs = await resolveInRoot(ctx, input.path);
36
+ }
37
+ catch (err) {
38
+ return { ok: false, error: err.message };
39
+ }
40
+ let content;
41
+ try {
42
+ content = await fs.readFile(abs, "utf8");
43
+ }
44
+ catch (err) {
45
+ if (err.code === "ENOENT") {
46
+ return { ok: false, error: `file not found: ${input.path}` };
47
+ }
48
+ return { ok: false, error: err.message };
49
+ }
50
+ const matches = countOccurrences(content, input.old_str);
51
+ if (matches === 0) {
52
+ return { ok: false, error: `old_str not found in ${input.path}` };
53
+ }
54
+ if (matches > 1) {
55
+ return {
56
+ ok: false,
57
+ error: `old_str not unique (${matches} matches); add surrounding context to disambiguate`,
58
+ };
59
+ }
60
+ const approved = await ctx.approve({
61
+ kind: "edit",
62
+ path: abs,
63
+ preview: { type: "edit", oldStr: input.old_str, newStr: input.new_str },
64
+ });
65
+ if (!approved) {
66
+ return { ok: false, error: `edit to ${input.path} denied` };
67
+ }
68
+ // Replace the single occurrence by index to avoid `$`-pattern interpretation.
69
+ const idx = content.indexOf(input.old_str);
70
+ const updated = content.slice(0, idx) +
71
+ input.new_str +
72
+ content.slice(idx + input.old_str.length);
73
+ try {
74
+ await fs.writeFile(abs, updated, "utf8");
75
+ return { ok: true, output: `edited ${input.path}` };
76
+ }
77
+ catch (err) {
78
+ return { ok: false, error: err.message };
79
+ }
80
+ },
81
+ };
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../types.js";
3
+ /**
4
+ * Find files by glob pattern within the project root. Read-only — no approval.
5
+ * The pattern is constrained to the root (no absolute or `..` patterns) so glob
6
+ * can't reach outside, consistent with the other file tools.
7
+ */
8
+ export declare const globTool: Tool<z.ZodObject<{
9
+ pattern: z.ZodString;
10
+ }>>;
@@ -0,0 +1,52 @@
1
+ import path from "node:path";
2
+ import { glob } from "tinyglobby";
3
+ import { z } from "zod";
4
+ /** Cap on returned paths — beyond this we truncate with a notice. */
5
+ const MAX_RESULTS = 200;
6
+ const DEFAULT_IGNORE = ["**/node_modules/**", "**/.git/**"];
7
+ /**
8
+ * Find files by glob pattern within the project root. Read-only — no approval.
9
+ * The pattern is constrained to the root (no absolute or `..` patterns) so glob
10
+ * can't reach outside, consistent with the other file tools.
11
+ */
12
+ export const globTool = {
13
+ name: "glob",
14
+ description: "Find files by glob pattern (e.g. 'src/**/*.ts') within the project, ignoring node_modules and .git. Returns paths relative to the project root.",
15
+ parameters: z.object({
16
+ pattern: z
17
+ .string()
18
+ .describe("Glob pattern, relative to the project root (e.g. 'src/**/*.ts')."),
19
+ }),
20
+ async execute(input, ctx) {
21
+ const pattern = input.pattern;
22
+ if (path.isAbsolute(pattern) || pattern.split(/[/\\]/).includes("..")) {
23
+ return {
24
+ ok: false,
25
+ error: "pattern must be relative to the project root (no '..' or absolute paths)",
26
+ };
27
+ }
28
+ try {
29
+ const matches = await glob(pattern, {
30
+ cwd: path.resolve(ctx.cwd),
31
+ ignore: DEFAULT_IGNORE,
32
+ onlyFiles: true,
33
+ dot: false,
34
+ });
35
+ matches.sort();
36
+ if (matches.length === 0) {
37
+ return { ok: true, output: "(no matches)" };
38
+ }
39
+ if (matches.length > MAX_RESULTS) {
40
+ const shown = matches.slice(0, MAX_RESULTS).join("\n");
41
+ return {
42
+ ok: true,
43
+ output: `${shown}\n\n[truncated: showing ${MAX_RESULTS} of ${matches.length} matches]`,
44
+ };
45
+ }
46
+ return { ok: true, output: matches.join("\n") };
47
+ }
48
+ catch (err) {
49
+ return { ok: false, error: err.message };
50
+ }
51
+ },
52
+ };
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../types.js";
3
+ declare const parameters: z.ZodObject<{
4
+ pattern: z.ZodString;
5
+ path: z.ZodOptional<z.ZodString>;
6
+ glob: z.ZodOptional<z.ZodString>;
7
+ ignoreCase: z.ZodOptional<z.ZodBoolean>;
8
+ maxResults: z.ZodOptional<z.ZodNumber>;
9
+ }, "strip", z.ZodTypeAny, {
10
+ pattern: string;
11
+ path?: string | undefined;
12
+ glob?: string | undefined;
13
+ ignoreCase?: boolean | undefined;
14
+ maxResults?: number | undefined;
15
+ }, {
16
+ pattern: string;
17
+ path?: string | undefined;
18
+ glob?: string | undefined;
19
+ ignoreCase?: boolean | undefined;
20
+ maxResults?: number | undefined;
21
+ }>;
22
+ /**
23
+ * Search file *contents* for a regex within the project root. Read-only — no
24
+ * approval — so the model should prefer this over shelling out to grep/rg via
25
+ * run_command (which is platform-dependent and routes through the approval gate).
26
+ *
27
+ * Files are enumerated with the same glob mechanism as the `glob` tool (so
28
+ * node_modules and .git are always ignored), binary files are skipped, and the
29
+ * search is bounded by the project-root path boundary shared by every file tool.
30
+ */
31
+ export declare const grepFilesTool: Tool<typeof parameters>;
32
+ export {};
@@ -0,0 +1,113 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { glob } from "tinyglobby";
4
+ import { z } from "zod";
5
+ import { resolveInRoot } from "./paths.js";
6
+ /** Default cap on returned match lines; beyond it we report the overflow. */
7
+ const DEFAULT_MAX_RESULTS = 100;
8
+ /** Trim point for a single matched line so long lines don't flood the output. */
9
+ const MAX_LINE_LENGTH = 200;
10
+ /** Bytes sniffed for a NUL to decide a file is binary and skip it. */
11
+ const BINARY_SNIFF_BYTES = 8 * 1024;
12
+ const DEFAULT_IGNORE = ["**/node_modules/**", "**/.git/**"];
13
+ const parameters = z.object({
14
+ pattern: z
15
+ .string()
16
+ .describe("A JavaScript regular expression to test each line against."),
17
+ path: z
18
+ .string()
19
+ .optional()
20
+ .describe("Optional sub-directory (relative to the project root) to scope the search to. Defaults to the project root."),
21
+ glob: z
22
+ .string()
23
+ .optional()
24
+ .describe("Optional glob to restrict which files are searched (e.g. '*.ts' or 'src/**/*.ts'). Defaults to all files."),
25
+ ignoreCase: z
26
+ .boolean()
27
+ .optional()
28
+ .describe("Match case-insensitively when true."),
29
+ maxResults: z
30
+ .number()
31
+ .int()
32
+ .positive()
33
+ .optional()
34
+ .describe(`Maximum number of matching lines to return (default ${DEFAULT_MAX_RESULTS}).`),
35
+ });
36
+ /**
37
+ * Search file *contents* for a regex within the project root. Read-only — no
38
+ * approval — so the model should prefer this over shelling out to grep/rg via
39
+ * run_command (which is platform-dependent and routes through the approval gate).
40
+ *
41
+ * Files are enumerated with the same glob mechanism as the `glob` tool (so
42
+ * node_modules and .git are always ignored), binary files are skipped, and the
43
+ * search is bounded by the project-root path boundary shared by every file tool.
44
+ */
45
+ export const grepFilesTool = {
46
+ name: "grep_files",
47
+ description: "Search file CONTENTS for a regular expression within the project (vs `glob`, which matches file NAMES). Returns matches as 'path:line: text', ignoring node_modules and .git. Read-only and requires no approval — prefer it over shelling out to grep/rg/find via run_command.",
48
+ parameters,
49
+ async execute(input, ctx) {
50
+ // Compile the regex up front; an invalid pattern is a clean failure, not a throw.
51
+ let regex;
52
+ try {
53
+ regex = new RegExp(input.pattern, input.ignoreCase ? "i" : "");
54
+ }
55
+ catch (err) {
56
+ return { ok: false, error: `invalid regex: ${err.message}` };
57
+ }
58
+ const root = path.resolve(ctx.cwd);
59
+ let scope;
60
+ try {
61
+ scope = input.path ? await resolveInRoot(ctx, input.path) : root;
62
+ }
63
+ catch (err) {
64
+ return { ok: false, error: err.message };
65
+ }
66
+ const maxResults = input.maxResults ?? DEFAULT_MAX_RESULTS;
67
+ try {
68
+ const files = await glob(input.glob ?? "**/*", {
69
+ cwd: scope,
70
+ ignore: DEFAULT_IGNORE,
71
+ onlyFiles: true,
72
+ dot: false,
73
+ });
74
+ files.sort();
75
+ const lines = [];
76
+ let total = 0; // total matches found, including those past the cap
77
+ for (const rel of files) {
78
+ const abs = path.join(scope, rel);
79
+ const buf = await fs.readFile(abs);
80
+ if (buf.subarray(0, BINARY_SNIFF_BYTES).includes(0))
81
+ continue; // binary
82
+ const display = path.relative(root, abs);
83
+ const fileLines = buf.toString("utf8").split("\n");
84
+ for (let i = 0; i < fileLines.length; i++) {
85
+ if (!regex.test(fileLines[i]))
86
+ continue;
87
+ total++;
88
+ if (lines.length < maxResults) {
89
+ let text = fileLines[i].trim();
90
+ if (text.length > MAX_LINE_LENGTH) {
91
+ text = `${text.slice(0, MAX_LINE_LENGTH)}…`;
92
+ }
93
+ lines.push(`${display}:${i + 1}: ${text}`);
94
+ }
95
+ }
96
+ }
97
+ if (total === 0) {
98
+ return { ok: true, output: "(no matches)" };
99
+ }
100
+ const omitted = total - lines.length;
101
+ const body = lines.join("\n");
102
+ return {
103
+ ok: true,
104
+ output: omitted > 0
105
+ ? `${body}\n\n[${omitted} more match${omitted === 1 ? "" : "es"} omitted]`
106
+ : body,
107
+ };
108
+ }
109
+ catch (err) {
110
+ return { ok: false, error: err.message };
111
+ }
112
+ },
113
+ };
@@ -0,0 +1,7 @@
1
+ export * from "./paths.js";
2
+ export * from "./read-file.js";
3
+ export * from "./write-file.js";
4
+ export * from "./edit-file.js";
5
+ export * from "./apply-patch.js";
6
+ export * from "./glob.js";
7
+ export * from "./grep-files.js";
@@ -0,0 +1,7 @@
1
+ export * from "./paths.js";
2
+ export * from "./read-file.js";
3
+ export * from "./write-file.js";
4
+ export * from "./edit-file.js";
5
+ export * from "./apply-patch.js";
6
+ export * from "./glob.js";
7
+ export * from "./grep-files.js";
@@ -0,0 +1,24 @@
1
+ import type { ToolContext } from "../types.js";
2
+ /**
3
+ * Thrown when a tool argument resolves to a path outside the project root —
4
+ * whether via `../` traversal, an absolute path, or a symlink pointing outward.
5
+ * Tools catch this and surface it as `{ ok:false }` rather than letting it throw.
6
+ */
7
+ export declare class PathEscapeError extends Error {
8
+ constructor(message: string);
9
+ }
10
+ /**
11
+ * Resolve a tool-supplied path against the project root (`ctx.cwd`) and prove it
12
+ * stays inside — the single security boundary every file tool funnels through.
13
+ *
14
+ * Two layers: (1) a lexical check that the resolved absolute path is within root
15
+ * (rejects `../` and absolute-outside before touching the FS); (2) a symlink
16
+ * check that the real target — or, for a new path, its nearest existing parent —
17
+ * resolves inside the *real* root. The root is realpath'd too, so this is correct
18
+ * even when the root itself sits under a symlink (e.g. macOS `/var → /private/var`).
19
+ *
20
+ * @returns the resolved absolute path (lexical, not realpath'd — so callers
21
+ * operate on the intended location).
22
+ * @throws {PathEscapeError} if the path escapes the root.
23
+ */
24
+ export declare function resolveInRoot(ctx: ToolContext, p: string): Promise<string>;
@@ -0,0 +1,65 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Thrown when a tool argument resolves to a path outside the project root —
5
+ * whether via `../` traversal, an absolute path, or a symlink pointing outward.
6
+ * Tools catch this and surface it as `{ ok:false }` rather than letting it throw.
7
+ */
8
+ export class PathEscapeError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "PathEscapeError";
12
+ }
13
+ }
14
+ /** Is `target` the root itself or a descendant of it? */
15
+ function isInside(root, target) {
16
+ return target === root || target.startsWith(root + path.sep);
17
+ }
18
+ /**
19
+ * realpath `p`, or — if it doesn't exist yet — the realpath of its nearest
20
+ * existing ancestor directory. Lets us validate a not-yet-created path by the
21
+ * directory it would be created in (catching outward symlinked parents).
22
+ */
23
+ async function realpathOfNearestExisting(p) {
24
+ let cur = p;
25
+ for (;;) {
26
+ try {
27
+ return await fs.realpath(cur);
28
+ }
29
+ catch (err) {
30
+ if (err.code !== "ENOENT")
31
+ throw err;
32
+ const parent = path.dirname(cur);
33
+ if (parent === cur)
34
+ return cur; // reached the filesystem root
35
+ cur = parent;
36
+ }
37
+ }
38
+ }
39
+ /**
40
+ * Resolve a tool-supplied path against the project root (`ctx.cwd`) and prove it
41
+ * stays inside — the single security boundary every file tool funnels through.
42
+ *
43
+ * Two layers: (1) a lexical check that the resolved absolute path is within root
44
+ * (rejects `../` and absolute-outside before touching the FS); (2) a symlink
45
+ * check that the real target — or, for a new path, its nearest existing parent —
46
+ * resolves inside the *real* root. The root is realpath'd too, so this is correct
47
+ * even when the root itself sits under a symlink (e.g. macOS `/var → /private/var`).
48
+ *
49
+ * @returns the resolved absolute path (lexical, not realpath'd — so callers
50
+ * operate on the intended location).
51
+ * @throws {PathEscapeError} if the path escapes the root.
52
+ */
53
+ export async function resolveInRoot(ctx, p) {
54
+ const root = path.resolve(ctx.cwd);
55
+ const resolved = path.resolve(root, p);
56
+ if (!isInside(root, resolved)) {
57
+ throw new PathEscapeError(`path "${p}" resolves outside the project root`);
58
+ }
59
+ const realRoot = await fs.realpath(root);
60
+ const realTarget = await realpathOfNearestExisting(resolved);
61
+ if (!isInside(realRoot, realTarget)) {
62
+ throw new PathEscapeError(`path "${p}" resolves outside the project root (via a symlink)`);
63
+ }
64
+ return resolved;
65
+ }
@@ -0,0 +1,8 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../types.js";
3
+ /**
4
+ * Read a UTF-8 text file from within the project root. Read-only — no approval.
5
+ */
6
+ export declare const readFileTool: Tool<z.ZodObject<{
7
+ path: z.ZodString;
8
+ }>>;
@@ -0,0 +1,52 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { z } from "zod";
3
+ import { resolveInRoot } from "./paths.js";
4
+ /** Files larger than this are truncated rather than dumped in full. */
5
+ const MAX_BYTES = 256 * 1024;
6
+ /**
7
+ * Read a UTF-8 text file from within the project root. Read-only — no approval.
8
+ */
9
+ export const readFileTool = {
10
+ name: "read_file",
11
+ description: "Read a UTF-8 text file within the project and return its contents. Read a file before editing it.",
12
+ parameters: z.object({
13
+ path: z
14
+ .string()
15
+ .describe("Path to the file, relative to the project root."),
16
+ }),
17
+ async execute(input, ctx) {
18
+ try {
19
+ const abs = await resolveInRoot(ctx, input.path);
20
+ const stat = await fs.stat(abs);
21
+ if (stat.isDirectory()) {
22
+ return {
23
+ ok: false,
24
+ error: `"${input.path}" is a directory, not a file`,
25
+ };
26
+ }
27
+ if (stat.size > MAX_BYTES) {
28
+ const fh = await fs.open(abs, "r");
29
+ try {
30
+ const buf = Buffer.alloc(MAX_BYTES);
31
+ const { bytesRead } = await fh.read(buf, 0, MAX_BYTES, 0);
32
+ const head = buf.subarray(0, bytesRead).toString("utf8");
33
+ return {
34
+ ok: true,
35
+ output: `${head}\n\n[truncated: first ${MAX_BYTES} bytes of ${stat.size}]`,
36
+ };
37
+ }
38
+ finally {
39
+ await fh.close();
40
+ }
41
+ }
42
+ const content = await fs.readFile(abs, "utf8");
43
+ return { ok: true, output: content };
44
+ }
45
+ catch (err) {
46
+ if (err.code === "ENOENT") {
47
+ return { ok: false, error: `file not found: ${input.path}` };
48
+ }
49
+ return { ok: false, error: err.message };
50
+ }
51
+ },
52
+ };