@thegitai/cli 1.0.0-preview.1 → 1.0.0-preview.10
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/README.md +16 -4
- package/dist/bin/ai.js +57 -287
- package/dist/src/api/auth.js +2 -2
- package/dist/src/api/browser-login.js +72 -3
- package/dist/src/api/chat.js +125 -24
- package/dist/src/api/http.js +16 -3
- package/dist/src/api/models.js +9 -4
- package/dist/src/help-text.js +19 -7
- package/dist/src/patcher.js +96 -9
- package/dist/src/project-index.js +13 -1
- package/dist/src/project-orientation.js +99 -0
- package/dist/src/scratch-dir.js +51 -33
- package/dist/src/session-store.js +52 -20
- package/dist/src/session.js +8 -0
- package/dist/src/tool-executor.js +38 -6
- package/dist/src/tools/delete-file.js +22 -4
- package/dist/src/tools/patch-file.js +30 -5
- package/dist/src/tools/read-file.js +3 -1
- package/dist/src/tools/replace-document-text.js +7 -1
- package/dist/src/tools/run-command.js +37 -19
- package/dist/src/tools/run-node-script.js +24 -4
- package/dist/src/tools/str-replace.js +30 -5
- package/dist/src/tools/write-file.js +25 -5
- package/dist/src/turn-failure-marker.js +11 -0
- package/dist/src/ui/prompt-history-store.js +1 -1
- package/dist/src/ui/repl.js +188 -49
- package/dist/src/ui/tui/bridge.js +3 -0
- package/dist/src/ui/tui/build-frame.js +179 -82
- package/dist/src/ui/tui/markdown-render.js +72 -73
- package/dist/src/ui/tui/shell-input.js +42 -13
- package/dist/src/ui/tui/terminal-title.js +3 -0
- package/dist/src/ui/tui/terminal-writes.js +48 -0
- package/dist/src/ui/tui/text.js +158 -4
- package/dist/src/utils.js +9 -0
- package/package.json +18 -6
- package/dist/src/markdown-renderer.js +0 -112
package/dist/src/api/chat.js
CHANGED
|
@@ -4,7 +4,9 @@ import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js'
|
|
|
4
4
|
import { executeLocalToolCall } from '../tool-executor.js';
|
|
5
5
|
import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
|
|
6
6
|
import { collectClientEnvironment } from '../client-environment.js';
|
|
7
|
+
import { collectProjectOrientation } from '../project-orientation.js';
|
|
7
8
|
import { autoAttachImages } from '../core/image-path-extractor.js';
|
|
9
|
+
import { formatTurnFailureMarker } from '../turn-failure-marker.js';
|
|
8
10
|
export class TurnCancelledError extends Error {
|
|
9
11
|
name = 'TurnCancelledError';
|
|
10
12
|
constructor(message = 'Turn cancelled.') {
|
|
@@ -50,6 +52,7 @@ function parseSseBlock(block) {
|
|
|
50
52
|
return { event, data: text };
|
|
51
53
|
}
|
|
52
54
|
}
|
|
55
|
+
let toolStateSeqCounter = 0;
|
|
53
56
|
function toolStateFromSession(session) {
|
|
54
57
|
return {
|
|
55
58
|
autoYes: session.autoYes,
|
|
@@ -92,16 +95,26 @@ export function preserveCancelledTurnInput(session, input) {
|
|
|
92
95
|
return;
|
|
93
96
|
break;
|
|
94
97
|
}
|
|
95
|
-
session.history.push({
|
|
98
|
+
session.history.push({
|
|
99
|
+
role: 'user',
|
|
100
|
+
parts: [{ text }],
|
|
101
|
+
kind: 'turnStart',
|
|
102
|
+
userInput: text,
|
|
103
|
+
});
|
|
96
104
|
}
|
|
97
105
|
function preserveFailedTurnInput(session, input, category) {
|
|
98
106
|
const text = input.trim();
|
|
99
107
|
if (!text)
|
|
100
108
|
return;
|
|
101
|
-
session.history.push({
|
|
109
|
+
session.history.push({
|
|
110
|
+
role: 'user',
|
|
111
|
+
parts: [{ text }],
|
|
112
|
+
kind: 'turnStart',
|
|
113
|
+
userInput: text,
|
|
114
|
+
});
|
|
102
115
|
session.history.push({
|
|
103
116
|
role: 'model',
|
|
104
|
-
parts: [{ text:
|
|
117
|
+
parts: [{ text: formatTurnFailureMarker(category) }],
|
|
105
118
|
});
|
|
106
119
|
}
|
|
107
120
|
function historyHasToolCall(session, callId) {
|
|
@@ -143,6 +156,9 @@ function publicStatusMessage(data) {
|
|
|
143
156
|
: 'tool';
|
|
144
157
|
if (event.phase === 'thinking')
|
|
145
158
|
return 'Thinking...';
|
|
159
|
+
if (event.phase === 'analyzing_image') {
|
|
160
|
+
return (event.imageCount ?? 1) > 1 ? 'Analyzing images...' : 'Analyzing image...';
|
|
161
|
+
}
|
|
146
162
|
if (event.phase === 'running_tool')
|
|
147
163
|
return `Running ${toolName}...`;
|
|
148
164
|
if (event.phase === 'waiting_for_tool')
|
|
@@ -183,6 +199,7 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
|
|
|
183
199
|
toolCallId: event.call.id,
|
|
184
200
|
result,
|
|
185
201
|
toolState: toolStateFromSession(session),
|
|
202
|
+
toolStateSeq: ++toolStateSeqCounter,
|
|
186
203
|
};
|
|
187
204
|
const trace = createTraceContext(traceId);
|
|
188
205
|
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/tool-result`, {
|
|
@@ -201,6 +218,29 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
|
|
|
201
218
|
throw await readErrorResponse(response, trace.traceId);
|
|
202
219
|
}
|
|
203
220
|
}
|
|
221
|
+
const turnIdOverrides = new WeakMap();
|
|
222
|
+
function enterServerTurnId(session, serverSessionTurnId) {
|
|
223
|
+
const active = turnIdOverrides.get(session);
|
|
224
|
+
if (active) {
|
|
225
|
+
active.depth += 1;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
turnIdOverrides.set(session, {
|
|
229
|
+
previousTurnId: session.turnState.id,
|
|
230
|
+
depth: 1,
|
|
231
|
+
});
|
|
232
|
+
session.turnState.id = serverSessionTurnId;
|
|
233
|
+
}
|
|
234
|
+
function exitServerTurnId(session) {
|
|
235
|
+
const active = turnIdOverrides.get(session);
|
|
236
|
+
if (!active)
|
|
237
|
+
return;
|
|
238
|
+
active.depth -= 1;
|
|
239
|
+
if (active.depth === 0) {
|
|
240
|
+
session.turnState.id = active.previousTurnId;
|
|
241
|
+
turnIdOverrides.delete(session);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
204
244
|
async function executeAndPostToolResult({ config, projectIndex, session, event, input, fetchImpl, signal, traceId, }) {
|
|
205
245
|
const turnId = String(event?.turnId ?? '').trim();
|
|
206
246
|
if (!turnId || !event?.call?.id || !event.call.name) {
|
|
@@ -209,10 +249,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
|
|
|
209
249
|
if (signal?.aborted) {
|
|
210
250
|
throw new TurnCancelledError();
|
|
211
251
|
}
|
|
212
|
-
const previousTurnId = session.turnState.id;
|
|
213
252
|
const serverSessionTurnId = String(event.sessionTurnId ?? '').trim();
|
|
214
253
|
if (serverSessionTurnId) {
|
|
215
|
-
session
|
|
254
|
+
enterServerTurnId(session, serverSessionTurnId);
|
|
216
255
|
if (!session.clientState.safety.checkpoints.some((checkpoint) => checkpoint.turnId === serverSessionTurnId)) {
|
|
217
256
|
createPromptCheckpoint(session.clientState.safety, 'prompt boundary', serverSessionTurnId);
|
|
218
257
|
}
|
|
@@ -235,7 +274,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
|
|
|
235
274
|
});
|
|
236
275
|
}
|
|
237
276
|
finally {
|
|
238
|
-
|
|
277
|
+
if (serverSessionTurnId) {
|
|
278
|
+
exitServerTurnId(session);
|
|
279
|
+
}
|
|
239
280
|
}
|
|
240
281
|
}
|
|
241
282
|
async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, }) {
|
|
@@ -248,8 +289,40 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
248
289
|
const finalResult = {
|
|
249
290
|
current: null,
|
|
250
291
|
};
|
|
292
|
+
const pendingParallelTools = [];
|
|
293
|
+
let firstParallelFailure = null;
|
|
294
|
+
let rejectOnParallelFailure = null;
|
|
295
|
+
const parallelToolFailure = new Promise((_, reject) => {
|
|
296
|
+
rejectOnParallelFailure = reject;
|
|
297
|
+
});
|
|
298
|
+
parallelToolFailure.catch(() => { });
|
|
299
|
+
function recordParallelFailure(error) {
|
|
300
|
+
const failure = error ?? new Error('Local tool execution failed.');
|
|
301
|
+
if (firstParallelFailure == null) {
|
|
302
|
+
firstParallelFailure = failure;
|
|
303
|
+
rejectOnParallelFailure?.(failure);
|
|
304
|
+
}
|
|
305
|
+
return failure;
|
|
306
|
+
}
|
|
307
|
+
async function drainParallelTools() {
|
|
308
|
+
if (!pendingParallelTools.length)
|
|
309
|
+
return;
|
|
310
|
+
const pending = pendingParallelTools.splice(0);
|
|
311
|
+
const outcomes = await Promise.all(pending);
|
|
312
|
+
for (const outcome of outcomes) {
|
|
313
|
+
if (outcome != null)
|
|
314
|
+
throw outcome;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
251
317
|
async function handleEvent(event) {
|
|
252
318
|
if (event.event === 'status') {
|
|
319
|
+
const data = event.data;
|
|
320
|
+
if (data?.phase === 'analyzing_image') {
|
|
321
|
+
session.onImageAnalysis?.(Math.max(1, Number(data.imageCount ?? 1) || 1));
|
|
322
|
+
}
|
|
323
|
+
else if (data?.phase) {
|
|
324
|
+
session.onImageAnalysis?.(0);
|
|
325
|
+
}
|
|
253
326
|
const message = publicStatusMessage(event.data);
|
|
254
327
|
if (message)
|
|
255
328
|
session.onStatus(message);
|
|
@@ -259,11 +332,26 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
259
332
|
return;
|
|
260
333
|
}
|
|
261
334
|
if (event.event === 'tool-call') {
|
|
335
|
+
const data = event.data;
|
|
336
|
+
if (data?.parallelSafe === true) {
|
|
337
|
+
pendingParallelTools.push(executeAndPostToolResult({
|
|
338
|
+
config,
|
|
339
|
+
projectIndex,
|
|
340
|
+
session,
|
|
341
|
+
event: data,
|
|
342
|
+
input,
|
|
343
|
+
fetchImpl,
|
|
344
|
+
signal,
|
|
345
|
+
traceId,
|
|
346
|
+
}).then(() => null, (error) => recordParallelFailure(error)));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
await drainParallelTools();
|
|
262
350
|
await executeAndPostToolResult({
|
|
263
351
|
config,
|
|
264
352
|
projectIndex,
|
|
265
353
|
session,
|
|
266
|
-
event:
|
|
354
|
+
event: data,
|
|
267
355
|
input,
|
|
268
356
|
fetchImpl,
|
|
269
357
|
signal,
|
|
@@ -279,10 +367,12 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
279
367
|
return;
|
|
280
368
|
}
|
|
281
369
|
if (event.event === 'result') {
|
|
370
|
+
await drainParallelTools();
|
|
282
371
|
finalResult.current = event.data;
|
|
283
372
|
return;
|
|
284
373
|
}
|
|
285
374
|
if (event.event === 'cancelled' || event.event === 'error') {
|
|
375
|
+
await drainParallelTools().catch(() => { });
|
|
286
376
|
const message = String(event.data?.message ?? 'Server chat failed.');
|
|
287
377
|
if (event.event === 'cancelled') {
|
|
288
378
|
throw new TurnCancelledError(message);
|
|
@@ -290,24 +380,34 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
290
380
|
throw new ChatTurnFailedError(message, typeof event.data?.category === 'string' ? event.data.category : 'unknown_error', Boolean(event.data?.retryable), typeof event.data?.traceId === 'string' ? event.data.traceId : traceId);
|
|
291
381
|
}
|
|
292
382
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
383
|
+
try {
|
|
384
|
+
while (true) {
|
|
385
|
+
if (signal?.aborted) {
|
|
386
|
+
await reader.cancel().catch(() => { });
|
|
387
|
+
throw new TurnCancelledError();
|
|
388
|
+
}
|
|
389
|
+
const read = await Promise.race([reader.read(), parallelToolFailure]);
|
|
390
|
+
if (read.done)
|
|
391
|
+
break;
|
|
392
|
+
buffer += decoder.decode(read.value, { stream: true });
|
|
393
|
+
let separatorIndex = buffer.indexOf('\n\n');
|
|
394
|
+
while (separatorIndex !== -1) {
|
|
395
|
+
const block = buffer.slice(0, separatorIndex);
|
|
396
|
+
buffer = buffer.slice(separatorIndex + 2);
|
|
397
|
+
const event = parseSseBlock(block);
|
|
398
|
+
if (event)
|
|
399
|
+
await handleEvent(event);
|
|
400
|
+
separatorIndex = buffer.indexOf('\n\n');
|
|
401
|
+
}
|
|
310
402
|
}
|
|
403
|
+
await drainParallelTools();
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
await reader.cancel().catch(() => { });
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
await drainParallelTools().catch(() => { });
|
|
311
411
|
}
|
|
312
412
|
buffer += decoder.decode();
|
|
313
413
|
const tail = buffer.trim();
|
|
@@ -339,6 +439,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
339
439
|
input: requestInputBase,
|
|
340
440
|
backgroundJobUpdate: backgroundJobUpdate || undefined,
|
|
341
441
|
clientEnvironment: collectClientEnvironment({ env: session.env }),
|
|
442
|
+
projectOrientation: collectProjectOrientation(session.rootDir) ?? undefined,
|
|
342
443
|
imageAttachments: imageAttachmentsForServer(requestImageAttachments),
|
|
343
444
|
maxToolSteps: session.maxToolSteps,
|
|
344
445
|
autoYes: session.autoYes,
|
package/dist/src/api/http.js
CHANGED
|
@@ -6,11 +6,13 @@ export const CLIENT_PLATFORM_HEADER = 'x-thegitai-client-platform';
|
|
|
6
6
|
export class ServerApiError extends Error {
|
|
7
7
|
status;
|
|
8
8
|
traceId;
|
|
9
|
-
|
|
9
|
+
code;
|
|
10
|
+
constructor(message, status, traceId, code = '') {
|
|
10
11
|
super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
|
|
11
12
|
this.name = 'ServerApiError';
|
|
12
13
|
this.status = status;
|
|
13
14
|
this.traceId = traceId;
|
|
15
|
+
this.code = code;
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
export function createTraceId() {
|
|
@@ -96,9 +98,20 @@ export async function readJsonResponse(response) {
|
|
|
96
98
|
export function failureMessage(data, status) {
|
|
97
99
|
return String(data?.error?.message ?? data?.message ?? `Request failed with ${status}`);
|
|
98
100
|
}
|
|
101
|
+
export function failureCode(data) {
|
|
102
|
+
return typeof data?.error?.code === 'string' ? data.error.code : '';
|
|
103
|
+
}
|
|
104
|
+
export function isAuthenticationError(error) {
|
|
105
|
+
return error instanceof ServerApiError && error.status === 401;
|
|
106
|
+
}
|
|
107
|
+
export function authenticationErrorMessage(error) {
|
|
108
|
+
return error.code === 'AUTH_TOKEN_EXPIRED'
|
|
109
|
+
? 'Your login expired after 24 hours of inactivity. Run `ai login` and resume this saved session.'
|
|
110
|
+
: 'Your login is no longer valid. Run `ai login` and resume this saved session.';
|
|
111
|
+
}
|
|
99
112
|
export async function readErrorResponse(response, traceId = response.headers.get(TRACE_ID_HEADER) ?? '') {
|
|
100
113
|
const data = await readJsonResponse(response);
|
|
101
|
-
return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
|
|
114
|
+
return new ServerApiError(failureMessage(data, response.status), response.status, traceId, failureCode(data));
|
|
102
115
|
}
|
|
103
116
|
export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
|
|
104
117
|
const trace = createTraceContext();
|
|
@@ -115,7 +128,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
|
|
|
115
128
|
});
|
|
116
129
|
const data = await readJsonResponse(response);
|
|
117
130
|
if (!response.ok) {
|
|
118
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
131
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
|
|
119
132
|
}
|
|
120
133
|
return data;
|
|
121
134
|
}
|
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 { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
4
|
+
import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
5
5
|
function sanitizeModelInfo(raw) {
|
|
6
6
|
if (!raw || typeof raw !== 'object') {
|
|
7
7
|
return null;
|
|
@@ -9,10 +9,15 @@ function sanitizeModelInfo(raw) {
|
|
|
9
9
|
const value = raw;
|
|
10
10
|
const id = Number(value.id);
|
|
11
11
|
const label = String(value.label ?? '').trim();
|
|
12
|
-
|
|
12
|
+
const costRating = Number(value.costRating);
|
|
13
|
+
const description = String(value.description ?? '').trim();
|
|
14
|
+
if (!Number.isInteger(id) || id <= 0 || !label || !isCostRating(costRating)) {
|
|
13
15
|
return null;
|
|
14
16
|
}
|
|
15
|
-
return { id, label };
|
|
17
|
+
return { id, label, costRating, description };
|
|
18
|
+
}
|
|
19
|
+
function isCostRating(value) {
|
|
20
|
+
return Number.isInteger(value) && value >= 1 && value <= 3;
|
|
16
21
|
}
|
|
17
22
|
export function getModelsCachePath(env = process.env) {
|
|
18
23
|
return path.join(getClientStateDir(env), 'models.json');
|
|
@@ -70,7 +75,7 @@ export async function fetchServerModels({ config, fetchImpl = globalThis.fetch,
|
|
|
70
75
|
});
|
|
71
76
|
const data = (await readJsonResponse(response));
|
|
72
77
|
if (!response.ok) {
|
|
73
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
78
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
|
|
74
79
|
}
|
|
75
80
|
const models = Array.isArray(data?.models)
|
|
76
81
|
? data.models.map(sanitizeModelInfo).filter(Boolean)
|
package/dist/src/help-text.js
CHANGED
|
@@ -22,6 +22,7 @@ const HELP_MARKDOWN = [
|
|
|
22
22
|
'',
|
|
23
23
|
'- `ai` — start an interactive chat session in the current repo',
|
|
24
24
|
'- `ai "<request>"` — start an interactive session with `<request>` as the first message',
|
|
25
|
+
'- Coding sessions require terminal stdin and stdout; piped prompts are not supported.',
|
|
25
26
|
'',
|
|
26
27
|
'## Auth',
|
|
27
28
|
'',
|
|
@@ -35,8 +36,8 @@ const HELP_MARKDOWN = [
|
|
|
35
36
|
'',
|
|
36
37
|
'- `ai --list-sessions` — list saved sessions for this repo',
|
|
37
38
|
'- `ai --session <id|name>` — resume a saved session by id or name',
|
|
38
|
-
'- Sessions are stored locally and
|
|
39
|
-
'
|
|
39
|
+
'- Sessions are stored locally and can be listed or resumed in the same repo.',
|
|
40
|
+
' Continuing one requires the TheGitAI account used for that session.',
|
|
40
41
|
'',
|
|
41
42
|
'## Options',
|
|
42
43
|
'',
|
|
@@ -56,7 +57,8 @@ const HELP_MARKDOWN = [
|
|
|
56
57
|
'## Keys & clipboard',
|
|
57
58
|
'',
|
|
58
59
|
'- **Enter** sends • **Shift+Tab** cycles modes • **Esc** cancels the turn •',
|
|
59
|
-
' **Ctrl+C**
|
|
60
|
+
' **Ctrl+C** clears the composer or the queued message, and quits once there',
|
|
61
|
+
' is nothing left to clear. These are the same on macOS, Linux, and Windows.',
|
|
60
62
|
`- **Paste** into the composer with your terminal's paste shortcut (\`${PASTE_SHORTCUT}\``,
|
|
61
63
|
' on this system) or by right-clicking the composer.',
|
|
62
64
|
'- **Copy** from the transcript by dragging to select; double-click copies a',
|
|
@@ -77,7 +79,7 @@ const HELP_MARKDOWN = [
|
|
|
77
79
|
' browse them, press Enter to expand one and read its output, k to stop it',
|
|
78
80
|
'- `/jobs output <id>` — print one job\'s full captured output',
|
|
79
81
|
'- `/jobs kill <id>` — stop one background job',
|
|
80
|
-
'- `/
|
|
82
|
+
'- `/new` — start a new conversation; this session remains saved',
|
|
81
83
|
'- `/exit` — quit the session',
|
|
82
84
|
'',
|
|
83
85
|
'## Safety & approvals',
|
|
@@ -105,7 +107,10 @@ const HELP_MARKDOWN = [
|
|
|
105
107
|
'- Auth or permission errors → run `ai whoami` to confirm the signed-in',
|
|
106
108
|
' account.',
|
|
107
109
|
'- Usage or quota errors → run `ai --usage`.',
|
|
108
|
-
'-
|
|
110
|
+
'- Signed in with the wrong credentials → `ai logout`, then `ai login` with',
|
|
111
|
+
' the account you intended to use.',
|
|
112
|
+
'- A local session was used with a different sign-in → sign in with the',
|
|
113
|
+
' account you used for that session or start a new session.',
|
|
109
114
|
'- For anything else, re-run the command and report the printed error',
|
|
110
115
|
' message — there is no client-side debug mode by design.',
|
|
111
116
|
].join('\n');
|
|
@@ -126,8 +131,15 @@ export function formatInteractiveHelpText() {
|
|
|
126
131
|
return HELP_MARKDOWN;
|
|
127
132
|
}
|
|
128
133
|
export function formatCliHelpText({ color = false } = {}) {
|
|
129
|
-
if (!color)
|
|
130
|
-
return HELP_MARKDOWN
|
|
134
|
+
if (!color) {
|
|
135
|
+
return HELP_MARKDOWN.split('\n')
|
|
136
|
+
.map((line) => line
|
|
137
|
+
.replace(/^#{1,6}\s+/, '')
|
|
138
|
+
.replace(/^-\s+/, ' ')
|
|
139
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
140
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1'))
|
|
141
|
+
.join('\n');
|
|
142
|
+
}
|
|
131
143
|
return HELP_MARKDOWN.split('\n')
|
|
132
144
|
.map((line) => {
|
|
133
145
|
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
package/dist/src/patcher.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import chalk from './colors.js';
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'fs';
|
|
2
|
+
import { chmodSync, closeSync, constants, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { createInterface } from 'readline';
|
|
5
5
|
import { runCommand } from './executor.js';
|
|
6
|
+
import { ensureSessionScratchDir, isInsideTheGitAiScratch, isWithinSessionScratchDir, } from './scratch-dir.js';
|
|
6
7
|
import { isTuiMode } from './runtime-mode.js';
|
|
7
8
|
import { truncate } from './utils.js';
|
|
8
9
|
function parseUnifiedDiff(patchText) {
|
|
@@ -143,17 +144,92 @@ export function renderDiffPreview(filePath, patchText) {
|
|
|
143
144
|
function normalizeRoot(rootDir) {
|
|
144
145
|
return path.resolve(rootDir);
|
|
145
146
|
}
|
|
146
|
-
|
|
147
|
+
function expandScratchPath(filePath) {
|
|
148
|
+
const match = filePath.match(/^(?:\$THEGITAI_SCRATCH_DIR|\$\{THEGITAI_SCRATCH_DIR\})(?:[\\/](.*))?$/);
|
|
149
|
+
if (!match)
|
|
150
|
+
return filePath;
|
|
151
|
+
const root = ensureSessionScratchDir();
|
|
152
|
+
return match[1] ? path.join(root, match[1]) : root;
|
|
153
|
+
}
|
|
154
|
+
export function classifyProjectPath(rootDir, filePath) {
|
|
147
155
|
const absRoot = normalizeRoot(rootDir);
|
|
148
|
-
const absPath = path.resolve(absRoot, filePath);
|
|
156
|
+
const absPath = path.resolve(absRoot, expandScratchPath(filePath));
|
|
157
|
+
if (isWithinSessionScratchDir(absPath)) {
|
|
158
|
+
return absPath !== path.resolve(ensureSessionScratchDir()) &&
|
|
159
|
+
isInsideTheGitAiScratch(absPath)
|
|
160
|
+
? 'scratch'
|
|
161
|
+
: 'outside';
|
|
162
|
+
}
|
|
149
163
|
const relative = path.relative(absRoot, absPath);
|
|
150
|
-
if (relative.startsWith('..')
|
|
151
|
-
|
|
164
|
+
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
165
|
+
return 'project';
|
|
166
|
+
}
|
|
167
|
+
return 'outside';
|
|
168
|
+
}
|
|
169
|
+
export function resolveProjectPath(rootDir, filePath) {
|
|
170
|
+
const absRoot = normalizeRoot(rootDir);
|
|
171
|
+
const absPath = path.resolve(absRoot, expandScratchPath(filePath));
|
|
172
|
+
if (classifyProjectPath(rootDir, filePath) === 'outside') {
|
|
173
|
+
throw new Error(`Refusing to access path outside the project root: ${filePath}. Allowed locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`);
|
|
152
174
|
}
|
|
153
175
|
return absPath;
|
|
154
176
|
}
|
|
177
|
+
function mkdirForWrite(absPath, scratchPath) {
|
|
178
|
+
const parent = path.dirname(absPath);
|
|
179
|
+
if (!scratchPath) {
|
|
180
|
+
mkdirSync(parent, { recursive: true });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const scratchRoot = path.resolve(ensureSessionScratchDir());
|
|
184
|
+
const relative = path.relative(scratchRoot, parent);
|
|
185
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
186
|
+
throw new Error(`Refusing to create a directory outside the session scratch root: ${parent}`);
|
|
187
|
+
}
|
|
188
|
+
let current = scratchRoot;
|
|
189
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
190
|
+
current = path.join(current, segment);
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(current, { mode: 0o700 });
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error?.code !== 'EEXIST')
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
const stat = lstatSync(current);
|
|
199
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
200
|
+
throw new Error(`Refusing to traverse unsafe scratch directory: ${current}`);
|
|
201
|
+
}
|
|
202
|
+
if (process.platform !== 'win32')
|
|
203
|
+
chmodSync(current, 0o700);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function writeScratchFile(absPath, content) {
|
|
207
|
+
const scratchRoot = realpathSync(ensureSessionScratchDir());
|
|
208
|
+
const parent = realpathSync(path.dirname(absPath));
|
|
209
|
+
const relativeParent = path.relative(scratchRoot, parent);
|
|
210
|
+
if (relativeParent.startsWith('..') || path.isAbsolute(relativeParent)) {
|
|
211
|
+
throw new Error(`Refusing to write through an unsafe scratch directory: ${absPath}`);
|
|
212
|
+
}
|
|
213
|
+
const verifiedPath = path.join(parent, path.basename(absPath));
|
|
214
|
+
const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
215
|
+
const fd = openSync(verifiedPath, constants.O_WRONLY | constants.O_CREAT | noFollow, 0o600);
|
|
216
|
+
try {
|
|
217
|
+
const stat = fstatSync(fd);
|
|
218
|
+
if (!stat.isFile() || stat.nlink > 1) {
|
|
219
|
+
throw new Error(`Refusing to write an unsafe scratch file: ${absPath}`);
|
|
220
|
+
}
|
|
221
|
+
ftruncateSync(fd, 0);
|
|
222
|
+
if (process.platform !== 'win32')
|
|
223
|
+
fchmodSync(fd, 0o600);
|
|
224
|
+
writeFileSync(fd, content);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
closeSync(fd);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
155
230
|
export function writeProjectFile(rootDir, filePath, content) {
|
|
156
231
|
const absPath = resolveProjectPath(rootDir, filePath);
|
|
232
|
+
const scratchPath = classifyProjectPath(rootDir, filePath) === 'scratch';
|
|
157
233
|
if (existsSync(absPath)) {
|
|
158
234
|
try {
|
|
159
235
|
const existingContent = readFileSync(absPath, 'utf-8');
|
|
@@ -164,12 +240,18 @@ export function writeProjectFile(rootDir, filePath, content) {
|
|
|
164
240
|
catch {
|
|
165
241
|
}
|
|
166
242
|
}
|
|
167
|
-
|
|
168
|
-
|
|
243
|
+
mkdirForWrite(absPath, scratchPath);
|
|
244
|
+
if (scratchPath) {
|
|
245
|
+
writeScratchFile(absPath, content);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
writeFileSync(absPath, content, 'utf-8');
|
|
249
|
+
}
|
|
169
250
|
return { absPath, changed: true };
|
|
170
251
|
}
|
|
171
252
|
export function writeProjectFileBuffer(rootDir, filePath, content) {
|
|
172
253
|
const absPath = resolveProjectPath(rootDir, filePath);
|
|
254
|
+
const scratchPath = classifyProjectPath(rootDir, filePath) === 'scratch';
|
|
173
255
|
if (existsSync(absPath)) {
|
|
174
256
|
try {
|
|
175
257
|
const existingContent = readFileSync(absPath);
|
|
@@ -180,8 +262,13 @@ export function writeProjectFileBuffer(rootDir, filePath, content) {
|
|
|
180
262
|
catch {
|
|
181
263
|
}
|
|
182
264
|
}
|
|
183
|
-
|
|
184
|
-
|
|
265
|
+
mkdirForWrite(absPath, scratchPath);
|
|
266
|
+
if (scratchPath) {
|
|
267
|
+
writeScratchFile(absPath, content);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
writeFileSync(absPath, content);
|
|
271
|
+
}
|
|
185
272
|
return { absPath, changed: true };
|
|
186
273
|
}
|
|
187
274
|
export function deleteProjectFile(rootDir, filePath) {
|
|
@@ -39,10 +39,21 @@ function removeFile(index, relPath) {
|
|
|
39
39
|
index.chunksByFile.delete(relPath);
|
|
40
40
|
index.fileSignatures.delete(relPath);
|
|
41
41
|
}
|
|
42
|
+
function countIndexedChunks(index) {
|
|
43
|
+
return Array.from(index.chunksByFile.values()).reduce((sum, chunks) => sum + chunks.length, 0);
|
|
44
|
+
}
|
|
42
45
|
async function initializeIndex(index) {
|
|
43
46
|
if (index.initialized) {
|
|
44
|
-
return
|
|
47
|
+
return countIndexedChunks(index);
|
|
48
|
+
}
|
|
49
|
+
if (!index._initializing) {
|
|
50
|
+
index._initializing = scanProjectIntoIndex(index).finally(() => {
|
|
51
|
+
index._initializing = null;
|
|
52
|
+
});
|
|
45
53
|
}
|
|
54
|
+
return index._initializing;
|
|
55
|
+
}
|
|
56
|
+
async function scanProjectIntoIndex(index) {
|
|
46
57
|
const files = listProjectFiles(index.rootDir);
|
|
47
58
|
const chunks = await scanFiles(index.rootDir, files);
|
|
48
59
|
index.fileSignatures.clear();
|
|
@@ -106,6 +117,7 @@ export function createIndex({ rootDir, onStatus = null, onContextLog = null, })
|
|
|
106
117
|
return {
|
|
107
118
|
rootDir: path.resolve(rootDir),
|
|
108
119
|
initialized: false,
|
|
120
|
+
_initializing: null,
|
|
109
121
|
fileSignatures: new Map(),
|
|
110
122
|
chunksByFile: new Map(),
|
|
111
123
|
onStatus,
|