@thegitai/cli 1.0.0-beta.14 → 1.0.0-beta.16
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 +3 -10
- 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/client-environment.js +2 -0
- package/dist/src/colors.js +0 -9
- package/dist/src/core/image-path-extractor.js +55 -23
- package/dist/src/executor.js +23 -10
- 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/scratch-dir.js +57 -0
- 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 +1 -3
- package/dist/src/tools/run-node-script.js +2 -0
- 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
|
@@ -65,7 +65,9 @@ function snapshotForServer(session) {
|
|
|
65
65
|
return snapshot;
|
|
66
66
|
}
|
|
67
67
|
function imageAttachmentsForServer(attachments) {
|
|
68
|
-
return (attachments ?? []).map(({ filePath
|
|
68
|
+
return (attachments ?? []).map(({ filePath, ...attachment }) => attachment.source === 'file' && filePath
|
|
69
|
+
? { ...attachment, filePath }
|
|
70
|
+
: attachment);
|
|
69
71
|
}
|
|
70
72
|
function userHistoryText(entry) {
|
|
71
73
|
return (entry.parts ?? [])
|
|
@@ -293,12 +295,6 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
293
295
|
const requestImageAttachments = autoAttach.attachments.length > 0
|
|
294
296
|
? [...imageAttachments, ...autoAttach.attachments]
|
|
295
297
|
: imageAttachments;
|
|
296
|
-
// requestInput is the server-bound, path-sanitized text ([Image #N] markers in
|
|
297
|
-
// place of local image paths). Every history-preservation path below (abort,
|
|
298
|
-
// cancel, failure, mid-stream tool cancel) must persist THIS, never the raw
|
|
299
|
-
// input — otherwise a cancelled or failed image turn leaks the local
|
|
300
|
-
// filesystem path into session history and the next snapshot ships it to the
|
|
301
|
-
// server, defeating the point of sanitizing before the request crosses over.
|
|
302
298
|
const requestInput = autoAttach.attachments.length > 0 ? autoAttach.sanitizedInput : input;
|
|
303
299
|
for (const err of autoAttach.errors) {
|
|
304
300
|
session.onStatus(`Image: ${err}`);
|
|
@@ -362,9 +358,6 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
362
358
|
? error
|
|
363
359
|
: new TurnCancelledError();
|
|
364
360
|
}
|
|
365
|
-
// Non-cancel failures (e.g. upstream connection errors) must not leave
|
|
366
|
-
// speculative cancelled-turn entries in history — otherwise the next
|
|
367
|
-
// request replays a malformed transcript to the server.
|
|
368
361
|
session.history.length = preTurnHistoryLength;
|
|
369
362
|
preserveFailedTurnInput(session, requestInput, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
|
|
370
363
|
throw error;
|
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;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { accessSync, constants, readFileSync } from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { ensureSessionScratchDir } from './scratch-dir.js';
|
|
4
5
|
const PACKAGE_MANAGER_CANDIDATES = [
|
|
5
6
|
'apt',
|
|
6
7
|
'apt-get',
|
|
@@ -123,5 +124,6 @@ export function collectClientEnvironment(options = {}) {
|
|
|
123
124
|
shell: detectShell(platform, env),
|
|
124
125
|
...linuxDistro,
|
|
125
126
|
packageManagers: detectPackageManagers(env, platform, executableExists),
|
|
127
|
+
scratchDir: options.scratchDir ?? ensureSessionScratchDir(),
|
|
126
128
|
};
|
|
127
129
|
}
|
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,36 +1,64 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { loadImageFromFile } from './clipboard.js';
|
|
5
5
|
const EXT = '(?:png|jpe?g|gif|webp)';
|
|
6
|
-
// Bare-path characters: exclude whitespace, quotes, prose terminators, ':' (so we
|
|
7
|
-
// don't swallow URL schemes or trailing "see foo.png:" prose) and bracket/brace/paren
|
|
8
|
-
// delimiters on both sides (so markdown "" / "(x.png)" don't leak in).
|
|
9
6
|
const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
|
|
10
|
-
// A bare token may carry backslash-escaped spaces (terminal drag-and-drop) and an
|
|
11
|
-
// optional Windows drive prefix ("C:\\" or "C:/").
|
|
12
7
|
const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
|
|
13
8
|
const IMAGE_PATH_PATTERN = new RegExp(`"([^"]*\\.${EXT})"` +
|
|
14
9
|
`|'([^']*\\.${EXT})'` +
|
|
15
10
|
`|file://(\\S*\\.${EXT})` +
|
|
16
11
|
`|(${BARE_PATH})`, 'gi');
|
|
12
|
+
const EXTENSION_TOKEN = new RegExp(`^(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+$`);
|
|
13
|
+
const MAX_EXTENSION_TOKENS = 8;
|
|
14
|
+
function isFile(p) {
|
|
15
|
+
try {
|
|
16
|
+
return statSync(p, { throwIfNoEntry: false })?.isFile() ?? false;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function extendBareMatchAcrossSpaces(input, matchStart, raw, cwd) {
|
|
23
|
+
const baseInner = raw.replace(/\\ /g, ' ');
|
|
24
|
+
const baseExists = isFile(path.isAbsolute(baseInner) ? baseInner : path.resolve(cwd, baseInner));
|
|
25
|
+
let best = null;
|
|
26
|
+
let candidate = raw;
|
|
27
|
+
let start = matchStart;
|
|
28
|
+
let addedSeparator = false;
|
|
29
|
+
for (let hops = 0; hops < MAX_EXTENSION_TOKENS; hops++) {
|
|
30
|
+
if (start < 2 || input[start - 1] !== ' ' || /\s/.test(input[start - 2]))
|
|
31
|
+
break;
|
|
32
|
+
let i = start - 2;
|
|
33
|
+
while (i >= 0 && !/\s/.test(input[i]))
|
|
34
|
+
i--;
|
|
35
|
+
const tokenStart = i + 1;
|
|
36
|
+
const token = input.slice(tokenStart, start - 1);
|
|
37
|
+
if (!EXTENSION_TOKEN.test(token))
|
|
38
|
+
break;
|
|
39
|
+
candidate = `${token} ${candidate}`;
|
|
40
|
+
start = tokenStart;
|
|
41
|
+
addedSeparator = addedSeparator || /[\\/]/.test(token);
|
|
42
|
+
if (baseExists && !addedSeparator)
|
|
43
|
+
continue;
|
|
44
|
+
const inner = candidate.replace(/\\ /g, ' ');
|
|
45
|
+
const resolved = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
|
|
46
|
+
if (isFile(resolved))
|
|
47
|
+
best = candidate;
|
|
48
|
+
}
|
|
49
|
+
return best ?? raw;
|
|
50
|
+
}
|
|
17
51
|
function detectImagePaths(input, cwd) {
|
|
18
52
|
const regex = new RegExp(IMAGE_PATH_PATTERN.source, IMAGE_PATH_PATTERN.flags);
|
|
19
|
-
const
|
|
53
|
+
const detected = [];
|
|
20
54
|
let match;
|
|
21
55
|
while ((match = regex.exec(input)) !== null) {
|
|
22
|
-
|
|
23
|
-
// A bare match containing "://" is a URL scheme remnant (e.g. the "p://" tail
|
|
24
|
-
// of "http://…" caught by the drive-letter prefix), never a local file path.
|
|
25
|
-
// Scope this to the bare branch only — a quoted value may legitimately be a
|
|
26
|
-
// file:// URL, which the quoted branch below decodes.
|
|
56
|
+
let raw = match[0];
|
|
27
57
|
if (match[4] != null && raw.includes('://'))
|
|
28
58
|
continue;
|
|
29
59
|
let inner;
|
|
30
60
|
if (match[1] != null || match[2] != null) {
|
|
31
61
|
const quoted = (match[1] ?? match[2]);
|
|
32
|
-
// A quoted value can itself be a file:// URL ("file:///a/b%20c.png"); it
|
|
33
|
-
// still needs percent-decoding, so route it through the same decoder.
|
|
34
62
|
if (/^file:\/\//i.test(quoted)) {
|
|
35
63
|
try {
|
|
36
64
|
inner = fileURLToPath(quoted);
|
|
@@ -44,9 +72,6 @@ function detectImagePaths(input, cwd) {
|
|
|
44
72
|
}
|
|
45
73
|
}
|
|
46
74
|
else if (match[3] != null) {
|
|
47
|
-
// file:// URL: decode percent-escapes (e.g. "%20") to the real filesystem
|
|
48
|
-
// path. A malformed URL (bad %-escape) must be skipped, never thrown:
|
|
49
|
-
// ordinary prose containing a broken file:// URL should not abort the turn.
|
|
50
75
|
try {
|
|
51
76
|
inner = fileURLToPath(raw);
|
|
52
77
|
}
|
|
@@ -60,10 +85,21 @@ function detectImagePaths(input, cwd) {
|
|
|
60
85
|
}
|
|
61
86
|
}
|
|
62
87
|
else {
|
|
63
|
-
|
|
64
|
-
inner =
|
|
88
|
+
raw = extendBareMatchAcrossSpaces(input, match.index, raw, cwd);
|
|
89
|
+
inner = raw.replace(/\\ /g, ' ');
|
|
65
90
|
}
|
|
66
91
|
const resolvedPath = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
|
|
92
|
+
const start = match.index - (raw.length - match[0].length);
|
|
93
|
+
while (detected.length > 0) {
|
|
94
|
+
const prev = detected[detected.length - 1];
|
|
95
|
+
if (prev.start + prev.raw.length <= start)
|
|
96
|
+
break;
|
|
97
|
+
detected.pop();
|
|
98
|
+
}
|
|
99
|
+
detected.push({ resolvedPath, raw, start });
|
|
100
|
+
}
|
|
101
|
+
const rawsByPath = new Map();
|
|
102
|
+
for (const { resolvedPath, raw } of detected) {
|
|
67
103
|
const existing = rawsByPath.get(resolvedPath);
|
|
68
104
|
if (existing) {
|
|
69
105
|
existing.push(raw);
|
|
@@ -80,10 +116,6 @@ export function autoAttachImages(input, cwd, existing = []) {
|
|
|
80
116
|
let sanitizedInput = input;
|
|
81
117
|
const attachments = [];
|
|
82
118
|
const errors = [];
|
|
83
|
-
// Seed new markers after the highest existing index, not the existing count.
|
|
84
|
-
// The TUI can submit a sparse attachment set (e.g. [Image #2] kept after
|
|
85
|
-
// [Image #1] was deleted from the prompt), so counting would reuse an index
|
|
86
|
-
// still present in the prompt and make the model inspect the wrong image.
|
|
87
119
|
const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
|
|
88
120
|
for (const [resolvedPath, rawForms] of rawsByPath) {
|
|
89
121
|
if (existing.length + attachments.length >= max)
|
package/dist/src/executor.js
CHANGED
|
@@ -6,12 +6,9 @@ import os from 'os';
|
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { ARTIFACT_INSPECT_BLOCK_DIRS, getBlockedArtifactInspectDir, relativeProjectPath, } from './artifact-policy.js';
|
|
8
8
|
import { emitCommandOutput, isTuiMode } from './runtime-mode.js';
|
|
9
|
+
import { ensureSessionScratchDir, isInsideTheGitAiScratch, sessionScratchDir, } from './scratch-dir.js';
|
|
9
10
|
const requireFromHere = createRequire(import.meta.url);
|
|
10
11
|
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
12
|
function loadNodePty() {
|
|
16
13
|
if (nodePtyCache !== undefined)
|
|
17
14
|
return nodePtyCache;
|
|
@@ -221,6 +218,7 @@ function isExistingDirectory(absPath) {
|
|
|
221
218
|
return false;
|
|
222
219
|
}
|
|
223
220
|
}
|
|
221
|
+
const OS_TEMP_BLOCK_MARKER = 'os-temp:';
|
|
224
222
|
function getBlockedOsTempInspection(rawToken, rootDir) {
|
|
225
223
|
const token = normalizeToken(rawToken);
|
|
226
224
|
if (!token || !path.isAbsolute(token))
|
|
@@ -228,6 +226,8 @@ function getBlockedOsTempInspection(rawToken, rootDir) {
|
|
|
228
226
|
const resolved = path.resolve(token);
|
|
229
227
|
if (!isInsideOsTemp(resolved))
|
|
230
228
|
return null;
|
|
229
|
+
if (isInsideTheGitAiScratch(resolved))
|
|
230
|
+
return null;
|
|
231
231
|
if (rootDir) {
|
|
232
232
|
const resolvedRoot = path.resolve(rootDir);
|
|
233
233
|
if (resolved === resolvedRoot ||
|
|
@@ -238,7 +238,7 @@ function getBlockedOsTempInspection(rawToken, rootDir) {
|
|
|
238
238
|
if (resolved === path.resolve(os.tmpdir()) ||
|
|
239
239
|
hasPathGlob(token) ||
|
|
240
240
|
isExistingDirectory(resolved)) {
|
|
241
|
-
return path.basename(os.tmpdir()) || os.tmpdir()
|
|
241
|
+
return `${OS_TEMP_BLOCK_MARKER}${path.basename(os.tmpdir()) || os.tmpdir()}`;
|
|
242
242
|
}
|
|
243
243
|
return null;
|
|
244
244
|
}
|
|
@@ -254,6 +254,9 @@ function findBlockedDirForToken(rawToken, rootDir, baseDir, allowBareDirMatch =
|
|
|
254
254
|
const resolved = path.isAbsolute(token)
|
|
255
255
|
? path.resolve(token)
|
|
256
256
|
: path.resolve(baseDir ?? rootDir, token);
|
|
257
|
+
const osTempResolved = getBlockedOsTempInspection(resolved, rootDir);
|
|
258
|
+
if (osTempResolved)
|
|
259
|
+
return osTempResolved;
|
|
257
260
|
return getBlockedProjectPathDir(resolved, rootDir);
|
|
258
261
|
}
|
|
259
262
|
if (!allowBareDirMatch || !BLOCKED_PATH_INSPECT_DIRS.has(token)) {
|
|
@@ -390,11 +393,16 @@ function findBlockedDirInCommandTokens(command, rootDir, baseDir) {
|
|
|
390
393
|
}
|
|
391
394
|
return null;
|
|
392
395
|
}
|
|
396
|
+
function expandTempEnvRefs(command) {
|
|
397
|
+
return command
|
|
398
|
+
.replace(/\$\{THEGITAI_SCRATCH_DIR[^}]*\}|\$THEGITAI_SCRATCH_DIR\b/g, sessionScratchDir())
|
|
399
|
+
.replace(/\$\{TMPDIR[^}]*\}|\$TMPDIR\b/g, process.env.TMPDIR || os.tmpdir());
|
|
400
|
+
}
|
|
393
401
|
function findIgnoredPathInspection(command, rootDir) {
|
|
394
402
|
if (!FILE_INSPECTION_COMMAND_PATTERN.test(command)) {
|
|
395
403
|
return null;
|
|
396
404
|
}
|
|
397
|
-
const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(command));
|
|
405
|
+
const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(expandTempEnvRefs(command)));
|
|
398
406
|
const baseDir = getCommandBaseDir(haystack, rootDir);
|
|
399
407
|
const blockedDir = findBlockedDirInCommandTokens(haystack, rootDir, baseDir);
|
|
400
408
|
if (blockedDir) {
|
|
@@ -554,10 +562,17 @@ function terminateChild(child, signal) {
|
|
|
554
562
|
}
|
|
555
563
|
}
|
|
556
564
|
export function getBlockedPathInspectDir(command, rootDir) {
|
|
557
|
-
|
|
565
|
+
const blocked = findIgnoredPathInspection(command, rootDir);
|
|
566
|
+
return blocked?.startsWith(OS_TEMP_BLOCK_MARKER)
|
|
567
|
+
? blocked.slice(OS_TEMP_BLOCK_MARKER.length)
|
|
568
|
+
: blocked;
|
|
558
569
|
}
|
|
559
570
|
export function getBlockedCommandReason(command, hasTimeout, rootDir) {
|
|
560
571
|
const ignoredDir = findIgnoredPathInspection(command, rootDir);
|
|
572
|
+
if (ignoredDir?.startsWith(OS_TEMP_BLOCK_MARKER)) {
|
|
573
|
+
const tempName = ignoredDir.slice(OS_TEMP_BLOCK_MARKER.length);
|
|
574
|
+
return `Listing or scanning the shared OS temp directory (${tempName}) is blocked because it can contain other users' and processes' files. Use the session scratch directory ${ensureSessionScratchDir()} for temporary scripts and files — creating, running, and listing are all allowed there — or reference an exact file path.`;
|
|
575
|
+
}
|
|
561
576
|
if (ignoredDir) {
|
|
562
577
|
return `Command inspects an off-limits generated or dependency directory (${ignoredDir}). Avoid that path.`;
|
|
563
578
|
}
|
|
@@ -619,6 +634,7 @@ function buildCommandEnv(cwd) {
|
|
|
619
634
|
npm_config_fund: 'false',
|
|
620
635
|
npm_config_audit: 'false',
|
|
621
636
|
NUXI_INIT_SKIP_PROMPT: 'true',
|
|
637
|
+
THEGITAI_SCRATCH_DIR: ensureSessionScratchDir(),
|
|
622
638
|
};
|
|
623
639
|
}
|
|
624
640
|
function sanitizePtyOutput(command, output, cwd, secrets) {
|
|
@@ -797,9 +813,6 @@ export async function runCommand(command, cwd, { requestSudoPassword, timeout, }
|
|
|
797
813
|
if (nodePty) {
|
|
798
814
|
return runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword, nodePty);
|
|
799
815
|
}
|
|
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
816
|
}
|
|
804
817
|
return new Promise((resolve) => {
|
|
805
818
|
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
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync, mkdtempSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
let cachedScratchDir = null;
|
|
5
|
+
export function sessionScratchDir() {
|
|
6
|
+
if (!cachedScratchDir) {
|
|
7
|
+
cachedScratchDir = path.join(os.tmpdir(), `thegitai-${process.pid}`);
|
|
8
|
+
}
|
|
9
|
+
return cachedScratchDir;
|
|
10
|
+
}
|
|
11
|
+
function isSquattedScratchRoot(dir) {
|
|
12
|
+
try {
|
|
13
|
+
const st = lstatSync(dir);
|
|
14
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
15
|
+
return true;
|
|
16
|
+
if (process.platform !== 'win32' &&
|
|
17
|
+
typeof process.getuid === 'function' &&
|
|
18
|
+
st.uid !== process.getuid()) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function ensureSessionScratchDir() {
|
|
28
|
+
const dir = sessionScratchDir();
|
|
29
|
+
try {
|
|
30
|
+
if (isSquattedScratchRoot(dir)) {
|
|
31
|
+
cachedScratchDir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
|
|
32
|
+
return cachedScratchDir;
|
|
33
|
+
}
|
|
34
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
35
|
+
if (process.platform !== 'win32') {
|
|
36
|
+
chmodSync(dir, 0o700);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
}
|
|
41
|
+
return sessionScratchDir();
|
|
42
|
+
}
|
|
43
|
+
export function isInsideTheGitAiScratch(absPath) {
|
|
44
|
+
const tempRoot = path.resolve(os.tmpdir());
|
|
45
|
+
const relPath = path.relative(tempRoot, path.resolve(absPath));
|
|
46
|
+
if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const first = relPath.split(/[\\/]/, 1)[0] ?? '';
|
|
50
|
+
if (!/^thegitai(?:$|[-.])/i.test(first)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (/[*?[\]{}]/.test(first)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return !isSquattedScratchRoot(path.join(tempRoot, first));
|
|
57
|
+
}
|
|
@@ -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,
|
|
@@ -45,7 +45,7 @@ export async function runShellCommand(context, args) {
|
|
|
45
45
|
ok: false,
|
|
46
46
|
skipped: true,
|
|
47
47
|
command,
|
|
48
|
-
error: 'User declined command execution',
|
|
48
|
+
error: 'User declined command execution. Do not rerun this command or try a broader variant of it; either continue without it or ask the user one specific question about how to proceed.',
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -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;
|
|
@@ -3,6 +3,7 @@ import { execFileSync, spawn } from 'node:child_process';
|
|
|
3
3
|
import { syncIndexFromDisk } from '../project-index.js';
|
|
4
4
|
import { isTuiMode } from '../runtime-mode.js';
|
|
5
5
|
import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
|
|
6
|
+
import { ensureSessionScratchDir } from '../scratch-dir.js';
|
|
6
7
|
const DEFAULT_TIMEOUT = 5 * 60 * 1000;
|
|
7
8
|
const MAX_OUTPUT_CHARS = 4000;
|
|
8
9
|
const MAX_CAPTURE_CHARS = 1024 * 1024;
|
|
@@ -99,6 +100,7 @@ function executeNodeScript(rootDir, script, timeout) {
|
|
|
99
100
|
npm_config_progress: 'false',
|
|
100
101
|
npm_config_fund: 'false',
|
|
101
102
|
npm_config_audit: 'false',
|
|
103
|
+
THEGITAI_SCRATCH_DIR: ensureSessionScratchDir(),
|
|
102
104
|
},
|
|
103
105
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
104
106
|
});
|
|
@@ -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.16",
|
|
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.16",
|
|
29
|
+
"@thegitai/tui-darwin-x64": "1.0.0-beta.16",
|
|
30
|
+
"@thegitai/tui-linux-x64": "1.0.0-beta.16",
|
|
31
|
+
"@thegitai/tui-win32-x64": "1.0.0-beta.16",
|
|
32
32
|
"@vscode/ripgrep": "1.18.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|