@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: `File not found: ${resolvedPath}`,
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: `File does not exist: ${sourcePath}`,
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 serverResult = await replaceDocumentTextOnServer(authConfig, path.basename(sourcePath), readFileSync(sourceAbsPath), replacements, args.replaceAll === true || args.replace_all === true);
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.7",
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.7",
31
- "@thegitai/tui-darwin-x64": "1.0.0-beta.7",
32
- "@thegitai/tui-linux-x64": "1.0.0-beta.7",
33
- "@thegitai/tui-win32-x64": "1.0.0-beta.7"
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"