@thegitai/cli 1.0.0-beta.7 → 1.0.0-beta.8
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,66 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isSensitiveProjectPath } from '../artifact-policy.js';
|
|
4
|
+
// "File not found" recovery hint: when a model mistypes a filename (most
|
|
5
|
+
// often Unicode punctuation — a straight ' for a curly ’ — or a small typo),
|
|
6
|
+
// suggest the closest real file from the same directory.
|
|
7
|
+
function foldName(name) {
|
|
8
|
+
return name
|
|
9
|
+
.normalize('NFC')
|
|
10
|
+
.replace(/[‘’ʼ]/g, "'")
|
|
11
|
+
.replace(/[“”]/g, '"')
|
|
12
|
+
.replace(/ /g, ' ')
|
|
13
|
+
.toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
function levenshtein(a, b) {
|
|
16
|
+
if (a === b)
|
|
17
|
+
return 0;
|
|
18
|
+
const rows = a.length + 1;
|
|
19
|
+
const cols = b.length + 1;
|
|
20
|
+
let prev = Array.from({ length: cols }, (_, j) => j);
|
|
21
|
+
for (let i = 1; i < rows; i++) {
|
|
22
|
+
const current = [i, ...new Array(cols - 1).fill(0)];
|
|
23
|
+
for (let j = 1; j < cols; j++) {
|
|
24
|
+
current[j] = Math.min(prev[j] + 1, current[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
25
|
+
}
|
|
26
|
+
prev = current;
|
|
27
|
+
}
|
|
28
|
+
return prev[cols - 1];
|
|
29
|
+
}
|
|
30
|
+
export function suggestClosestPath(rootDir, missingPath) {
|
|
31
|
+
const resolved = path.isAbsolute(missingPath)
|
|
32
|
+
? missingPath
|
|
33
|
+
: path.resolve(rootDir, missingPath);
|
|
34
|
+
const directory = path.dirname(resolved);
|
|
35
|
+
const wantedBase = foldName(path.basename(resolved));
|
|
36
|
+
if (!wantedBase)
|
|
37
|
+
return null;
|
|
38
|
+
let candidates;
|
|
39
|
+
try {
|
|
40
|
+
candidates = readdirSync(directory);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const threshold = Math.max(2, Math.floor(wantedBase.length * 0.25));
|
|
46
|
+
let best = null;
|
|
47
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
48
|
+
for (const candidate of candidates) {
|
|
49
|
+
// Never suggest a file the caller would refuse to read/write directly:
|
|
50
|
+
// probing a near-miss like `.enx` or `credential.docx` must not leak the
|
|
51
|
+
// existence of `.env`/credentials through the recovery hint.
|
|
52
|
+
const candidateRelative = path.relative(rootDir, path.join(directory, candidate));
|
|
53
|
+
if (isSensitiveProjectPath(candidateRelative))
|
|
54
|
+
continue;
|
|
55
|
+
const distance = levenshtein(wantedBase, foldName(candidate));
|
|
56
|
+
if (distance < bestDistance) {
|
|
57
|
+
bestDistance = distance;
|
|
58
|
+
best = candidate;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!best || bestDistance > threshold)
|
|
62
|
+
return null;
|
|
63
|
+
const suggested = path.join(directory, best);
|
|
64
|
+
const relative = path.relative(rootDir, suggested);
|
|
65
|
+
return relative && !relative.startsWith('..') ? relative : suggested;
|
|
66
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { isSensitiveProjectPath, normalizeProjectRelativePath, } from '../artifact-policy.js';
|
|
4
|
+
import { suggestClosestPath } from './path-suggest.js';
|
|
4
5
|
import { readCliAuthConfig } from '../api/auth.js';
|
|
5
6
|
export function normalizeDocumentText(raw) {
|
|
6
7
|
const text = String(raw ?? '').replace(/\r\n?/g, '\n');
|
|
@@ -55,6 +56,9 @@ async function parseDocumentOnServer(config, fileName, fileData, ext, args) {
|
|
|
55
56
|
...(includeParagraphArgs && args.firstParagraph !== undefined
|
|
56
57
|
? { firstParagraph: args.firstParagraph }
|
|
57
58
|
: {}),
|
|
59
|
+
...(includeParagraphArgs && args.lastParagraph !== undefined
|
|
60
|
+
? { lastParagraph: args.lastParagraph }
|
|
61
|
+
: {}),
|
|
58
62
|
}),
|
|
59
63
|
});
|
|
60
64
|
const data = await response.json().catch(() => null);
|
|
@@ -80,9 +84,12 @@ export async function readDocument(rootDir, args, env) {
|
|
|
80
84
|
};
|
|
81
85
|
}
|
|
82
86
|
if (!existsSync(resolvedPath)) {
|
|
87
|
+
const suggestion = suggestClosestPath(rootDir, resolvedPath);
|
|
83
88
|
return {
|
|
84
89
|
ok: false,
|
|
85
|
-
error:
|
|
90
|
+
error: suggestion
|
|
91
|
+
? `File not found: ${resolvedPath}. Did you mean "${suggestion}"? Note the exact punctuation (e.g. curly apostrophe ’ vs straight ').`
|
|
92
|
+
: `File not found: ${resolvedPath}`,
|
|
86
93
|
failureCategory: 'not_found',
|
|
87
94
|
};
|
|
88
95
|
}
|
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { isSensitiveProjectPath, normalizeProjectRelativePath, } from '../artifact-policy.js';
|
|
5
5
|
import { readCliAuthConfig } from '../api/auth.js';
|
|
6
6
|
import { resolveProjectPath, writeProjectFileBuffer } from '../patcher.js';
|
|
7
|
+
import { suggestClosestPath } from './path-suggest.js';
|
|
7
8
|
import { isTuiMode } from '../runtime-mode.js';
|
|
8
9
|
function normalizeReplacements(value) {
|
|
9
10
|
if (!Array.isArray(value))
|
|
@@ -50,7 +51,7 @@ function renderPreview(filePath, preview) {
|
|
|
50
51
|
}
|
|
51
52
|
console.log();
|
|
52
53
|
}
|
|
53
|
-
async function replaceDocumentTextOnServer(config, fileName, fileData, replacements, replaceAll) {
|
|
54
|
+
async function replaceDocumentTextOnServer(config, fileName, fileData, replacements, replaceAll, validate) {
|
|
54
55
|
const response = await globalThis.fetch(`${config.serverUrl.replace(/\/+$/, '')}/v1/document/replace-text`, {
|
|
55
56
|
method: 'POST',
|
|
56
57
|
headers: {
|
|
@@ -62,6 +63,7 @@ async function replaceDocumentTextOnServer(config, fileName, fileData, replaceme
|
|
|
62
63
|
fileData: fileData.toString('base64'),
|
|
63
64
|
replacements,
|
|
64
65
|
replaceAll,
|
|
66
|
+
validate,
|
|
65
67
|
}),
|
|
66
68
|
});
|
|
67
69
|
const data = await response.json().catch(() => null);
|
|
@@ -128,14 +130,18 @@ export async function replaceDocumentText(context, args) {
|
|
|
128
130
|
}
|
|
129
131
|
const sourceAbsPath = resolveProjectPath(context.rootDir, sourcePath);
|
|
130
132
|
if (!existsSync(sourceAbsPath)) {
|
|
133
|
+
const suggestion = suggestClosestPath(context.rootDir, sourceAbsPath);
|
|
131
134
|
return {
|
|
132
135
|
ok: false,
|
|
133
136
|
filePath: sourcePath,
|
|
134
|
-
error:
|
|
137
|
+
error: suggestion
|
|
138
|
+
? `File does not exist: ${sourcePath}. Did you mean "${suggestion}"? Note the exact punctuation (e.g. curly apostrophe ’ vs straight ').`
|
|
139
|
+
: `File does not exist: ${sourcePath}`,
|
|
135
140
|
failureCategory: 'not_found',
|
|
136
141
|
};
|
|
137
142
|
}
|
|
138
|
-
const
|
|
143
|
+
const validateOnly = args.validate === true || args.dryRun === true;
|
|
144
|
+
const serverResult = await replaceDocumentTextOnServer(authConfig, path.basename(sourcePath), readFileSync(sourceAbsPath), replacements, args.replaceAll === true || args.replace_all === true, validateOnly);
|
|
139
145
|
if (!serverResult.ok) {
|
|
140
146
|
return {
|
|
141
147
|
...serverResult,
|
|
@@ -143,6 +149,38 @@ export async function replaceDocumentText(context, args) {
|
|
|
143
149
|
failureCategory: serverResult.failureCategory ?? 'external_service',
|
|
144
150
|
};
|
|
145
151
|
}
|
|
152
|
+
// Validate-only: report per-replacement match info without touching the file.
|
|
153
|
+
// changed:false marks it non-mutating so the agent loop does not count a
|
|
154
|
+
// dry-run as an applied edit.
|
|
155
|
+
if (validateOnly) {
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
validate: true,
|
|
159
|
+
changed: false,
|
|
160
|
+
filePath: sourcePath,
|
|
161
|
+
operation: 'replace_document_text',
|
|
162
|
+
results: serverResult.results,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// No replacement matched: nothing was written. Surface per-item reasons so
|
|
166
|
+
// the model can correct and resend only the failing entries.
|
|
167
|
+
const replacementCount = Number(serverResult.replacementCount ?? 0);
|
|
168
|
+
if (replacementCount === 0) {
|
|
169
|
+
const failures = Array.isArray(serverResult.replacements)
|
|
170
|
+
? serverResult.replacements
|
|
171
|
+
.filter((item) => item && item.ok === false)
|
|
172
|
+
.map((item) => `- ${item.error ?? 'no match'}`)
|
|
173
|
+
: [];
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
filePath: sourcePath,
|
|
177
|
+
operation: 'replace_document_text',
|
|
178
|
+
failureCategory: 'conflict',
|
|
179
|
+
error: `0 of ${serverResult.requestedCount ?? replacements.length} replacements applied; file unchanged.` +
|
|
180
|
+
(failures.length ? `\n${failures.join('\n')}` : ''),
|
|
181
|
+
replacements: serverResult.replacements,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
146
184
|
const preview = String(serverResult.preview ?? '');
|
|
147
185
|
renderPreview(targetPath, preview);
|
|
148
186
|
if (!context.autoYes && context.confirmPatch) {
|
|
@@ -162,6 +200,14 @@ export async function replaceDocumentText(context, args) {
|
|
|
162
200
|
const fileData = String(serverResult.fileData ?? '');
|
|
163
201
|
const nextData = Buffer.from(fileData, 'base64');
|
|
164
202
|
const write = writeProjectFileBuffer(context.rootDir, targetPath, nextData);
|
|
203
|
+
const failedCount = Number(serverResult.failedCount ?? 0);
|
|
204
|
+
const appliedCount = Number(serverResult.appliedCount ?? replacementCount);
|
|
205
|
+
const requestedCount = Number(serverResult.requestedCount ?? replacements.length);
|
|
206
|
+
const partialFailures = Array.isArray(serverResult.replacements)
|
|
207
|
+
? serverResult.replacements
|
|
208
|
+
.filter((item) => item && item.ok === false)
|
|
209
|
+
.map((item) => `- ${item.error ?? 'no match'}`)
|
|
210
|
+
: [];
|
|
165
211
|
return {
|
|
166
212
|
ok: true,
|
|
167
213
|
filePath: targetPath,
|
|
@@ -169,7 +215,28 @@ export async function replaceDocumentText(context, args) {
|
|
|
169
215
|
changed: write.changed,
|
|
170
216
|
operation: 'replace_document_text',
|
|
171
217
|
replacementCount: serverResult.replacementCount,
|
|
218
|
+
requestedCount: serverResult.requestedCount,
|
|
219
|
+
appliedCount: serverResult.appliedCount,
|
|
220
|
+
failedCount: serverResult.failedCount,
|
|
172
221
|
replacements: serverResult.replacements,
|
|
173
222
|
bytesWritten: nextData.length,
|
|
223
|
+
// A partial batch still wrote the matched entries (changed:true above), but
|
|
224
|
+
// the loop must reflect and repair the missed entries — needsRepair forces
|
|
225
|
+
// that without losing credit for the applied edits.
|
|
226
|
+
...(failedCount > 0
|
|
227
|
+
? {
|
|
228
|
+
needsRepair: true,
|
|
229
|
+
failureCategory: 'conflict',
|
|
230
|
+
error: `${appliedCount} of ${requestedCount} replacements applied; ` +
|
|
231
|
+
`${failedCount} missed and still need to be fixed:\n` +
|
|
232
|
+
partialFailures.join('\n'),
|
|
233
|
+
failureDetails: {
|
|
234
|
+
category: 'conflict',
|
|
235
|
+
tool: 'replace_document_text',
|
|
236
|
+
action: 'Re-issue replace_document_text for the missed entries only, with corrected oldText. ' +
|
|
237
|
+
'Copy the exact text from read_document (mind curly vs straight quotes), or pass validate:true to test a match first.',
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
: {}),
|
|
174
241
|
};
|
|
175
242
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thegitai/cli",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.8",
|
|
4
4
|
"description": "TheGitAI CLI client (source-visible, proprietary)",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://thegit.ai",
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"web-tree-sitter": "^0.26.6"
|
|
28
28
|
},
|
|
29
29
|
"optionalDependencies": {
|
|
30
|
-
"@thegitai/tui-darwin-arm64": "1.0.0-beta.
|
|
31
|
-
"@thegitai/tui-darwin-x64": "1.0.0-beta.
|
|
32
|
-
"@thegitai/tui-linux-x64": "1.0.0-beta.
|
|
33
|
-
"@thegitai/tui-win32-x64": "1.0.0-beta.
|
|
30
|
+
"@thegitai/tui-darwin-arm64": "1.0.0-beta.8",
|
|
31
|
+
"@thegitai/tui-darwin-x64": "1.0.0-beta.8",
|
|
32
|
+
"@thegitai/tui-linux-x64": "1.0.0-beta.8",
|
|
33
|
+
"@thegitai/tui-win32-x64": "1.0.0-beta.8"
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|