@jc20231028/local-code-agent 0.1.0 → 0.1.3

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/workspace.js CHANGED
@@ -1,224 +1,235 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { spawn } from "node:child_process";
4
-
5
- const DEFAULT_IGNORES = new Set([
6
- ".git",
7
- "node_modules",
8
- ".DS_Store"
9
- ]);
10
-
11
- export class Workspace {
12
- constructor(rootPath, options = {}) {
13
- this.rootPath = path.resolve(rootPath);
14
- this.allowCommands = Boolean(options.allowCommands);
15
- }
16
-
17
- resolvePath(targetPath = ".") {
18
- const normalizedTarget = targetPath === "" ? "." : targetPath;
19
- const fullPath = path.resolve(this.rootPath, normalizedTarget);
20
- const relative = path.relative(this.rootPath, fullPath);
21
-
22
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
23
- throw new Error(`Path escapes workspace: ${targetPath}`);
24
- }
25
-
26
- return fullPath;
27
- }
28
-
29
- async listFiles(targetPath = ".", limit = 200) {
30
- const startPath = this.resolvePath(targetPath);
31
- const results = [];
32
- await walk(startPath, this.rootPath, results, limit);
33
- return results;
34
- }
35
-
36
- async listRecentFiles(limit = 5) {
37
- const results = [];
38
- await walkRecent(this.rootPath, this.rootPath, results);
39
-
40
- return results
41
- .sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt))
42
- .slice(0, limit);
43
- }
44
-
45
- async readFile(targetPath) {
46
- const fullPath = this.resolvePath(targetPath);
47
- return fs.readFile(fullPath, "utf8");
48
- }
49
-
50
- async writeFile(targetPath, content) {
51
- const fullPath = this.resolvePath(targetPath);
52
- await fs.mkdir(path.dirname(fullPath), { recursive: true });
53
- await fs.writeFile(fullPath, content, "utf8");
54
- return `Wrote ${path.relative(this.rootPath, fullPath)}`;
55
- }
56
-
57
- async appendFile(targetPath, content) {
58
- const fullPath = this.resolvePath(targetPath);
59
- await fs.mkdir(path.dirname(fullPath), { recursive: true });
60
- await fs.appendFile(fullPath, content, "utf8");
61
- return `Appended to ${path.relative(this.rootPath, fullPath)}`;
62
- }
63
-
64
- async replaceInFile(targetPath, findText, replaceText, replaceAll = false) {
65
- const fullPath = this.resolvePath(targetPath);
66
- const current = await fs.readFile(fullPath, "utf8");
67
- if (!current.includes(findText)) {
68
- throw new Error(`Text not found in ${targetPath}`);
69
- }
70
-
71
- const updated = replaceAll
72
- ? current.split(findText).join(replaceText)
73
- : current.replace(findText, replaceText);
74
- await fs.writeFile(fullPath, updated, "utf8");
75
- return `Updated ${targetPath}`;
76
- }
77
-
78
- async searchText(query, targetPath = ".", limit = 50) {
79
- const files = await this.listFiles(targetPath, 500);
80
- const matches = [];
81
- for (const relativePath of files) {
82
- if (matches.length >= limit) {
83
- break;
84
- }
85
-
86
- const content = await this.readFile(relativePath);
87
- const lines = content.split(/\r?\n/);
88
- for (let index = 0; index < lines.length; index += 1) {
89
- if (lines[index].includes(query)) {
90
- matches.push({
91
- path: relativePath,
92
- line: index + 1,
93
- text: lines[index].trim()
94
- });
95
- if (matches.length >= limit) {
96
- break;
97
- }
98
- }
99
- }
100
- }
101
-
102
- return matches;
103
- }
104
-
105
- async makeDirectory(targetPath) {
106
- const fullPath = this.resolvePath(targetPath);
107
- await fs.mkdir(fullPath, { recursive: true });
108
- return `Created ${path.relative(this.rootPath, fullPath)}`;
109
- }
110
-
111
- async runCommand(command, args = [], { approved = false } = {}) {
112
- if (!this.allowCommands && !approved) {
113
- throw new Error("Command execution was not approved. Re-run with --allow-commands to skip the prompt, or approve it when asked.");
114
- }
115
-
116
- return new Promise((resolve, reject) => {
117
- const child = spawn(command, args, {
118
- cwd: this.rootPath,
119
- shell: process.platform === "win32",
120
- env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }
121
- });
122
-
123
- let stdout = "";
124
- let stderr = "";
125
-
126
- child.stdout.on("data", (chunk) => {
127
- stdout += chunk.toString();
128
- });
129
-
130
- child.stderr.on("data", (chunk) => {
131
- stderr += chunk.toString();
132
- });
133
-
134
- child.on("error", (error) => {
135
- reject(error);
136
- });
137
-
138
- child.on("close", (code) => {
139
- resolve({
140
- code,
141
- stdout: stdout.trimEnd(),
142
- stderr: stderr.trimEnd()
143
- });
144
- });
145
- });
146
- }
147
- }
148
-
149
- const MAX_SCANNED_ENTRIES = 5000;
150
-
151
- async function walk(currentPath, rootPath, results, limit) {
152
- if (results.length >= limit) {
153
- return;
154
- }
155
-
156
- let entries;
157
- try {
158
- entries = await fs.readdir(currentPath, { withFileTypes: true });
159
- } catch {
160
- return;
161
- }
162
-
163
- for (const entry of entries) {
164
- if (results.length >= limit) {
165
- return;
166
- }
167
-
168
- if (DEFAULT_IGNORES.has(entry.name)) {
169
- continue;
170
- }
171
-
172
- const fullPath = path.join(currentPath, entry.name);
173
- const relativePath = path.relative(rootPath, fullPath).replace(/\\/g, "/");
174
-
175
- if (entry.isDirectory()) {
176
- await walk(fullPath, rootPath, results, limit);
177
- continue;
178
- }
179
-
180
- results.push(relativePath);
181
- }
182
- }
183
-
184
- async function walkRecent(currentPath, rootPath, results, scanned = { count: 0 }) {
185
- if (scanned.count >= MAX_SCANNED_ENTRIES) {
186
- return;
187
- }
188
-
189
- let entries;
190
- try {
191
- entries = await fs.readdir(currentPath, { withFileTypes: true });
192
- } catch {
193
- return;
194
- }
195
-
196
- for (const entry of entries) {
197
- if (scanned.count >= MAX_SCANNED_ENTRIES) {
198
- return;
199
- }
200
-
201
- if (DEFAULT_IGNORES.has(entry.name)) {
202
- continue;
203
- }
204
-
205
- scanned.count += 1;
206
- const fullPath = path.join(currentPath, entry.name);
207
- const relativePath = path.relative(rootPath, fullPath).replace(/\\/g, "/");
208
-
209
- if (entry.isDirectory()) {
210
- await walkRecent(fullPath, rootPath, results, scanned);
211
- continue;
212
- }
213
-
214
- try {
215
- const stat = await fs.stat(fullPath);
216
- results.push({
217
- path: relativePath,
218
- modifiedAt: stat.mtime.toISOString()
219
- });
220
- } catch {
221
- // Skip files we can't stat (permissions, broken symlinks, etc).
222
- }
223
- }
224
- }
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+
5
+ const DEFAULT_IGNORES = new Set([
6
+ ".git",
7
+ "node_modules",
8
+ ".DS_Store"
9
+ ]);
10
+
11
+ export class Workspace {
12
+ constructor(rootPath, options = {}) {
13
+ this.rootPath = path.resolve(rootPath);
14
+ this.allowCommands = Boolean(options.allowCommands);
15
+ this.allowWrites = Boolean(options.allowWrites);
16
+ }
17
+
18
+ assertWriteApproved(approved) {
19
+ if (!this.allowWrites && !approved) {
20
+ throw new Error("File change was not approved. Re-run with --allow-writes to skip the prompt, or approve it when asked.");
21
+ }
22
+ }
23
+
24
+ resolvePath(targetPath = ".") {
25
+ const normalizedTarget = targetPath === "" ? "." : targetPath;
26
+ const fullPath = path.resolve(this.rootPath, normalizedTarget);
27
+ const relative = path.relative(this.rootPath, fullPath);
28
+
29
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
30
+ throw new Error(`Path escapes workspace: ${targetPath}`);
31
+ }
32
+
33
+ return fullPath;
34
+ }
35
+
36
+ async listFiles(targetPath = ".", limit = 200) {
37
+ const startPath = this.resolvePath(targetPath);
38
+ const results = [];
39
+ await walk(startPath, this.rootPath, results, limit);
40
+ return results;
41
+ }
42
+
43
+ async listRecentFiles(limit = 5) {
44
+ const results = [];
45
+ await walkRecent(this.rootPath, this.rootPath, results);
46
+
47
+ return results
48
+ .sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt))
49
+ .slice(0, limit);
50
+ }
51
+
52
+ async readFile(targetPath) {
53
+ const fullPath = this.resolvePath(targetPath);
54
+ return fs.readFile(fullPath, "utf8");
55
+ }
56
+
57
+ async writeFile(targetPath, content, { approved = false } = {}) {
58
+ this.assertWriteApproved(approved);
59
+ const fullPath = this.resolvePath(targetPath);
60
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
61
+ await fs.writeFile(fullPath, content, "utf8");
62
+ return `Wrote ${path.relative(this.rootPath, fullPath)}`;
63
+ }
64
+
65
+ async appendFile(targetPath, content, { approved = false } = {}) {
66
+ this.assertWriteApproved(approved);
67
+ const fullPath = this.resolvePath(targetPath);
68
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
69
+ await fs.appendFile(fullPath, content, "utf8");
70
+ return `Appended to ${path.relative(this.rootPath, fullPath)}`;
71
+ }
72
+
73
+ async replaceInFile(targetPath, findText, replaceText, replaceAll = false, { approved = false } = {}) {
74
+ this.assertWriteApproved(approved);
75
+ const fullPath = this.resolvePath(targetPath);
76
+ const current = await fs.readFile(fullPath, "utf8");
77
+ if (!current.includes(findText)) {
78
+ throw new Error(`Text not found in ${targetPath}`);
79
+ }
80
+
81
+ const updated = replaceAll
82
+ ? current.split(findText).join(replaceText)
83
+ : current.replace(findText, replaceText);
84
+ await fs.writeFile(fullPath, updated, "utf8");
85
+ return `Updated ${targetPath}`;
86
+ }
87
+
88
+ async searchText(query, targetPath = ".", limit = 50) {
89
+ const files = await this.listFiles(targetPath, 500);
90
+ const matches = [];
91
+ for (const relativePath of files) {
92
+ if (matches.length >= limit) {
93
+ break;
94
+ }
95
+
96
+ const content = await this.readFile(relativePath);
97
+ const lines = content.split(/\r?\n/);
98
+ for (let index = 0; index < lines.length; index += 1) {
99
+ if (lines[index].includes(query)) {
100
+ matches.push({
101
+ path: relativePath,
102
+ line: index + 1,
103
+ text: lines[index].trim()
104
+ });
105
+ if (matches.length >= limit) {
106
+ break;
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ return matches;
113
+ }
114
+
115
+ async makeDirectory(targetPath, { approved = false } = {}) {
116
+ this.assertWriteApproved(approved);
117
+ const fullPath = this.resolvePath(targetPath);
118
+ await fs.mkdir(fullPath, { recursive: true });
119
+ return `Created ${path.relative(this.rootPath, fullPath)}`;
120
+ }
121
+
122
+ async runCommand(command, args = [], { approved = false } = {}) {
123
+ if (!this.allowCommands && !approved) {
124
+ throw new Error("Command execution was not approved. Re-run with --allow-commands to skip the prompt, or approve it when asked.");
125
+ }
126
+
127
+ return new Promise((resolve, reject) => {
128
+ const child = spawn(command, args, {
129
+ cwd: this.rootPath,
130
+ shell: process.platform === "win32",
131
+ env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }
132
+ });
133
+
134
+ let stdout = "";
135
+ let stderr = "";
136
+
137
+ child.stdout.on("data", (chunk) => {
138
+ stdout += chunk.toString();
139
+ });
140
+
141
+ child.stderr.on("data", (chunk) => {
142
+ stderr += chunk.toString();
143
+ });
144
+
145
+ child.on("error", (error) => {
146
+ reject(error);
147
+ });
148
+
149
+ child.on("close", (code) => {
150
+ resolve({
151
+ code,
152
+ stdout: stdout.trimEnd(),
153
+ stderr: stderr.trimEnd()
154
+ });
155
+ });
156
+ });
157
+ }
158
+ }
159
+
160
+ const MAX_SCANNED_ENTRIES = 5000;
161
+
162
+ async function walk(currentPath, rootPath, results, limit) {
163
+ if (results.length >= limit) {
164
+ return;
165
+ }
166
+
167
+ let entries;
168
+ try {
169
+ entries = await fs.readdir(currentPath, { withFileTypes: true });
170
+ } catch {
171
+ return;
172
+ }
173
+
174
+ for (const entry of entries) {
175
+ if (results.length >= limit) {
176
+ return;
177
+ }
178
+
179
+ if (DEFAULT_IGNORES.has(entry.name)) {
180
+ continue;
181
+ }
182
+
183
+ const fullPath = path.join(currentPath, entry.name);
184
+ const relativePath = path.relative(rootPath, fullPath).replace(/\\/g, "/");
185
+
186
+ if (entry.isDirectory()) {
187
+ await walk(fullPath, rootPath, results, limit);
188
+ continue;
189
+ }
190
+
191
+ results.push(relativePath);
192
+ }
193
+ }
194
+
195
+ async function walkRecent(currentPath, rootPath, results, scanned = { count: 0 }) {
196
+ if (scanned.count >= MAX_SCANNED_ENTRIES) {
197
+ return;
198
+ }
199
+
200
+ let entries;
201
+ try {
202
+ entries = await fs.readdir(currentPath, { withFileTypes: true });
203
+ } catch {
204
+ return;
205
+ }
206
+
207
+ for (const entry of entries) {
208
+ if (scanned.count >= MAX_SCANNED_ENTRIES) {
209
+ return;
210
+ }
211
+
212
+ if (DEFAULT_IGNORES.has(entry.name)) {
213
+ continue;
214
+ }
215
+
216
+ scanned.count += 1;
217
+ const fullPath = path.join(currentPath, entry.name);
218
+ const relativePath = path.relative(rootPath, fullPath).replace(/\\/g, "/");
219
+
220
+ if (entry.isDirectory()) {
221
+ await walkRecent(fullPath, rootPath, results, scanned);
222
+ continue;
223
+ }
224
+
225
+ try {
226
+ const stat = await fs.stat(fullPath);
227
+ results.push({
228
+ path: relativePath,
229
+ modifiedAt: stat.mtime.toISOString()
230
+ });
231
+ } catch {
232
+ // Skip files we can't stat (permissions, broken symlinks, etc).
233
+ }
234
+ }
235
+ }