@thegitai/cli 1.0.0-beta.7 → 1.0.0-beta.9
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/dist/bin/ai.js
CHANGED
|
@@ -20,12 +20,6 @@ const { auth, chat, models, sessions } = ServerApi;
|
|
|
20
20
|
function printUsage() {
|
|
21
21
|
console.log(formatCliHelpText({ color: process.stdout.isTTY === true }));
|
|
22
22
|
}
|
|
23
|
-
function commandFlagValue(args, name) {
|
|
24
|
-
const index = args.indexOf(name);
|
|
25
|
-
if (index === -1)
|
|
26
|
-
return null;
|
|
27
|
-
return args[index + 1] ?? null;
|
|
28
|
-
}
|
|
29
23
|
async function promptText(question, fallback = null) {
|
|
30
24
|
const rl = readline.createInterface({ input, output });
|
|
31
25
|
try {
|
|
@@ -42,17 +36,16 @@ function appendPromptHistory(prompt, env = process.env) {
|
|
|
42
36
|
}
|
|
43
37
|
async function runAuthCommand(command, args) {
|
|
44
38
|
if (command === 'login') {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
39
|
+
// The public CLI always authenticates against the official TheGitAI host.
|
|
40
|
+
// There is intentionally no server/website override here — internal dev
|
|
41
|
+
// uses private tooling, not a customer-visible runtime override path.
|
|
42
|
+
const serverUrl = DEFAULT_SERVER_URL;
|
|
49
43
|
const noBrowser = args.includes('--no-browser');
|
|
50
44
|
console.log(chalk.dim(noBrowser
|
|
51
45
|
? 'Sign in on the website, then paste the authorization code here.'
|
|
52
46
|
: 'Opening your browser to sign in…'));
|
|
53
47
|
const result = await loginViaBrowser({
|
|
54
48
|
serverUrl,
|
|
55
|
-
websiteUrl,
|
|
56
49
|
noBrowser,
|
|
57
50
|
onUrl: (url) => {
|
|
58
51
|
console.log(chalk.dim(noBrowser ? 'Open this URL to sign in:' : 'If your browser did not open, visit:'));
|
|
@@ -4,7 +4,6 @@ import os from 'node:os';
|
|
|
4
4
|
import { openUrl } from '../core/open-url.js';
|
|
5
5
|
import { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
|
|
6
6
|
const DEFAULT_WEBSITE_URL = 'https://thegit.ai';
|
|
7
|
-
const DEFAULT_DEV_WEBSITE_URL = 'http://localhost:3002';
|
|
8
7
|
const DEFAULT_SERVER_URL = 'https://thegit.ai';
|
|
9
8
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
9
|
function shutDownServer(server) {
|
|
@@ -13,29 +12,11 @@ function shutDownServer(server) {
|
|
|
13
12
|
server.closeAllConnections?.();
|
|
14
13
|
server.close();
|
|
15
14
|
}
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
export function resolveWebsiteUrl(websiteUrl, env = process.env, serverUrl) {
|
|
28
|
-
const explicit = String(websiteUrl ?? '').trim() ||
|
|
29
|
-
String(env.THEGITAI_WEBSITE_URL ?? '').trim();
|
|
30
|
-
// With no explicit override, point at production — unless we're clearly in
|
|
31
|
-
// local dev (talking to a localhost server), in which case default to the
|
|
32
|
-
// local website so `ai login` works without any flags or env vars.
|
|
33
|
-
const value = explicit || (isLocalhostUrl(serverUrl) ? DEFAULT_DEV_WEBSITE_URL : DEFAULT_WEBSITE_URL);
|
|
34
|
-
const normalized = value.replace(/\/+$/, '');
|
|
35
|
-
if (!/^https?:\/\//i.test(normalized)) {
|
|
36
|
-
throw new Error('Website URL must start with http:// or https://.');
|
|
37
|
-
}
|
|
38
|
-
return normalized;
|
|
15
|
+
export function resolveWebsiteUrl() {
|
|
16
|
+
// The public CLI always signs in through the official TheGitAI website. There
|
|
17
|
+
// is no override path here so the published package cannot be pointed at a
|
|
18
|
+
// clone host.
|
|
19
|
+
return DEFAULT_WEBSITE_URL.replace(/\/+$/, '');
|
|
39
20
|
}
|
|
40
21
|
function defaultDeviceName() {
|
|
41
22
|
try {
|
|
@@ -104,7 +85,7 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
|
|
|
104
85
|
*/
|
|
105
86
|
export async function loginViaBrowser(options) {
|
|
106
87
|
const serverUrl = normalizeServerUrl(options.serverUrl ?? DEFAULT_SERVER_URL);
|
|
107
|
-
const websiteUrl = resolveWebsiteUrl(
|
|
88
|
+
const websiteUrl = resolveWebsiteUrl();
|
|
108
89
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
109
90
|
const openBrowser = options.openBrowser ?? openUrl;
|
|
110
91
|
const onUrl = options.onUrl ?? (() => { });
|
|
@@ -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.9",
|
|
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.9",
|
|
31
|
+
"@thegitai/tui-darwin-x64": "1.0.0-beta.9",
|
|
32
|
+
"@thegitai/tui-linux-x64": "1.0.0-beta.9",
|
|
33
|
+
"@thegitai/tui-win32-x64": "1.0.0-beta.9"
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|