aiforcecli-local 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.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/package.json +40 -0
- package/src/agent-tools.js +115 -0
- package/src/cli.js +290 -0
- package/src/coding-agent.js +266 -0
- package/src/command-runner.js +90 -0
- package/src/models.js +35 -0
- package/src/ollama-client.js +92 -0
- package/src/session-store.js +68 -0
- package/src/workspace.js +297 -0
package/src/workspace.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_IGNORES = new Set([
|
|
5
|
+
'.git',
|
|
6
|
+
'.aiforce-local',
|
|
7
|
+
'node_modules',
|
|
8
|
+
'dist',
|
|
9
|
+
'build',
|
|
10
|
+
'coverage',
|
|
11
|
+
'.next',
|
|
12
|
+
'.venv',
|
|
13
|
+
'venv',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const MAX_FILE_BYTES = 1_000_000;
|
|
17
|
+
const MAX_SEARCH_FILES = 2_000;
|
|
18
|
+
const MAX_SEARCH_MATCHES = 200;
|
|
19
|
+
|
|
20
|
+
export class WorkspaceError extends Error {
|
|
21
|
+
constructor(message, options = {}) {
|
|
22
|
+
super(message, options);
|
|
23
|
+
this.name = 'WorkspaceError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class Workspace {
|
|
28
|
+
static async open(root) {
|
|
29
|
+
const resolved = path.resolve(root);
|
|
30
|
+
const realRoot = await fs.realpath(resolved).catch(() => null);
|
|
31
|
+
if (!realRoot) throw new WorkspaceError(`Repository directory does not exist: ${resolved}`);
|
|
32
|
+
const stat = await fs.stat(realRoot);
|
|
33
|
+
if (!stat.isDirectory()) throw new WorkspaceError(`Repository path is not a directory: ${realRoot}`);
|
|
34
|
+
return new Workspace(realRoot);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
constructor(root) {
|
|
38
|
+
this.root = root;
|
|
39
|
+
this.changes = new Map();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async listFiles(relativePath = '.', { maxDepth = 6, maxFiles = 500 } = {}) {
|
|
43
|
+
const start = await this.#resolve(relativePath, { mustExist: true });
|
|
44
|
+
const stat = await fs.stat(start.absolute);
|
|
45
|
+
if (!stat.isDirectory()) throw new WorkspaceError(`Not a directory: ${start.relative}`);
|
|
46
|
+
|
|
47
|
+
const files = [];
|
|
48
|
+
const walk = async (directory, depth) => {
|
|
49
|
+
if (depth > maxDepth || files.length >= maxFiles) return;
|
|
50
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
51
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (files.length >= maxFiles || DEFAULT_IGNORES.has(entry.name)) continue;
|
|
54
|
+
if (entry.isFile() && isSensitiveFile(entry.name)) continue;
|
|
55
|
+
const absolute = path.join(directory, entry.name);
|
|
56
|
+
if (entry.isSymbolicLink()) continue;
|
|
57
|
+
if (entry.isDirectory()) await walk(absolute, depth + 1);
|
|
58
|
+
else if (entry.isFile()) files.push(this.#displayPath(absolute));
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
await walk(start.absolute, 0);
|
|
62
|
+
return { files, truncated: files.length >= maxFiles };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async readFile(relativePath, { startLine = 1, endLine = 400 } = {}) {
|
|
66
|
+
if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1 || endLine < startLine) {
|
|
67
|
+
throw new WorkspaceError('Line range must use positive integers with end_line >= start_line.');
|
|
68
|
+
}
|
|
69
|
+
if (endLine - startLine > 1_000) throw new WorkspaceError('A single read is limited to 1,000 lines.');
|
|
70
|
+
const target = await this.#resolve(relativePath, { mustExist: true });
|
|
71
|
+
const stat = await fs.stat(target.absolute);
|
|
72
|
+
if (!stat.isFile()) throw new WorkspaceError(`Not a file: ${target.relative}`);
|
|
73
|
+
if (stat.size > MAX_FILE_BYTES) throw new WorkspaceError(`File exceeds ${MAX_FILE_BYTES} byte read limit: ${target.relative}`);
|
|
74
|
+
const content = await fs.readFile(target.absolute, 'utf8');
|
|
75
|
+
const lines = content.split(/\r?\n/);
|
|
76
|
+
return {
|
|
77
|
+
path: target.relative,
|
|
78
|
+
startLine,
|
|
79
|
+
endLine: Math.min(endLine, lines.length),
|
|
80
|
+
totalLines: lines.length,
|
|
81
|
+
content: lines.slice(startLine - 1, endLine).join('\n'),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async searchText(query, relativePath = '.') {
|
|
86
|
+
if (typeof query !== 'string' || !query.trim()) throw new WorkspaceError('Search query is required.');
|
|
87
|
+
if (query.length > 500) throw new WorkspaceError('Search query is limited to 500 characters.');
|
|
88
|
+
const { files, truncated: fileLimitReached } = await this.listFiles(relativePath, {
|
|
89
|
+
maxDepth: 20,
|
|
90
|
+
maxFiles: MAX_SEARCH_FILES,
|
|
91
|
+
});
|
|
92
|
+
const matches = [];
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
if (matches.length >= MAX_SEARCH_MATCHES) break;
|
|
95
|
+
const absolute = path.join(this.root, file);
|
|
96
|
+
const stat = await fs.stat(absolute).catch(() => null);
|
|
97
|
+
if (!stat?.isFile() || stat.size > MAX_FILE_BYTES) continue;
|
|
98
|
+
let content;
|
|
99
|
+
try {
|
|
100
|
+
content = await fs.readFile(absolute, 'utf8');
|
|
101
|
+
} catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (content.includes('\0')) continue;
|
|
105
|
+
for (const [index, line] of content.split(/\r?\n/).entries()) {
|
|
106
|
+
if (line.toLowerCase().includes(query.toLowerCase())) {
|
|
107
|
+
matches.push({ path: file, line: index + 1, text: line.slice(0, 500) });
|
|
108
|
+
if (matches.length >= MAX_SEARCH_MATCHES) break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
matches,
|
|
114
|
+
truncated: fileLimitReached || matches.length >= MAX_SEARCH_MATCHES,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async createFile(relativePath, content) {
|
|
119
|
+
this.#validateContent(content);
|
|
120
|
+
const target = await this.#resolve(relativePath, { mustExist: false, forWrite: true });
|
|
121
|
+
if (await exists(target.absolute)) throw new WorkspaceError(`File already exists: ${target.relative}`);
|
|
122
|
+
await fs.mkdir(path.dirname(target.absolute), { recursive: true });
|
|
123
|
+
await fs.writeFile(target.absolute, content, { encoding: 'utf8', flag: 'wx' });
|
|
124
|
+
this.#track(target.relative, null, content);
|
|
125
|
+
return { path: target.relative, created: true, bytes: Buffer.byteLength(content) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async replaceText(relativePath, oldText, newText, { replaceAll = false } = {}) {
|
|
129
|
+
if (typeof oldText !== 'string' || !oldText) throw new WorkspaceError('old_text must be a non-empty string.');
|
|
130
|
+
this.#validateContent(newText);
|
|
131
|
+
const target = await this.#resolve(relativePath, { mustExist: true, forWrite: true });
|
|
132
|
+
const stat = await fs.stat(target.absolute);
|
|
133
|
+
if (!stat.isFile()) throw new WorkspaceError(`Not a file: ${target.relative}`);
|
|
134
|
+
if (stat.size > MAX_FILE_BYTES) throw new WorkspaceError(`File exceeds ${MAX_FILE_BYTES} byte edit limit: ${target.relative}`);
|
|
135
|
+
const content = await fs.readFile(target.absolute, 'utf8');
|
|
136
|
+
const occurrences = countOccurrences(content, oldText);
|
|
137
|
+
if (occurrences === 0) throw new WorkspaceError(`old_text was not found in ${target.relative}.`);
|
|
138
|
+
if (!replaceAll && occurrences !== 1) {
|
|
139
|
+
throw new WorkspaceError(`old_text occurs ${occurrences} times in ${target.relative}; provide more context or set replace_all.`);
|
|
140
|
+
}
|
|
141
|
+
const updated = replaceAll ? content.split(oldText).join(newText) : content.replace(oldText, newText);
|
|
142
|
+
this.#validateContent(updated);
|
|
143
|
+
await fs.writeFile(target.absolute, updated, 'utf8');
|
|
144
|
+
this.#track(target.relative, content, updated);
|
|
145
|
+
return { path: target.relative, replacements: replaceAll ? occurrences : 1, bytes: Buffer.byteLength(updated) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
changeSummary() {
|
|
149
|
+
return [...this.changes.entries()].map(([file, change]) => ({
|
|
150
|
+
path: file,
|
|
151
|
+
status: change.original === null ? 'created' : 'modified',
|
|
152
|
+
...lineDelta(change.original, change.current),
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
formatChanges({ maxCharacters = 20_000 } = {}) {
|
|
157
|
+
let output = '';
|
|
158
|
+
for (const [file, change] of this.changes) {
|
|
159
|
+
const patch = compactDiff(file, change.original, change.current);
|
|
160
|
+
if (output.length + patch.length > maxCharacters) {
|
|
161
|
+
output += '\n... diff output truncated ...\n';
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
output += `${output ? '\n' : ''}${patch}`;
|
|
165
|
+
}
|
|
166
|
+
return output;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async #resolve(input, { mustExist, forWrite = false }) {
|
|
170
|
+
if (typeof input !== 'string' || !input.trim()) throw new WorkspaceError('A relative repository path is required.');
|
|
171
|
+
if (path.isAbsolute(input)) throw new WorkspaceError('Absolute paths are not allowed.');
|
|
172
|
+
const normalized = input.replaceAll('\\', '/');
|
|
173
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
174
|
+
if (segments.includes('.git')) throw new WorkspaceError('Access to .git is not allowed.');
|
|
175
|
+
if (segments.length && isSensitiveFile(segments.at(-1))) throw new WorkspaceError('Access to credential or secret files is not allowed.');
|
|
176
|
+
if (forWrite && segments.some((segment) => DEFAULT_IGNORES.has(segment))) {
|
|
177
|
+
throw new WorkspaceError('Writes to ignored metadata or dependency directories are not allowed.');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const absolute = path.resolve(this.root, input);
|
|
181
|
+
this.#assertInside(absolute);
|
|
182
|
+
if (mustExist) {
|
|
183
|
+
const real = await fs.realpath(absolute).catch(() => null);
|
|
184
|
+
if (!real) throw new WorkspaceError(`Path does not exist: ${this.#displayPath(absolute)}`);
|
|
185
|
+
this.#assertInside(real);
|
|
186
|
+
return { absolute: real, relative: this.#displayPath(real) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const parent = await nearestExistingParent(path.dirname(absolute));
|
|
190
|
+
const realParent = await fs.realpath(parent);
|
|
191
|
+
this.#assertInside(realParent);
|
|
192
|
+
return { absolute, relative: this.#displayPath(absolute) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#assertInside(absolute) {
|
|
196
|
+
const relative = path.relative(this.root, absolute);
|
|
197
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
198
|
+
throw new WorkspaceError('Path escapes the selected repository.');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#displayPath(absolute) {
|
|
203
|
+
const relative = path.relative(this.root, absolute);
|
|
204
|
+
return relative ? relative.split(path.sep).join('/') : '.';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#validateContent(content) {
|
|
208
|
+
if (typeof content !== 'string') throw new WorkspaceError('File content must be text.');
|
|
209
|
+
if (Buffer.byteLength(content) > MAX_FILE_BYTES) throw new WorkspaceError(`File content exceeds ${MAX_FILE_BYTES} bytes.`);
|
|
210
|
+
if (content.includes('\0')) throw new WorkspaceError('Binary file content is not supported.');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#track(file, before, after) {
|
|
214
|
+
const existing = this.changes.get(file);
|
|
215
|
+
this.changes.set(file, { original: existing ? existing.original : before, current: after });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function nearestExistingParent(start) {
|
|
220
|
+
let current = start;
|
|
221
|
+
while (!(await exists(current))) {
|
|
222
|
+
const parent = path.dirname(current);
|
|
223
|
+
if (parent === current) throw new WorkspaceError('Cannot resolve a writable parent directory.');
|
|
224
|
+
current = parent;
|
|
225
|
+
}
|
|
226
|
+
return current;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function exists(target) {
|
|
230
|
+
return fs.access(target).then(() => true, () => false);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function countOccurrences(content, search) {
|
|
234
|
+
let count = 0;
|
|
235
|
+
let offset = 0;
|
|
236
|
+
while ((offset = content.indexOf(search, offset)) !== -1) {
|
|
237
|
+
count += 1;
|
|
238
|
+
offset += search.length;
|
|
239
|
+
}
|
|
240
|
+
return count;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function lineDelta(original, current) {
|
|
244
|
+
if (original === null) return { addedLines: current ? current.split(/\r?\n/).length : 0, removedLines: 0 };
|
|
245
|
+
const before = original.split(/\r?\n/);
|
|
246
|
+
const after = current.split(/\r?\n/);
|
|
247
|
+
let prefix = 0;
|
|
248
|
+
while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) prefix += 1;
|
|
249
|
+
let suffix = 0;
|
|
250
|
+
while (
|
|
251
|
+
suffix < before.length - prefix &&
|
|
252
|
+
suffix < after.length - prefix &&
|
|
253
|
+
before[before.length - 1 - suffix] === after[after.length - 1 - suffix]
|
|
254
|
+
) suffix += 1;
|
|
255
|
+
return {
|
|
256
|
+
addedLines: after.length - prefix - suffix,
|
|
257
|
+
removedLines: before.length - prefix - suffix,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function compactDiff(file, original, current) {
|
|
262
|
+
if (original === null) {
|
|
263
|
+
return `--- /dev/null\n+++ b/${file}\n@@ created @@\n${current.split(/\r?\n/).map((line) => `+${line}`).join('\n')}\n`;
|
|
264
|
+
}
|
|
265
|
+
const before = original.split(/\r?\n/);
|
|
266
|
+
const after = current.split(/\r?\n/);
|
|
267
|
+
let prefix = 0;
|
|
268
|
+
while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) prefix += 1;
|
|
269
|
+
let suffix = 0;
|
|
270
|
+
while (
|
|
271
|
+
suffix < before.length - prefix &&
|
|
272
|
+
suffix < after.length - prefix &&
|
|
273
|
+
before[before.length - 1 - suffix] === after[after.length - 1 - suffix]
|
|
274
|
+
) suffix += 1;
|
|
275
|
+
const beforeSlice = before.slice(prefix, before.length - suffix);
|
|
276
|
+
const afterSlice = after.slice(prefix, after.length - suffix);
|
|
277
|
+
const contextStart = Math.max(0, prefix - 2);
|
|
278
|
+
const contextBefore = before.slice(contextStart, prefix);
|
|
279
|
+
const contextAfter = suffix ? after.slice(after.length - suffix, Math.min(after.length, after.length - suffix + 2)) : [];
|
|
280
|
+
return [
|
|
281
|
+
`--- a/${file}`,
|
|
282
|
+
`+++ b/${file}`,
|
|
283
|
+
`@@ -${prefix + 1},${beforeSlice.length} +${prefix + 1},${afterSlice.length} @@`,
|
|
284
|
+
...contextBefore.map((line) => ` ${line}`),
|
|
285
|
+
...beforeSlice.map((line) => `-${line}`),
|
|
286
|
+
...afterSlice.map((line) => `+${line}`),
|
|
287
|
+
...contextAfter.map((line) => ` ${line}`),
|
|
288
|
+
'',
|
|
289
|
+
].join('\n');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isSensitiveFile(name) {
|
|
293
|
+
const lower = name.toLowerCase();
|
|
294
|
+
if (lower === '.env' || (lower.startsWith('.env.') && !['.env.example', '.env.sample', '.env.template'].includes(lower))) return true;
|
|
295
|
+
if (['.npmrc', '.pypirc', '.netrc', 'id_rsa', 'id_ed25519', 'credentials.json'].includes(lower)) return true;
|
|
296
|
+
return lower.endsWith('.pem') || lower.endsWith('.key');
|
|
297
|
+
}
|