@thegitai/cli 1.0.0-beta.13 → 1.0.0-beta.15
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 +41 -6
- package/dist/src/api/auth.js +3 -3
- package/dist/src/api/browser-login.js +0 -16
- package/dist/src/api/chat.js +18 -9
- package/dist/src/api/http.js +49 -1
- package/dist/src/api/models.js +26 -20
- package/dist/src/artifact-policy.js +0 -4
- package/dist/src/cli-args.js +0 -5
- package/dist/src/colors.js +0 -9
- package/dist/src/core/clipboard.js +19 -0
- package/dist/src/core/image-path-extractor.js +93 -0
- package/dist/src/executor.js +0 -7
- package/dist/src/help-text.js +0 -5
- package/dist/src/patcher.js +0 -2
- package/dist/src/scanner.js +0 -9
- package/dist/src/secret-preview.js +0 -10
- package/dist/src/session-safety.js +0 -19
- package/dist/src/session-store.js +0 -1
- package/dist/src/tool-executor.js +0 -11
- package/dist/src/tools/path-suggest.js +1 -38
- package/dist/src/tools/read-file.js +0 -4
- package/dist/src/tools/replace-document-text.js +0 -12
- package/dist/src/tools/run-command.js +0 -2
- package/dist/src/tree-sitter-runtime.js +0 -6
- package/dist/src/ui/repl.js +0 -15
- package/dist/src/ui/tui/bridge.js +0 -4
- package/dist/src/ui/tui/build-frame.js +0 -8
- package/dist/src/ui/tui/shell-input.js +0 -4
- package/dist/src/version.js +0 -6
- package/package.json +5 -5
package/dist/bin/ai.js
CHANGED
|
@@ -4,6 +4,7 @@ import { stdin as input, stdout as output } from 'node:process';
|
|
|
4
4
|
import readline from 'node:readline/promises';
|
|
5
5
|
import { ServerApi } from '../src/api/index.js';
|
|
6
6
|
import { loginViaBrowser } from '../src/api/browser-login.js';
|
|
7
|
+
import { isTransientNetworkError } from '../src/api/http.js';
|
|
7
8
|
import { formatCliHelpText, formatInteractiveHelpText, } from '../src/help-text.js';
|
|
8
9
|
import { renderMarkdownForTerminal } from '../src/markdown-renderer.js';
|
|
9
10
|
import { createIndex } from '../src/project-index.js';
|
|
@@ -36,9 +37,6 @@ function appendPromptHistory(prompt, env = process.env) {
|
|
|
36
37
|
}
|
|
37
38
|
async function runAuthCommand(command, args) {
|
|
38
39
|
if (command === 'login') {
|
|
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
40
|
const serverUrl = DEFAULT_SERVER_URL;
|
|
43
41
|
const noBrowser = args.includes('--no-browser');
|
|
44
42
|
console.log(chalk.dim(noBrowser
|
|
@@ -314,9 +312,46 @@ export async function main() {
|
|
|
314
312
|
const rootDir = process.cwd();
|
|
315
313
|
const authConfig = requireCliAuthConfig();
|
|
316
314
|
const serverSessionClient = sessions.createServerSessionClient({ config: authConfig });
|
|
317
|
-
const cachedModels = models.readCachedServerModels();
|
|
318
|
-
|
|
319
|
-
|
|
315
|
+
const cachedModels = models.selectCacheForServer(models.readCachedServerModels(), authConfig.serverUrl);
|
|
316
|
+
let offlineNotice = null;
|
|
317
|
+
let serverModels;
|
|
318
|
+
try {
|
|
319
|
+
serverModels = await models.fetchServerModels({ config: authConfig });
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
if (isTransientNetworkError(error) && cachedModels?.models.length) {
|
|
323
|
+
serverModels = { models: cachedModels.models };
|
|
324
|
+
offlineNotice = error?.message ? String(error.message) : 'network error';
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
let whoami;
|
|
331
|
+
try {
|
|
332
|
+
whoami = await auth.fetchWhoamiResponse({ config: authConfig });
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (isTransientNetworkError(error)) {
|
|
336
|
+
whoami = {
|
|
337
|
+
customer: {
|
|
338
|
+
id: '',
|
|
339
|
+
uuid: '',
|
|
340
|
+
email: authConfig.email,
|
|
341
|
+
customer_type: authConfig.customerType ?? 'USER',
|
|
342
|
+
scopes: [],
|
|
343
|
+
},
|
|
344
|
+
debugUi: { showSessionId: false },
|
|
345
|
+
};
|
|
346
|
+
offlineNotice ??= error?.message ? String(error.message) : 'network error';
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (offlineNotice) {
|
|
353
|
+
console.error(chalk.yellow(`⚠ Couldn't reach TheGitAI (${offlineNotice}). Starting with cached settings — it will reconnect on your next message.`));
|
|
354
|
+
}
|
|
320
355
|
if (listSessions) {
|
|
321
356
|
printSessionList(rootDir, listSessionMetadata(rootDir), serverModels);
|
|
322
357
|
return;
|
package/dist/src/api/auth.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getClientStateDir } from '../client-state.js';
|
|
4
|
-
import { ServerApiError, authorizedJson, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
|
|
4
|
+
import { ServerApiError, authorizedJson, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
5
5
|
export function getAuthConfigPath(env = process.env) {
|
|
6
6
|
const configured = String(env.THEGITAI_AUTH_CONFIG ?? '').trim();
|
|
7
7
|
if (configured) {
|
|
@@ -51,11 +51,11 @@ export async function fetchWhoami({ config, fetchImpl = globalThis.fetch, }) {
|
|
|
51
51
|
return data.customer;
|
|
52
52
|
}
|
|
53
53
|
export async function fetchWhoamiResponse({ config, fetchImpl = globalThis.fetch, }) {
|
|
54
|
-
const data = (await authorizedJson({
|
|
54
|
+
const data = (await retryTransient(() => authorizedJson({
|
|
55
55
|
config,
|
|
56
56
|
path: '/v1/auth/whoami',
|
|
57
57
|
fetchImpl,
|
|
58
|
-
}));
|
|
58
|
+
})));
|
|
59
59
|
if (!data?.customer?.email) {
|
|
60
60
|
throw new Error('Server returned an invalid whoami response.');
|
|
61
61
|
}
|
|
@@ -7,15 +7,10 @@ const DEFAULT_WEBSITE_URL = 'https://thegit.ai';
|
|
|
7
7
|
const DEFAULT_SERVER_URL = 'https://thegit.ai';
|
|
8
8
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
9
|
function shutDownServer(server) {
|
|
10
|
-
// Drop any lingering (keep-alive) connections so the event loop empties and
|
|
11
|
-
// the CLI exits instead of hanging after a successful login.
|
|
12
10
|
server.closeAllConnections?.();
|
|
13
11
|
server.close();
|
|
14
12
|
}
|
|
15
13
|
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
14
|
return DEFAULT_WEBSITE_URL.replace(/\/+$/, '');
|
|
20
15
|
}
|
|
21
16
|
function defaultDeviceName() {
|
|
@@ -26,7 +21,6 @@ function defaultDeviceName() {
|
|
|
26
21
|
return os.hostname();
|
|
27
22
|
}
|
|
28
23
|
}
|
|
29
|
-
/** PKCE (RFC 7636, S256): a random verifier and its SHA-256 challenge. */
|
|
30
24
|
export function generatePkce() {
|
|
31
25
|
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
32
26
|
const challenge = crypto
|
|
@@ -77,12 +71,6 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
|
|
|
77
71
|
customer,
|
|
78
72
|
};
|
|
79
73
|
}
|
|
80
|
-
/**
|
|
81
|
-
* Browser-based login. Starts a loopback server so the website can redirect the
|
|
82
|
-
* one-time code back automatically; the code is then exchanged for a token
|
|
83
|
-
* using the PKCE verifier. With `noBrowser`, the user pastes the code instead.
|
|
84
|
-
* The CLI never sees the user's credentials.
|
|
85
|
-
*/
|
|
86
74
|
export async function loginViaBrowser(options) {
|
|
87
75
|
const serverUrl = normalizeServerUrl(options.serverUrl ?? DEFAULT_SERVER_URL);
|
|
88
76
|
const websiteUrl = resolveWebsiteUrl();
|
|
@@ -97,8 +85,6 @@ export async function loginViaBrowser(options) {
|
|
|
97
85
|
deviceName,
|
|
98
86
|
paste: true,
|
|
99
87
|
});
|
|
100
|
-
// Headless mode: only print the URL for the user to open on another device.
|
|
101
|
-
// Never launch a browser here — that is the whole point of --no-browser.
|
|
102
88
|
onUrl(authUrl);
|
|
103
89
|
if (!options.promptCode) {
|
|
104
90
|
throw new Error('No way to read the authorization code in this context.');
|
|
@@ -125,8 +111,6 @@ export async function loginViaBrowser(options) {
|
|
|
125
111
|
}
|
|
126
112
|
const code = requestUrl.searchParams.get('code') ?? '';
|
|
127
113
|
const returnedState = requestUrl.searchParams.get('state') ?? '';
|
|
128
|
-
// `Connection: close` plus closeAllConnections() ensures the browser's
|
|
129
|
-
// keep-alive socket is torn down so the process can exit after login.
|
|
130
114
|
if (!code || returnedState !== state) {
|
|
131
115
|
res.writeHead(400, { 'content-type': 'text/html', connection: 'close' });
|
|
132
116
|
res.end(RESULT_PAGE('Login failed', 'The request could not be verified. Please run ai login again.'));
|
package/dist/src/api/chat.js
CHANGED
|
@@ -3,6 +3,7 @@ import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js'
|
|
|
3
3
|
import { executeLocalToolCall } from '../tool-executor.js';
|
|
4
4
|
import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
|
|
5
5
|
import { collectClientEnvironment } from '../client-environment.js';
|
|
6
|
+
import { autoAttachImages } from '../core/image-path-extractor.js';
|
|
6
7
|
export class TurnCancelledError extends Error {
|
|
7
8
|
name = 'TurnCancelledError';
|
|
8
9
|
constructor(message = 'Turn cancelled.') {
|
|
@@ -63,6 +64,9 @@ function snapshotForServer(session) {
|
|
|
63
64
|
snapshot.clientState.safety = sanitizeSessionSafetyForServer(snapshot.clientState.safety);
|
|
64
65
|
return snapshot;
|
|
65
66
|
}
|
|
67
|
+
function imageAttachmentsForServer(attachments) {
|
|
68
|
+
return (attachments ?? []).map(({ filePath: _filePath, ...attachment }) => attachment);
|
|
69
|
+
}
|
|
66
70
|
function userHistoryText(entry) {
|
|
67
71
|
return (entry.parts ?? [])
|
|
68
72
|
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
@@ -285,19 +289,27 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
285
289
|
return finalResult.current;
|
|
286
290
|
}
|
|
287
291
|
export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
|
|
292
|
+
const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
|
|
293
|
+
const requestImageAttachments = autoAttach.attachments.length > 0
|
|
294
|
+
? [...imageAttachments, ...autoAttach.attachments]
|
|
295
|
+
: imageAttachments;
|
|
296
|
+
const requestInput = autoAttach.attachments.length > 0 ? autoAttach.sanitizedInput : input;
|
|
297
|
+
for (const err of autoAttach.errors) {
|
|
298
|
+
session.onStatus(`Image: ${err}`);
|
|
299
|
+
}
|
|
288
300
|
const request = {
|
|
289
301
|
modelId: session.modelId,
|
|
290
302
|
session: snapshotForServer(session),
|
|
291
|
-
input,
|
|
303
|
+
input: requestInput,
|
|
292
304
|
clientEnvironment: collectClientEnvironment({ env: session.env }),
|
|
293
|
-
imageAttachments,
|
|
305
|
+
imageAttachments: imageAttachmentsForServer(requestImageAttachments),
|
|
294
306
|
maxToolSteps: session.maxToolSteps,
|
|
295
307
|
autoYes: session.autoYes,
|
|
296
308
|
agentMode: session.agentMode,
|
|
297
309
|
};
|
|
298
310
|
const trace = createTraceContext();
|
|
299
311
|
const preTurnHistoryLength = session.history.length;
|
|
300
|
-
const preserveOnAbort = () => preserveCancelledTurnInput(session,
|
|
312
|
+
const preserveOnAbort = () => preserveCancelledTurnInput(session, requestInput);
|
|
301
313
|
if (signal?.aborted) {
|
|
302
314
|
preserveOnAbort();
|
|
303
315
|
}
|
|
@@ -324,7 +336,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
324
336
|
config,
|
|
325
337
|
projectIndex,
|
|
326
338
|
session,
|
|
327
|
-
input,
|
|
339
|
+
input: requestInput,
|
|
328
340
|
fetchImpl,
|
|
329
341
|
signal,
|
|
330
342
|
traceId: trace.traceId,
|
|
@@ -339,16 +351,13 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
339
351
|
}
|
|
340
352
|
catch (error) {
|
|
341
353
|
if (isTurnCancelledError(error)) {
|
|
342
|
-
preserveCancelledTurnInput(session,
|
|
354
|
+
preserveCancelledTurnInput(session, requestInput);
|
|
343
355
|
throw error instanceof TurnCancelledError
|
|
344
356
|
? error
|
|
345
357
|
: new TurnCancelledError();
|
|
346
358
|
}
|
|
347
|
-
// Non-cancel failures (e.g. upstream connection errors) must not leave
|
|
348
|
-
// speculative cancelled-turn entries in history — otherwise the next
|
|
349
|
-
// request replays a malformed transcript to the server.
|
|
350
359
|
session.history.length = preTurnHistoryLength;
|
|
351
|
-
preserveFailedTurnInput(session,
|
|
360
|
+
preserveFailedTurnInput(session, requestInput, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
|
|
352
361
|
throw error;
|
|
353
362
|
}
|
|
354
363
|
finally {
|
package/dist/src/api/http.js
CHANGED
|
@@ -26,6 +26,53 @@ export function createTraceContext(traceId = createTraceId()) {
|
|
|
26
26
|
},
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
export const REQUEST_TIMEOUT_MS = 8000;
|
|
30
|
+
const TRANSIENT_NETWORK_CODES = new Set([
|
|
31
|
+
'ECONNRESET',
|
|
32
|
+
'ECONNREFUSED',
|
|
33
|
+
'ETIMEDOUT',
|
|
34
|
+
'EAI_AGAIN',
|
|
35
|
+
'ENOTFOUND',
|
|
36
|
+
'ENETUNREACH',
|
|
37
|
+
'EHOSTUNREACH',
|
|
38
|
+
'EPIPE',
|
|
39
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
40
|
+
'UND_ERR_SOCKET',
|
|
41
|
+
'UND_ERR_HEADERS_TIMEOUT',
|
|
42
|
+
'UND_ERR_BODY_TIMEOUT',
|
|
43
|
+
]);
|
|
44
|
+
export function isTransientNetworkError(error) {
|
|
45
|
+
if (error instanceof ServerApiError)
|
|
46
|
+
return false;
|
|
47
|
+
if (!error || typeof error !== 'object')
|
|
48
|
+
return false;
|
|
49
|
+
const err = error;
|
|
50
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError')
|
|
51
|
+
return true;
|
|
52
|
+
const code = err.code ?? err.cause?.code;
|
|
53
|
+
if (code) {
|
|
54
|
+
return TRANSIENT_NETWORK_CODES.has(code);
|
|
55
|
+
}
|
|
56
|
+
if (error instanceof TypeError && /fetch failed/i.test(err.message ?? '')) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
export async function retryTransient(run, { retries = 2, baseDelayMs = 400 } = {}) {
|
|
62
|
+
let attempt = 0;
|
|
63
|
+
for (;;) {
|
|
64
|
+
try {
|
|
65
|
+
return await run();
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (attempt >= retries || !isTransientNetworkError(error))
|
|
69
|
+
throw error;
|
|
70
|
+
const delayMs = baseDelayMs * 2 ** attempt;
|
|
71
|
+
attempt += 1;
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
29
76
|
export function normalizeServerUrl(serverUrl) {
|
|
30
77
|
const normalized = String(serverUrl || DEFAULT_SERVER_URL)
|
|
31
78
|
.trim()
|
|
@@ -53,7 +100,7 @@ export async function readErrorResponse(response, traceId = response.headers.get
|
|
|
53
100
|
const data = await readJsonResponse(response);
|
|
54
101
|
return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
|
|
55
102
|
}
|
|
56
|
-
export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, }) {
|
|
103
|
+
export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
|
|
57
104
|
const trace = createTraceContext();
|
|
58
105
|
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}${path}`, {
|
|
59
106
|
method,
|
|
@@ -64,6 +111,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
|
|
|
64
111
|
...(body === null ? {} : { 'content-type': 'application/json' }),
|
|
65
112
|
},
|
|
66
113
|
body: body === null ? undefined : JSON.stringify(body),
|
|
114
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
67
115
|
});
|
|
68
116
|
const data = await readJsonResponse(response);
|
|
69
117
|
if (!response.ok) {
|
package/dist/src/api/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getClientStateDir } from '../client-state.js';
|
|
4
|
-
import { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
|
|
4
|
+
import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
5
5
|
function sanitizeModelInfo(raw) {
|
|
6
6
|
if (!raw || typeof raw !== 'object') {
|
|
7
7
|
return null;
|
|
@@ -45,6 +45,11 @@ export function readCachedServerModels(env = process.env) {
|
|
|
45
45
|
return null;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
export function selectCacheForServer(cached, serverUrl) {
|
|
49
|
+
if (!cached)
|
|
50
|
+
return null;
|
|
51
|
+
return cached.serverUrl === normalizeServerUrl(serverUrl) ? cached : null;
|
|
52
|
+
}
|
|
48
53
|
export function writeCachedServerModels(cache, env = process.env) {
|
|
49
54
|
const filePath = getModelsCachePath(env);
|
|
50
55
|
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
@@ -54,26 +59,27 @@ export function writeCachedServerModels(cache, env = process.env) {
|
|
|
54
59
|
});
|
|
55
60
|
}
|
|
56
61
|
export async function fetchServerModels({ config, fetchImpl = globalThis.fetch, }) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
return retryTransient(async () => {
|
|
63
|
+
const trace = createTraceContext();
|
|
64
|
+
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
|
|
65
|
+
headers: {
|
|
66
|
+
authorization: `Bearer ${config.token}`,
|
|
67
|
+
...trace.headers,
|
|
68
|
+
},
|
|
69
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
70
|
+
});
|
|
71
|
+
const data = (await readJsonResponse(response));
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
74
|
+
}
|
|
75
|
+
const models = Array.isArray(data?.models)
|
|
76
|
+
? data.models.map(sanitizeModelInfo).filter(Boolean)
|
|
77
|
+
: [];
|
|
78
|
+
if (models.length === 0) {
|
|
79
|
+
throw new Error('Server returned an invalid model list.');
|
|
80
|
+
}
|
|
81
|
+
return { models };
|
|
63
82
|
});
|
|
64
|
-
const data = (await readJsonResponse(response));
|
|
65
|
-
if (!response.ok) {
|
|
66
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
67
|
-
}
|
|
68
|
-
const models = Array.isArray(data?.models)
|
|
69
|
-
? data.models.map(sanitizeModelInfo).filter(Boolean)
|
|
70
|
-
: [];
|
|
71
|
-
if (models.length === 0) {
|
|
72
|
-
throw new Error('Server returned an invalid model list.');
|
|
73
|
-
}
|
|
74
|
-
return {
|
|
75
|
-
models,
|
|
76
|
-
};
|
|
77
83
|
}
|
|
78
84
|
export function selectServerModel({ requestedModelId, cached, serverModels, }) {
|
|
79
85
|
const supportedIds = new Set(serverModels.models.map((model) => model.id));
|
|
@@ -148,10 +148,6 @@ export const ARTIFACT_FALLBACK_IGNORE_GLOBS = [
|
|
|
148
148
|
'**/pnpm-lock.yaml',
|
|
149
149
|
...ARTIFACT_IGNORE_PATH_PREFIXES.map((prefix) => `${prefix}/**`),
|
|
150
150
|
];
|
|
151
|
-
// Kept in lockstep with the server's secret-path check: the client repair guard
|
|
152
|
-
// and the server-side secret check must agree on what counts as a secret, or a
|
|
153
|
-
// quoted/curly secret path the client repairs (e.g. service-account.json) slips
|
|
154
|
-
// past the server check, which keys off the original tool-call args.
|
|
155
151
|
const SENSITIVE_BASENAME_PATTERNS = [
|
|
156
152
|
/^\.env(?:\..+)?$/i,
|
|
157
153
|
/^\.?npmrc$/i,
|
package/dist/src/cli-args.js
CHANGED
|
@@ -39,11 +39,6 @@ export function parseArgs(argv) {
|
|
|
39
39
|
usage = true;
|
|
40
40
|
continue;
|
|
41
41
|
}
|
|
42
|
-
// An unrecognized dashed token is a mistyped flag, not prompt text. Without
|
|
43
|
-
// an auth subcommand (whose flags are parsed separately) it would otherwise
|
|
44
|
-
// be swept into the prompt and silently start a billable session. Flag the
|
|
45
|
-
// first one so the caller can fail fast instead. Quoted prompts are a single
|
|
46
|
-
// argv entry with spaces, so they never look like a bare option here.
|
|
47
42
|
if (command === null && unknownOption === null && /^-/.test(arg)) {
|
|
48
43
|
unknownOption = arg;
|
|
49
44
|
continue;
|
package/dist/src/colors.js
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
// Dependency-free ANSI styler with a chalk-compatible surface, imported as
|
|
2
|
-
// `chalk` at call sites. Color gating (NO_COLOR / FORCE_COLOR / non-TTY) lives
|
|
3
|
-
// in colorEnabled() below.
|
|
4
1
|
const STYLE_NAMES = ['bold', 'dim', 'red', 'green', 'yellow', 'cyan'];
|
|
5
|
-
// SGR open/close codes. Bold and dim share the 22 reset; colors share 39, so a
|
|
6
|
-
// nested inner style restores exactly its own attribute without clearing the
|
|
7
|
-
// outer one.
|
|
8
2
|
const OPEN = {
|
|
9
3
|
bold: '\x1b[1m',
|
|
10
4
|
dim: '\x1b[2m',
|
|
@@ -33,8 +27,6 @@ function colorEnabled() {
|
|
|
33
27
|
function applyStyle(name, text) {
|
|
34
28
|
const open = OPEN[name];
|
|
35
29
|
const close = CLOSE[name];
|
|
36
|
-
// Re-open this style after any inner close of the same code, so a nested
|
|
37
|
-
// style (e.g. chalk.red(`a ${chalk.bold('b')} c`)) doesn't terminate it early.
|
|
38
30
|
const body = text.includes(close) ? text.split(close).join(close + open) : text;
|
|
39
31
|
return open + body + close;
|
|
40
32
|
}
|
|
@@ -43,7 +35,6 @@ function createStyler(styles) {
|
|
|
43
35
|
const value = String(text);
|
|
44
36
|
if (!colorEnabled() || styles.length === 0)
|
|
45
37
|
return value;
|
|
46
|
-
// Apply right-to-left so the first style in the chain is outermost.
|
|
47
38
|
return styles.reduceRight((acc, name) => applyStyle(name, acc), value);
|
|
48
39
|
});
|
|
49
40
|
for (const name of STYLE_NAMES) {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
|
|
3
5
|
const MIME_BY_EXT = {
|
|
4
6
|
'.png': 'image/png',
|
|
@@ -262,3 +264,20 @@ export function writeClipboardText(text, platform = process.platform) {
|
|
|
262
264
|
}
|
|
263
265
|
throw new ClipboardError(`Clipboard text copy is not supported on ${platform}.`, 'NO_TOOL');
|
|
264
266
|
}
|
|
267
|
+
export function loadImageFromFile(filePath) {
|
|
268
|
+
const resolved = path.resolve(filePath);
|
|
269
|
+
if (!existsSync(resolved)) {
|
|
270
|
+
throw new ClipboardError(`Image file not found: ${resolved}`, 'READ_FAILED');
|
|
271
|
+
}
|
|
272
|
+
const stat = statSync(resolved);
|
|
273
|
+
if (stat.size > MAX_IMAGE_SIZE_BYTES) {
|
|
274
|
+
throw new ClipboardError(`Image file exceeds 10MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'READ_FAILED');
|
|
275
|
+
}
|
|
276
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
277
|
+
const mimeType = MIME_BY_EXT[ext];
|
|
278
|
+
if (!mimeType) {
|
|
279
|
+
throw new ClipboardError(`Unsupported image format "${ext}". Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
|
|
280
|
+
}
|
|
281
|
+
const buf = readFileSync(resolved);
|
|
282
|
+
return { base64Data: buf.toString('base64'), mimeType };
|
|
283
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadImageFromFile } from './clipboard.js';
|
|
5
|
+
const EXT = '(?:png|jpe?g|gif|webp)';
|
|
6
|
+
const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
|
|
7
|
+
const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
|
|
8
|
+
const IMAGE_PATH_PATTERN = new RegExp(`"([^"]*\\.${EXT})"` +
|
|
9
|
+
`|'([^']*\\.${EXT})'` +
|
|
10
|
+
`|file://(\\S*\\.${EXT})` +
|
|
11
|
+
`|(${BARE_PATH})`, 'gi');
|
|
12
|
+
function detectImagePaths(input, cwd) {
|
|
13
|
+
const regex = new RegExp(IMAGE_PATH_PATTERN.source, IMAGE_PATH_PATTERN.flags);
|
|
14
|
+
const rawsByPath = new Map();
|
|
15
|
+
let match;
|
|
16
|
+
while ((match = regex.exec(input)) !== null) {
|
|
17
|
+
const raw = match[0];
|
|
18
|
+
if (match[4] != null && raw.includes('://'))
|
|
19
|
+
continue;
|
|
20
|
+
let inner;
|
|
21
|
+
if (match[1] != null || match[2] != null) {
|
|
22
|
+
const quoted = (match[1] ?? match[2]);
|
|
23
|
+
if (/^file:\/\//i.test(quoted)) {
|
|
24
|
+
try {
|
|
25
|
+
inner = fileURLToPath(quoted);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
inner = quoted;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else if (match[3] != null) {
|
|
36
|
+
try {
|
|
37
|
+
inner = fileURLToPath(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
try {
|
|
41
|
+
inner = decodeURIComponent(match[3]);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
inner = match[4].replace(/\\ /g, ' ');
|
|
50
|
+
}
|
|
51
|
+
const resolvedPath = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
|
|
52
|
+
const existing = rawsByPath.get(resolvedPath);
|
|
53
|
+
if (existing) {
|
|
54
|
+
existing.push(raw);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
rawsByPath.set(resolvedPath, [raw]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return rawsByPath;
|
|
61
|
+
}
|
|
62
|
+
export function autoAttachImages(input, cwd, existing = []) {
|
|
63
|
+
const max = 2;
|
|
64
|
+
const rawsByPath = detectImagePaths(input, cwd);
|
|
65
|
+
let sanitizedInput = input;
|
|
66
|
+
const attachments = [];
|
|
67
|
+
const errors = [];
|
|
68
|
+
const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
|
|
69
|
+
for (const [resolvedPath, rawForms] of rawsByPath) {
|
|
70
|
+
if (existing.length + attachments.length >= max)
|
|
71
|
+
break;
|
|
72
|
+
if (!existsSync(resolvedPath))
|
|
73
|
+
continue;
|
|
74
|
+
try {
|
|
75
|
+
const loaded = loadImageFromFile(resolvedPath);
|
|
76
|
+
const idx = maxExistingIndex + attachments.length + 1;
|
|
77
|
+
attachments.push({
|
|
78
|
+
index: idx,
|
|
79
|
+
mimeType: loaded.mimeType,
|
|
80
|
+
base64Data: loaded.base64Data,
|
|
81
|
+
source: 'file',
|
|
82
|
+
filePath: resolvedPath,
|
|
83
|
+
});
|
|
84
|
+
for (const raw of rawForms) {
|
|
85
|
+
sanitizedInput = sanitizedInput.replace(raw, `[Image #${idx}]`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
errors.push(err.message);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { sanitizedInput, attachments, errors };
|
|
93
|
+
}
|
package/dist/src/executor.js
CHANGED
|
@@ -8,10 +8,6 @@ import { ARTIFACT_INSPECT_BLOCK_DIRS, getBlockedArtifactInspectDir, relativeProj
|
|
|
8
8
|
import { emitCommandOutput, isTuiMode } from './runtime-mode.js';
|
|
9
9
|
const requireFromHere = createRequire(import.meta.url);
|
|
10
10
|
let nodePtyCache;
|
|
11
|
-
// @lydell/node-pty ships its native binding as platform-specific optional
|
|
12
|
-
// packages. If none is installed for this OS/arch the require throws; we cache
|
|
13
|
-
// the failure and fall back to the non-interactive child_process path. A pty is
|
|
14
|
-
// only needed to answer interactive sudo prompts.
|
|
15
11
|
function loadNodePty() {
|
|
16
12
|
if (nodePtyCache !== undefined)
|
|
17
13
|
return nodePtyCache;
|
|
@@ -797,9 +793,6 @@ export async function runCommand(command, cwd, { requestSudoPassword, timeout, }
|
|
|
797
793
|
if (nodePty) {
|
|
798
794
|
return runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword, nodePty);
|
|
799
795
|
}
|
|
800
|
-
// No pty binding installed for this platform — fall through to the
|
|
801
|
-
// non-interactive spawn path. The command still runs; an interactive sudo
|
|
802
|
-
// prompt simply can't be answered here.
|
|
803
796
|
}
|
|
804
797
|
return new Promise((resolve) => {
|
|
805
798
|
let stdout = '';
|
package/dist/src/help-text.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import chalk from './colors.js';
|
|
2
2
|
import { getCliVersion, getPlatformTag } from './version.js';
|
|
3
|
-
// The bound keys (Enter/Esc/Ctrl+C/Tab/arrows) are identical across platforms in
|
|
4
|
-
// a terminal. The one thing that genuinely differs is the terminal's paste
|
|
5
|
-
// shortcut, so surface the one for the host OS (right-click paste works
|
|
6
|
-
// everywhere regardless).
|
|
7
3
|
function pasteShortcutForPlatform() {
|
|
8
4
|
switch (process.platform) {
|
|
9
5
|
case 'darwin':
|
|
@@ -104,7 +100,6 @@ const HELP_MARKDOWN = [
|
|
|
104
100
|
' message — there is no client-side debug mode by design.',
|
|
105
101
|
].join('\n');
|
|
106
102
|
export function formatAboutCard() {
|
|
107
|
-
// Fenced so the column alignment survives terminal markdown rendering.
|
|
108
103
|
return [
|
|
109
104
|
'```',
|
|
110
105
|
'TheGitAI',
|
package/dist/src/patcher.js
CHANGED
|
@@ -162,7 +162,6 @@ export function writeProjectFile(rootDir, filePath, content) {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
catch {
|
|
165
|
-
// If we can't read it for some reason, proceed with write
|
|
166
165
|
}
|
|
167
166
|
}
|
|
168
167
|
mkdirSync(path.dirname(absPath), { recursive: true });
|
|
@@ -179,7 +178,6 @@ export function writeProjectFileBuffer(rootDir, filePath, content) {
|
|
|
179
178
|
}
|
|
180
179
|
}
|
|
181
180
|
catch {
|
|
182
|
-
// If we can't read it for some reason, proceed with write
|
|
183
181
|
}
|
|
184
182
|
}
|
|
185
183
|
mkdirSync(path.dirname(absPath), { recursive: true });
|
package/dist/src/scanner.js
CHANGED
|
@@ -42,12 +42,6 @@ function isFallbackIgnoredFile(relPath, fileName) {
|
|
|
42
42
|
return true;
|
|
43
43
|
return FALLBACK_LOCKFILES.has(fileName);
|
|
44
44
|
}
|
|
45
|
-
// Local stand-in for the previous glob('**/*', { nodir: true, dot: false,
|
|
46
|
-
// ignore: FALLBACK_IGNORE }) call, used only when `git ls-files` fails (e.g. a
|
|
47
|
-
// non-git directory). Walks the tree depth-first, skips dotfiles and
|
|
48
|
-
// dot-directories (glob's dot: false), prunes ignored artifact dirs, and drops
|
|
49
|
-
// lockfiles plus sensitive/ignored paths — reproducing the old fallback without
|
|
50
|
-
// the glob dependency.
|
|
51
45
|
function walkProjectFilesFallback(rootDir, limit) {
|
|
52
46
|
const results = [];
|
|
53
47
|
const visit = (relDir) => {
|
|
@@ -70,9 +64,6 @@ function walkProjectFilesFallback(rootDir, limit) {
|
|
|
70
64
|
continue;
|
|
71
65
|
const relPath = relDir ? `${relDir}/${name}` : name;
|
|
72
66
|
if (entry.isDirectory()) {
|
|
73
|
-
// Only the prefix-based artifact rule is safe to prune a directory by;
|
|
74
|
-
// the sensitive-basename check must stay at the file level so a dir
|
|
75
|
-
// merely named e.g. `secret` doesn't hide non-sensitive files under it.
|
|
76
67
|
if (ALWAYS_IGNORE_DIRS.has(name) || shouldIgnoreArtifactPath(relPath)) {
|
|
77
68
|
continue;
|
|
78
69
|
}
|
|
@@ -6,11 +6,7 @@ const PRIVATE_KEY_REDACTION = '[REDACTED: private key]';
|
|
|
6
6
|
const SENSITIVE_JSON_KEY_PATTERN = /^(?:private[_-]?key|secret|api[_-]?key|password|client_secret|refresh_token|access_token|id_token|auth_provider_x509_cert_url)$/i;
|
|
7
7
|
const PEM_BLOCK_PATTERN = /-----BEGIN [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)-----/gi;
|
|
8
8
|
const PEM_SECRET_PATH_PATTERN = /\.(?:pem|key)$/i;
|
|
9
|
-
// Password embedded in a connection-string URL, e.g.
|
|
10
|
-
// `postgresql://user:PASS@host`. Redacted from shell output so secrets in
|
|
11
|
-
// commands like `cat .env` do not leak into history or telemetry.
|
|
12
9
|
const URL_CREDENTIALS_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi;
|
|
13
|
-
/** Redact only userinfo passwords in connection-string URLs (zero false positives). */
|
|
14
10
|
export function redactConnectionStringCredentials(text) {
|
|
15
11
|
return text.replace(URL_CREDENTIALS_PATTERN, (_match, prefix, _password, at) => `${prefix}${VALUE_REDACTION}${at}`);
|
|
16
12
|
}
|
|
@@ -72,12 +68,6 @@ export function isDotenvLikePath(value) {
|
|
|
72
68
|
const base = path.posix.basename(text.replace(/\\/g, '/'));
|
|
73
69
|
return DOTENV_BASENAME_PATTERN.test(base);
|
|
74
70
|
}
|
|
75
|
-
/**
|
|
76
|
-
* True only for a clean dotenv file we can safely show with keys visible and
|
|
77
|
-
* values tokenized: no PEM block, not JSON, and every non-blank/non-comment line
|
|
78
|
-
* is a `KEY=VALUE` assignment. Anything ambiguous (a stray line that might be a
|
|
79
|
-
* raw secret) returns false so the caller keeps the opaque blackout instead.
|
|
80
|
-
*/
|
|
81
71
|
export function looksLikeEditableDotenv(content) {
|
|
82
72
|
PEM_BLOCK_PATTERN.lastIndex = 0;
|
|
83
73
|
if (PEM_BLOCK_PATTERN.test(content))
|
|
@@ -454,17 +454,6 @@ export function resolveRedactionTokens(state, text, filePath, hash) {
|
|
|
454
454
|
}
|
|
455
455
|
const DOTENV_ASSIGNMENT_PATTERN = /^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=)(.*)$/;
|
|
456
456
|
const DOTENV_COMMENT_PATTERN = /^(\s*#\s*)(\S.*)$/;
|
|
457
|
-
/**
|
|
458
|
-
* Redact a dotenv file's values while leaving keys visible. Every assignment's
|
|
459
|
-
* value is replaced with a stable, reversible token so the agent can see the
|
|
460
|
-
* file's structure and edit it (remove or replace lines) without ever seeing a
|
|
461
|
-
* secret value; `resolveRedactionTokens` swaps the real values back on write.
|
|
462
|
-
* Comment bodies are tokenized too, because developers routinely leave
|
|
463
|
-
* commented-out credentials in dotenv files and those must not leak where the
|
|
464
|
-
* opaque preview would have hidden them. Callers must confirm the content is
|
|
465
|
-
* clean dotenv (`looksLikeEditableDotenv`) first so the only non-assignment
|
|
466
|
-
* lines reaching here are blanks and comments.
|
|
467
|
-
*/
|
|
468
457
|
export function redactDotenvWithStableTokens(state, content, filePath, hash) {
|
|
469
458
|
const tokens = [];
|
|
470
459
|
const redactedLines = content.split('\n').map((line) => {
|
|
@@ -490,14 +479,6 @@ export function redactDotenvWithStableTokens(state, content, filePath, hash) {
|
|
|
490
479
|
});
|
|
491
480
|
return { content: redactedLines.join('\n'), tokens };
|
|
492
481
|
}
|
|
493
|
-
/**
|
|
494
|
-
* The redaction-token registry is capped at `MAX_REDACTION_TOKENS`; a read that
|
|
495
|
-
* emits more tokens than that would evict its own oldest tokens, leaving
|
|
496
|
-
* `[REDACTED:n]` markers in the preview that `write_file`/`str_replace` can no
|
|
497
|
-
* longer resolve (silently writing the literal token back). So a dotenv file
|
|
498
|
-
* with more tokenizable lines than the budget must not use the editable preview
|
|
499
|
-
* — the caller falls back to the opaque blackout instead.
|
|
500
|
-
*/
|
|
501
482
|
export function dotenvFitsRedactionBudget(content) {
|
|
502
483
|
let count = 0;
|
|
503
484
|
for (const line of content.split('\n')) {
|
|
@@ -122,7 +122,6 @@ function loadAllSnapshots(rootDir, env = process.env) {
|
|
|
122
122
|
snapshots.push(loadSnapshotFile(filePath, rootDir));
|
|
123
123
|
}
|
|
124
124
|
catch {
|
|
125
|
-
// Skip corrupted snapshots silently — customers have no actionable debug path here.
|
|
126
125
|
}
|
|
127
126
|
}
|
|
128
127
|
return snapshots.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
@@ -46,10 +46,6 @@ function getEditToolFilePath(call) {
|
|
|
46
46
|
}
|
|
47
47
|
return '';
|
|
48
48
|
}
|
|
49
|
-
// replace_document_text repairs its source filePath but writes a separate
|
|
50
|
-
// outputPath verbatim (a write target must not be fold-matched onto a different
|
|
51
|
-
// existing file). When an outputPath is given, the snapshot path it returns is
|
|
52
|
-
// that raw output, so it must not be repaired.
|
|
53
49
|
function editToolWritesSeparateOutput(call) {
|
|
54
50
|
if (call.name !== 'replace_document_text')
|
|
55
51
|
return false;
|
|
@@ -131,13 +127,6 @@ export async function executeLocalToolCall(toolContext, session, call) {
|
|
|
131
127
|
session.onToolEvent?.({ call, result });
|
|
132
128
|
return result;
|
|
133
129
|
}
|
|
134
|
-
// Snapshot the real file the edit tool will touch. Tools that repair their
|
|
135
|
-
// path internally (str_replace/patch_file/replace_document_text) must be
|
|
136
|
-
// snapshotted against the repaired path, or the pre-edit snapshot targets
|
|
137
|
-
// the unrepaired path and the edit is journaled as a `create` and undone by
|
|
138
|
-
// deleting the user's file. write_file/delete_file consume the raw path, so
|
|
139
|
-
// repairing their snapshot would instead journal a phantom edit of a
|
|
140
|
-
// different file — keep them on the raw path.
|
|
141
130
|
const rawEditFilePath = isEditToolName(call.name)
|
|
142
131
|
? getEditToolFilePath(call)
|
|
143
132
|
: '';
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { isSensitiveProjectPath, shouldIgnoreArtifactPath, } 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
4
|
function foldName(name) {
|
|
8
5
|
return name
|
|
9
6
|
.normalize('NFC')
|
|
@@ -46,9 +43,6 @@ export function suggestClosestPath(rootDir, missingPath) {
|
|
|
46
43
|
let best = null;
|
|
47
44
|
let bestDistance = Number.POSITIVE_INFINITY;
|
|
48
45
|
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
46
|
const candidateRelative = path.relative(rootDir, path.join(directory, candidate));
|
|
53
47
|
if (isSensitiveProjectPath(candidateRelative))
|
|
54
48
|
continue;
|
|
@@ -64,10 +58,6 @@ export function suggestClosestPath(rootDir, missingPath) {
|
|
|
64
58
|
const relative = path.relative(rootDir, suggested);
|
|
65
59
|
return relative && !relative.startsWith('..') ? relative : suggested;
|
|
66
60
|
}
|
|
67
|
-
// Fold only the punctuation/whitespace a model routinely alters when it echoes
|
|
68
|
-
// a filename — a curly apostrophe ’ flattened to a straight ', smart double
|
|
69
|
-
// quotes, and a non-breaking space — WITHOUT touching case, so a path is only
|
|
70
|
-
// auto-corrected when nothing but this punctuation differs from a real file.
|
|
71
61
|
function foldPunctuation(name) {
|
|
72
62
|
return name
|
|
73
63
|
.normalize('NFC')
|
|
@@ -75,9 +65,6 @@ function foldPunctuation(name) {
|
|
|
75
65
|
.replace(/[“”]/g, '"')
|
|
76
66
|
.replace(/ /g, ' ');
|
|
77
67
|
}
|
|
78
|
-
// Strip ONE matched pair of surrounding quotes. A path pasted from a file
|
|
79
|
-
// manager's "Copy as path" or dragged into a terminal arrives wrapped in
|
|
80
|
-
// '…' / "…", and that wrapping is captured verbatim as part of the filename.
|
|
81
68
|
function stripSurroundingQuotes(p) {
|
|
82
69
|
if (p.length >= 2) {
|
|
83
70
|
const first = p[0];
|
|
@@ -88,9 +75,6 @@ function stripSurroundingQuotes(p) {
|
|
|
88
75
|
}
|
|
89
76
|
return p;
|
|
90
77
|
}
|
|
91
|
-
// A single backslash is a legal filename byte on POSIX, but models routinely
|
|
92
|
-
// double it (it is JSON's escape character) when echoing a name, turning
|
|
93
|
-
// `back\slash.js` into `back\\slash.js`. Collapse doubled backslashes to one.
|
|
94
78
|
function collapseDoubledBackslashes(p) {
|
|
95
79
|
return p.replace(/\\\\/g, '\\');
|
|
96
80
|
}
|
|
@@ -105,36 +89,17 @@ function existsAgainst(rootDir, p) {
|
|
|
105
89
|
return false;
|
|
106
90
|
}
|
|
107
91
|
}
|
|
108
|
-
// Repair must never resolve a protected file (a secret like `.env`/credentials).
|
|
109
|
-
// Repair only runs when the literal path is missing, so without this a quoted or
|
|
110
|
-
// curly-flattened secret path — which used to fail as not-found — would be
|
|
111
|
-
// silently resolved to the real secret, bypassing the redaction that keys off
|
|
112
|
-
// the original tool-call args (e.g. read_document, str_replace, patch_file).
|
|
113
92
|
function isProtectedRepairTarget(rootDir, candidate) {
|
|
114
93
|
const rel = path.relative(rootDir, resolveAgainst(rootDir, candidate));
|
|
115
94
|
const projectPath = rel && !rel.startsWith('..') ? rel : candidate;
|
|
116
95
|
return (isSensitiveProjectPath(projectPath) ||
|
|
117
96
|
(rel !== '' && !rel.startsWith('..') && shouldIgnoreArtifactPath(rel)));
|
|
118
97
|
}
|
|
119
|
-
// Edit tools that call repairFilePath on their path argument internally. The
|
|
120
|
-
// executor repairs the pre-edit snapshot path only for these, so its snapshot
|
|
121
|
-
// targets the same file the tool writes; write_file/delete_file consume the raw
|
|
122
|
-
// path, so their snapshot must too.
|
|
123
98
|
export const PATH_REPAIRING_EDIT_TOOLS = new Set([
|
|
124
99
|
'str_replace',
|
|
125
100
|
'patch_file',
|
|
126
101
|
'replace_document_text',
|
|
127
102
|
]);
|
|
128
|
-
// Repair a model/user-supplied path to a real on-disk file WITHOUT changing
|
|
129
|
-
// intent, for tools that act on a file expected to already exist. Literal
|
|
130
|
-
// first: any path that already resolves — including one that legitimately
|
|
131
|
-
// contains quotes or backslashes — is returned untouched. Only when the path
|
|
132
|
-
// does not resolve do we try safe de-manglings: strip surrounding quotes,
|
|
133
|
-
// collapse doubled backslashes, and finally match a directory entry that
|
|
134
|
-
// differs only by foldable punctuation (the "model flattened a curly ’ to a
|
|
135
|
-
// straight '" case, which no transform of the input can reproduce). Returns the
|
|
136
|
-
// input unchanged when nothing better exists, so the caller's normal not-found
|
|
137
|
-
// handling (and its recovery hint) still fires.
|
|
138
103
|
export function repairFilePath(rootDir, raw) {
|
|
139
104
|
if (!raw || existsAgainst(rootDir, raw))
|
|
140
105
|
return raw;
|
|
@@ -164,12 +129,10 @@ export function repairFilePath(rootDir, raw) {
|
|
|
164
129
|
}
|
|
165
130
|
const matches = entries.filter((entry) => foldPunctuation(entry) === wanted);
|
|
166
131
|
if (matches.length !== 1)
|
|
167
|
-
return raw;
|
|
132
|
+
return raw;
|
|
168
133
|
const matchedAbs = path.join(directory, matches[0]);
|
|
169
134
|
const matchedRel = path.relative(rootDir, matchedAbs);
|
|
170
135
|
const matched = path.isAbsolute(dequoted) ? matchedAbs : matchedRel || matchedAbs;
|
|
171
|
-
// Never auto-resolve into a protected file: a fold-match that lands on `.env`
|
|
172
|
-
// would confirm its existence to the model and bypass redaction.
|
|
173
136
|
if (isProtectedRepairTarget(rootDir, matched))
|
|
174
137
|
return raw;
|
|
175
138
|
return matched;
|
|
@@ -68,10 +68,6 @@ export async function readFile(context, args) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
const previewPath = projectPath ?? filePath;
|
|
71
|
-
// A clean dotenv file is shown with keys visible and values tokenized so the
|
|
72
|
-
// agent can still edit it (str_replace/write_file round-trip the tokens) and
|
|
73
|
-
// read coverage is recorded. Any other secret file — PEM, JSON credentials,
|
|
74
|
-
// or a dotenv with a stray non-assignment line — keeps the opaque blackout.
|
|
75
71
|
const editableDotenv = Boolean(projectPath) &&
|
|
76
72
|
Boolean(safety) &&
|
|
77
73
|
isDotenvLikePath(previewPath) &&
|
|
@@ -95,10 +95,6 @@ export async function replaceDocumentText(context, args) {
|
|
|
95
95
|
failureCategory: 'invalid_argument',
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
|
-
// outputPath is a write target, not an existing input, so it must NOT be
|
|
99
|
-
// path-repaired: fold-match could redirect a "create Review '24.docx" onto an
|
|
100
|
-
// existing Review ’24.docx and overwrite it. The executor mirrors this by not
|
|
101
|
-
// repairing the snapshot path when an outputPath is present.
|
|
102
98
|
const outputRaw = String(args.outputPath ?? args.output_path ?? '').trim();
|
|
103
99
|
const targetPath = outputRaw
|
|
104
100
|
? relativeEditablePath(context.rootDir, outputRaw)
|
|
@@ -153,9 +149,6 @@ export async function replaceDocumentText(context, args) {
|
|
|
153
149
|
failureCategory: serverResult.failureCategory ?? 'external_service',
|
|
154
150
|
};
|
|
155
151
|
}
|
|
156
|
-
// Validate-only: report per-replacement match info without touching the file.
|
|
157
|
-
// changed:false marks it non-mutating so the agent loop does not count a
|
|
158
|
-
// dry-run as an applied edit.
|
|
159
152
|
if (validateOnly) {
|
|
160
153
|
return {
|
|
161
154
|
ok: true,
|
|
@@ -166,8 +159,6 @@ export async function replaceDocumentText(context, args) {
|
|
|
166
159
|
results: serverResult.results,
|
|
167
160
|
};
|
|
168
161
|
}
|
|
169
|
-
// No replacement matched: nothing was written. Surface per-item reasons so
|
|
170
|
-
// the model can correct and resend only the failing entries.
|
|
171
162
|
const replacementCount = Number(serverResult.replacementCount ?? 0);
|
|
172
163
|
if (replacementCount === 0) {
|
|
173
164
|
const failures = Array.isArray(serverResult.replacements)
|
|
@@ -224,9 +215,6 @@ export async function replaceDocumentText(context, args) {
|
|
|
224
215
|
failedCount: serverResult.failedCount,
|
|
225
216
|
replacements: serverResult.replacements,
|
|
226
217
|
bytesWritten: nextData.length,
|
|
227
|
-
// A partial batch still wrote the matched entries (changed:true above), but
|
|
228
|
-
// the loop must reflect and repair the missed entries — needsRepair forces
|
|
229
|
-
// that without losing credit for the applied edits.
|
|
230
218
|
...(failedCount > 0
|
|
231
219
|
? {
|
|
232
220
|
needsRepair: true,
|
|
@@ -67,8 +67,6 @@ export async function runShellCommand(context, args) {
|
|
|
67
67
|
if (repoSync.added || repoSync.modified || repoSync.removed) {
|
|
68
68
|
onStatus(`Synced repo state after command (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
|
|
69
69
|
}
|
|
70
|
-
// Redact connection-string passwords so shell output (e.g. `cat .env`,
|
|
71
|
-
// `printenv`) cannot leak them into history or telemetry.
|
|
72
70
|
let output = typeof result.output === 'string'
|
|
73
71
|
? redactConnectionStringCredentials(result.output)
|
|
74
72
|
: result.output;
|
|
@@ -6,12 +6,6 @@ import { addSignatureForNode } from './extractors/index.js';
|
|
|
6
6
|
import { getRepoMapLanguageForFile, } from './repo-map-languages.js';
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
-
// web-tree-sitter is vendored (see vendor/web-tree-sitter/NOTICE) so the
|
|
10
|
-
// published package has zero runtime dependencies. Resolve the vendored CommonJS
|
|
11
|
-
// runtime relative to this compiled file: dist/src/ -> dist/vendor in the
|
|
12
|
-
// published layout, with the source tree as a dev fallback. The .cjs locates its
|
|
13
|
-
// own web-tree-sitter.wasm next to itself via __dirname, so no locateFile
|
|
14
|
-
// override is needed.
|
|
15
9
|
function resolveVendoredTreeSitter() {
|
|
16
10
|
const candidates = [
|
|
17
11
|
path.resolve(__dirname, '..', 'vendor', 'web-tree-sitter', 'web-tree-sitter.cjs'),
|
package/dist/src/ui/repl.js
CHANGED
|
@@ -998,8 +998,6 @@ export function getInputCommandToken(input) {
|
|
|
998
998
|
return '';
|
|
999
999
|
const firstSpaceIndex = trimmed.indexOf(' ');
|
|
1000
1000
|
const token = firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
|
|
1001
|
-
// A token with a second '/' is a filesystem path (e.g. /home/user/repo),
|
|
1002
|
-
// not a slash command — no command contains a slash, so don't treat it as one.
|
|
1003
1001
|
if (token.indexOf('/', 1) !== -1)
|
|
1004
1002
|
return '';
|
|
1005
1003
|
return token;
|
|
@@ -1012,9 +1010,6 @@ function shouldShowCommandPalette(state) {
|
|
|
1012
1010
|
!state.resumePickerOpen &&
|
|
1013
1011
|
trimmed.startsWith('/') &&
|
|
1014
1012
|
!trimmed.includes(' ') &&
|
|
1015
|
-
// A '/'-prefixed token with a second '/' is a filesystem path, not a
|
|
1016
|
-
// command — getInputCommandToken returns '' for it, so the palette stays
|
|
1017
|
-
// closed when a folder path is pasted.
|
|
1018
1013
|
getInputCommandToken(trimmed) !== '');
|
|
1019
1014
|
}
|
|
1020
1015
|
export function shouldRemountLiveFrameForComposerInputChange(current, nextInput) {
|
|
@@ -1405,9 +1400,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1405
1400
|
activeTurnAbort?.abort();
|
|
1406
1401
|
activeTurnAbort = null;
|
|
1407
1402
|
cancelActiveCommand();
|
|
1408
|
-
// Ctrl+C with a queued message: recall it into the composer for editing
|
|
1409
|
-
// rather than auto-submitting it against the cancelled turn. (Esc with a
|
|
1410
|
-
// queued message clears the slot in shell-input without cancelling.)
|
|
1411
1403
|
const queued = store.getState().queuedMessage;
|
|
1412
1404
|
const cancelledEntries = [
|
|
1413
1405
|
...takePendingTurnEntries(),
|
|
@@ -1811,8 +1803,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1811
1803
|
if (!queued || store.getState().busy || exiting) {
|
|
1812
1804
|
return;
|
|
1813
1805
|
}
|
|
1814
|
-
// Rehydrate attachments/chunks before submit: handleSubmit expands
|
|
1815
|
-
// pastedChunks from the store and the turn picks up imageAttachments.
|
|
1816
1806
|
store.update((current) => ({
|
|
1817
1807
|
...current,
|
|
1818
1808
|
imageAttachments: queued.imageAttachments,
|
|
@@ -1839,9 +1829,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1839
1829
|
});
|
|
1840
1830
|
return;
|
|
1841
1831
|
}
|
|
1842
|
-
// Snapshot the raw (placeholder) body plus chunks/images so recall and
|
|
1843
|
-
// flush round-trip the collapsed paste + attachments. Hold at most one;
|
|
1844
|
-
// a second enqueue replaces the slot.
|
|
1845
1832
|
const pending = store.getState();
|
|
1846
1833
|
const snapshot = {
|
|
1847
1834
|
body: String(rawInput ?? ''),
|
|
@@ -2094,8 +2081,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2094
2081
|
appendError(error.message);
|
|
2095
2082
|
}
|
|
2096
2083
|
await remountTui();
|
|
2097
|
-
// A cancelled turn never auto-submits the queue (Ctrl+C recalls it via
|
|
2098
|
-
// cancelActiveTurn); only a genuine error flushes a pending message.
|
|
2099
2084
|
if (!cancelled && turnGeneration === activeTurnGeneration) {
|
|
2100
2085
|
await flushQueuedMessage();
|
|
2101
2086
|
}
|
|
@@ -91,14 +91,11 @@ function normalizeChildMessage(raw) {
|
|
|
91
91
|
export function resolveTuiBinaryPath() {
|
|
92
92
|
const binaryName = process.platform === 'win32' ? 'thegitai-tui.exe' : 'thegitai-tui';
|
|
93
93
|
const platformPackage = `@thegitai/tui-${process.platform}-${process.arch}`;
|
|
94
|
-
// 1) Published per-platform optional dependency (the installed-from-npm path).
|
|
95
94
|
try {
|
|
96
95
|
return requireFromHere.resolve(`${platformPackage}/${binaryName}`);
|
|
97
96
|
}
|
|
98
97
|
catch {
|
|
99
|
-
// Not installed (unsupported platform yet, or local dev) — fall through.
|
|
100
98
|
}
|
|
101
|
-
// 2) Local dev build: `npm run build:tui` populates the workspace bin/.
|
|
102
99
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
103
100
|
const devCandidates = [
|
|
104
101
|
path.join(moduleDir, '../../../bin', binaryName),
|
|
@@ -137,7 +134,6 @@ export function createRatatuiBridge() {
|
|
|
137
134
|
}
|
|
138
135
|
}
|
|
139
136
|
catch {
|
|
140
|
-
// ignore malformed protocol lines
|
|
141
137
|
}
|
|
142
138
|
});
|
|
143
139
|
child.on('exit', () => {
|
|
@@ -131,9 +131,6 @@ function diffLinePrefix(kind) {
|
|
|
131
131
|
return ' ';
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
-
// Fit a single diff line to the available terminal width with a clean ellipsis.
|
|
135
|
-
// Unlike the general-purpose truncate(), this never appends a word like
|
|
136
|
-
// "(truncated)" — that wording belongs on tool output, not on-screen diff rows.
|
|
137
134
|
function fitDiffLine(content, maxWidth) {
|
|
138
135
|
if (maxWidth <= 0)
|
|
139
136
|
return '';
|
|
@@ -178,8 +175,6 @@ function getInputCommandToken(input) {
|
|
|
178
175
|
const trimmed = String(input ?? '').trimStart();
|
|
179
176
|
const match = trimmed.match(/^\/[^\s]*/);
|
|
180
177
|
const token = match?.[0] ?? '';
|
|
181
|
-
// A token with a second '/' is a filesystem path (e.g. /home/user/repo),
|
|
182
|
-
// not a slash command — no command contains a slash, so don't treat it as one.
|
|
183
178
|
if (token.indexOf('/', 1) !== -1)
|
|
184
179
|
return '';
|
|
185
180
|
return token;
|
|
@@ -689,9 +684,6 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
|
|
|
689
684
|
sections.push({ kind: 'live', lines: liveLines });
|
|
690
685
|
}
|
|
691
686
|
const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
|
|
692
|
-
// Composer stays visible while busy unless a blocking overlay is active. When
|
|
693
|
-
// one message is already queued, the queued chip is shown here in place of the
|
|
694
|
-
// input box (only one message is ever held) so there is no empty prompt.
|
|
695
687
|
if (!state.resumePickerOpen && !state.modelPickerOpen && !overlayActive) {
|
|
696
688
|
const composerLines = [];
|
|
697
689
|
if (state.queuedMessage) {
|
|
@@ -275,8 +275,6 @@ export function handleShellKeyEvent(store, handlers, event) {
|
|
|
275
275
|
}
|
|
276
276
|
if (key.escape) {
|
|
277
277
|
if (state.busy) {
|
|
278
|
-
// With a queued message, Esc clears the queue only (turn keeps running).
|
|
279
|
-
// With nothing queued, Esc cancels the turn (Ctrl+C also cancels).
|
|
280
278
|
if (state.queuedMessage) {
|
|
281
279
|
handlers.onLiveFrameShapeChange();
|
|
282
280
|
store.update((current) => ({ ...current, queuedMessage: null }));
|
|
@@ -317,8 +315,6 @@ export function handleShellKeyEvent(store, handlers, event) {
|
|
|
317
315
|
return;
|
|
318
316
|
}
|
|
319
317
|
if (key.upArrow) {
|
|
320
|
-
// While busy with an empty composer, Up recalls and dequeues the queued
|
|
321
|
-
// message for editing; otherwise it walks prompt history as usual.
|
|
322
318
|
if (state.busy && state.input.trim() === '' && state.queuedMessage) {
|
|
323
319
|
handlers.onLiveFrameShapeChange();
|
|
324
320
|
store.update((current) => {
|
package/dist/src/version.js
CHANGED
|
@@ -3,11 +3,6 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
const PACKAGE_NAME = '@thegitai/cli';
|
|
5
5
|
const UNKNOWN_VERSION = '0.0.0';
|
|
6
|
-
// Resolve the package version at runtime by walking up from this module to the
|
|
7
|
-
// nearest package.json named @thegitai/cli. This works in both layouts: the
|
|
8
|
-
// compiled binary (dist/bin/ai.js → ../../package.json) and the source tree run
|
|
9
|
-
// under tsx in tests (src/version.ts → ../package.json). The name guard avoids
|
|
10
|
-
// picking up an unrelated manifest if the file is ever nested elsewhere.
|
|
11
6
|
export function getCliVersion() {
|
|
12
7
|
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
13
8
|
for (let depth = 0; depth < 6; depth++) {
|
|
@@ -18,7 +13,6 @@ export function getCliVersion() {
|
|
|
18
13
|
}
|
|
19
14
|
}
|
|
20
15
|
catch {
|
|
21
|
-
// No package.json at this level (or unreadable); keep walking up.
|
|
22
16
|
}
|
|
23
17
|
const parent = path.dirname(dir);
|
|
24
18
|
if (parent === dir)
|
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.15",
|
|
4
4
|
"description": "TheGitAI CLI client (source-visible, proprietary)",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://thegit.ai",
|
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
"@lydell/node-pty-linux-x64": "1.1.0",
|
|
26
26
|
"@lydell/node-pty-win32-arm64": "1.1.0",
|
|
27
27
|
"@lydell/node-pty-win32-x64": "1.1.0",
|
|
28
|
-
"@thegitai/tui-darwin-arm64": "1.0.0-beta.
|
|
29
|
-
"@thegitai/tui-darwin-x64": "1.0.0-beta.
|
|
30
|
-
"@thegitai/tui-linux-x64": "1.0.0-beta.
|
|
31
|
-
"@thegitai/tui-win32-x64": "1.0.0-beta.
|
|
28
|
+
"@thegitai/tui-darwin-arm64": "1.0.0-beta.15",
|
|
29
|
+
"@thegitai/tui-darwin-x64": "1.0.0-beta.15",
|
|
30
|
+
"@thegitai/tui-linux-x64": "1.0.0-beta.15",
|
|
31
|
+
"@thegitai/tui-win32-x64": "1.0.0-beta.15",
|
|
32
32
|
"@vscode/ripgrep": "1.18.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|