@thegitai/cli 1.0.0-beta.2 → 1.0.0-beta.5

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.
@@ -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 { authorizedJson, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
4
+ import { ServerApiError, authorizedJson, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } 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) {
@@ -68,14 +68,16 @@ export async function fetchWhoamiResponse({ config, fetchImpl = globalThis.fetch
68
68
  };
69
69
  }
70
70
  export async function logoutFromServer({ config, fetchImpl = globalThis.fetch, }) {
71
+ const trace = createTraceContext();
71
72
  const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/auth/logout`, {
72
73
  method: 'POST',
73
74
  headers: {
74
75
  authorization: `Bearer ${config.token}`,
76
+ ...trace.headers,
75
77
  },
76
78
  });
77
79
  if (!response.ok && response.status !== 401) {
78
80
  const data = (await readJsonResponse(response));
79
- throw new Error(failureMessage(data, response.status));
81
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
80
82
  }
81
83
  }
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import http from 'node:http';
3
3
  import os from 'node:os';
4
4
  import { openUrl } from '../core/open-url.js';
5
- import { failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
5
+ import { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
6
6
  const DEFAULT_WEBSITE_URL = 'https://thegit.ai';
7
7
  const DEFAULT_DEV_WEBSITE_URL = 'http://localhost:3002';
8
8
  const DEFAULT_SERVER_URL = 'https://thegit.ai';
@@ -73,14 +73,15 @@ const RESULT_PAGE = (heading, detail) => `<!doctype html><html><head><meta chars
73
73
  `p{color:#9aa0a6}</style></head><body><div class="card"><h1>${heading}</h1>` +
74
74
  `<p>${detail}</p></div></body></html>`;
75
75
  async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl, }) {
76
+ const trace = createTraceContext();
76
77
  const response = await fetchImpl(`${serverUrl}/v1/cli/auth/token`, {
77
78
  method: 'POST',
78
- headers: { 'content-type': 'application/json' },
79
+ headers: { 'content-type': 'application/json', ...trace.headers },
79
80
  body: JSON.stringify({ code, code_verifier: codeVerifier }),
80
81
  });
81
82
  const data = (await readJsonResponse(response));
82
83
  if (!response.ok) {
83
- throw new Error(failureMessage(data, response.status));
84
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
84
85
  }
85
86
  const token = String(data?.token ?? '').trim();
86
87
  const customer = data?.customer;
@@ -1,7 +1,7 @@
1
1
  import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
2
2
  import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js';
3
3
  import { executeLocalToolCall } from '../tool-executor.js';
4
- import { normalizeServerUrl, readErrorResponse, } from './http.js';
4
+ import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
5
5
  export class TurnCancelledError extends Error {
6
6
  name = 'TurnCancelledError';
7
7
  constructor(message = 'Turn cancelled.') {
@@ -12,10 +12,12 @@ export class ChatTurnFailedError extends Error {
12
12
  name = 'ChatTurnFailedError';
13
13
  category;
14
14
  retryable;
15
- constructor(message, category = 'unknown_error', retryable = false) {
16
- super(message);
15
+ traceId;
16
+ constructor(message, category = 'unknown_error', retryable = false, traceId = '') {
17
+ super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
17
18
  this.category = category;
18
19
  this.retryable = retryable;
20
+ this.traceId = traceId;
19
21
  }
20
22
  }
21
23
  export function isTurnCancelledError(error) {
@@ -139,17 +141,19 @@ function publicStatusMessage(data) {
139
141
  return `Running ${toolName} locally...`;
140
142
  return null;
141
143
  }
142
- async function postToolResult({ config, turnId, event, result, session, fetchImpl, }) {
144
+ async function postToolResult({ config, turnId, event, result, session, fetchImpl, traceId, }) {
143
145
  const payload = {
144
146
  toolCallId: event.call.id,
145
147
  result,
146
148
  toolState: toolStateFromSession(session),
147
149
  };
150
+ const trace = createTraceContext(traceId);
148
151
  const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/tool-result`, {
149
152
  method: 'POST',
150
153
  headers: {
151
154
  authorization: `Bearer ${config.token}`,
152
155
  'content-type': 'application/json',
156
+ ...trace.headers,
153
157
  },
154
158
  body: JSON.stringify(payload),
155
159
  });
@@ -157,10 +161,10 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
157
161
  return;
158
162
  }
159
163
  if (!response.ok) {
160
- throw await readErrorResponse(response);
164
+ throw await readErrorResponse(response, trace.traceId);
161
165
  }
162
166
  }
163
- async function executeAndPostToolResult({ config, projectIndex, session, event, input, fetchImpl, signal, }) {
167
+ async function executeAndPostToolResult({ config, projectIndex, session, event, input, fetchImpl, signal, traceId, }) {
164
168
  const turnId = String(event?.turnId ?? '').trim();
165
169
  if (!turnId || !event?.call?.id || !event.call.name) {
166
170
  throw new Error('Server emitted an invalid tool-call event.');
@@ -189,13 +193,14 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
189
193
  result: rawResult,
190
194
  session,
191
195
  fetchImpl,
196
+ traceId,
192
197
  });
193
198
  }
194
199
  finally {
195
200
  session.turnState.id = previousTurnId;
196
201
  }
197
202
  }
198
- async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, }) {
203
+ async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, }) {
199
204
  if (!response.body) {
200
205
  throw new Error('Server returned an empty chat stream.');
201
206
  }
@@ -224,6 +229,7 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
224
229
  input,
225
230
  fetchImpl,
226
231
  signal,
232
+ traceId,
227
233
  });
228
234
  return;
229
235
  }
@@ -243,7 +249,7 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
243
249
  if (event.event === 'cancelled') {
244
250
  throw new TurnCancelledError(message);
245
251
  }
246
- throw new ChatTurnFailedError(message, typeof event.data?.category === 'string' ? event.data.category : 'unknown_error', Boolean(event.data?.retryable));
252
+ 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);
247
253
  }
248
254
  }
249
255
  while (true) {
@@ -287,6 +293,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
287
293
  autoYes: session.autoYes,
288
294
  agentMode: session.agentMode,
289
295
  };
296
+ const trace = createTraceContext();
290
297
  const preTurnHistoryLength = session.history.length;
291
298
  const preserveOnAbort = () => preserveCancelledTurnInput(session, input);
292
299
  if (signal?.aborted) {
@@ -302,12 +309,13 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
302
309
  accept: 'text/event-stream',
303
310
  authorization: `Bearer ${config.token}`,
304
311
  'content-type': 'application/json',
312
+ ...trace.headers,
305
313
  },
306
314
  body: JSON.stringify(request),
307
315
  signal,
308
316
  });
309
317
  if (!response.ok) {
310
- throw await readErrorResponse(response);
318
+ throw await readErrorResponse(response, trace.traceId);
311
319
  }
312
320
  const result = await consumeTurnStream({
313
321
  response,
@@ -317,6 +325,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
317
325
  input,
318
326
  fetchImpl,
319
327
  signal,
328
+ traceId: trace.traceId,
320
329
  });
321
330
  applySessionSnapshot(session, result.snapshot, { preserveAgentMode: true });
322
331
  return {
@@ -1,4 +1,31 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  const DEFAULT_SERVER_URL = 'https://thegit.ai';
3
+ export const TRACE_ID_HEADER = 'x-thegitai-trace-id';
4
+ export const CLIENT_HEADER = 'x-thegitai-client';
5
+ export const CLIENT_PLATFORM_HEADER = 'x-thegitai-client-platform';
6
+ export class ServerApiError extends Error {
7
+ status;
8
+ traceId;
9
+ constructor(message, status, traceId) {
10
+ super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
11
+ this.name = 'ServerApiError';
12
+ this.status = status;
13
+ this.traceId = traceId;
14
+ }
15
+ }
16
+ export function createTraceId() {
17
+ return `tr_${randomUUID().replace(/-/g, '')}`;
18
+ }
19
+ export function createTraceContext(traceId = createTraceId()) {
20
+ return {
21
+ traceId,
22
+ headers: {
23
+ [TRACE_ID_HEADER]: traceId,
24
+ [CLIENT_HEADER]: 'cli',
25
+ [CLIENT_PLATFORM_HEADER]: `${process.platform}/${process.arch}`,
26
+ },
27
+ };
28
+ }
2
29
  export function normalizeServerUrl(serverUrl) {
3
30
  const normalized = String(serverUrl || DEFAULT_SERVER_URL)
4
31
  .trim()
@@ -22,15 +49,17 @@ export async function readJsonResponse(response) {
22
49
  export function failureMessage(data, status) {
23
50
  return String(data?.error?.message ?? data?.message ?? `Request failed with ${status}`);
24
51
  }
25
- export async function readErrorResponse(response) {
52
+ export async function readErrorResponse(response, traceId = response.headers.get(TRACE_ID_HEADER) ?? '') {
26
53
  const data = await readJsonResponse(response);
27
- return new Error(failureMessage(data, response.status));
54
+ return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
28
55
  }
29
56
  export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, }) {
57
+ const trace = createTraceContext();
30
58
  const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}${path}`, {
31
59
  method,
32
60
  headers: {
33
61
  authorization: `Bearer ${config.token}`,
62
+ ...trace.headers,
34
63
  ...headers,
35
64
  ...(body === null ? {} : { 'content-type': 'application/json' }),
36
65
  },
@@ -38,7 +67,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
38
67
  });
39
68
  const data = await readJsonResponse(response);
40
69
  if (!response.ok) {
41
- throw new Error(failureMessage(data, response.status));
70
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
42
71
  }
43
72
  return data;
44
73
  }
@@ -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 { failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
4
+ import { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
5
5
  function sanitizeModelInfo(raw) {
6
6
  if (!raw || typeof raw !== 'object') {
7
7
  return null;
@@ -54,14 +54,16 @@ export function writeCachedServerModels(cache, env = process.env) {
54
54
  });
55
55
  }
56
56
  export async function fetchServerModels({ config, fetchImpl = globalThis.fetch, }) {
57
+ const trace = createTraceContext();
57
58
  const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
58
59
  headers: {
59
60
  authorization: `Bearer ${config.token}`,
61
+ ...trace.headers,
60
62
  },
61
63
  });
62
64
  const data = (await readJsonResponse(response));
63
65
  if (!response.ok) {
64
- throw new Error(failureMessage(data, response.status));
66
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
65
67
  }
66
68
  const models = Array.isArray(data?.models)
67
69
  ? data.models.map(sanitizeModelInfo).filter(Boolean)
@@ -21,6 +21,14 @@ export function isSupportedImageMimeType(mime) {
21
21
  }
22
22
  function whichSync(cmd) {
23
23
  try {
24
+ if (process.platform === 'win32') {
25
+ execFileSync('where.exe', [cmd], {
26
+ stdio: 'ignore',
27
+ timeout: 2000,
28
+ windowsHide: true,
29
+ });
30
+ return true;
31
+ }
24
32
  execFileSync('which', [cmd], { stdio: 'ignore', timeout: 2000 });
25
33
  return true;
26
34
  }
@@ -93,6 +101,52 @@ function readClipboardLinux() {
93
101
  }
94
102
  throw new ClipboardError('Clipboard contains no image data.', 'NO_IMAGE');
95
103
  }
104
+ const WINDOWS_CLIPBOARD_IMAGE_PS = [
105
+ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
106
+ '$ErrorActionPreference = "Stop";',
107
+ 'Add-Type -AssemblyName System.Drawing;',
108
+ '$img = Get-Clipboard -Format Image;',
109
+ 'if ($null -eq $img) { exit 2 }',
110
+ '$ms = New-Object System.IO.MemoryStream;',
111
+ '$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);',
112
+ '[Convert]::ToBase64String($ms.ToArray())',
113
+ ].join(' ');
114
+ function readClipboardWindows() {
115
+ try {
116
+ const b64 = execFileSync('powershell.exe', ['-NoProfile', '-Command', WINDOWS_CLIPBOARD_IMAGE_PS], {
117
+ encoding: 'utf-8',
118
+ timeout: 5000,
119
+ maxBuffer: MAX_IMAGE_SIZE_BYTES * 2,
120
+ stdio: ['ignore', 'pipe', 'pipe'],
121
+ windowsHide: true,
122
+ }).trim();
123
+ if (!b64) {
124
+ throw new ClipboardError('Clipboard contains no image data.', 'NO_IMAGE');
125
+ }
126
+ const buf = Buffer.from(b64, 'base64');
127
+ if (!buf.length) {
128
+ throw new ClipboardError('Clipboard contains no image data.', 'NO_IMAGE');
129
+ }
130
+ if (buf.length > MAX_IMAGE_SIZE_BYTES) {
131
+ throw new ClipboardError('Clipboard image exceeds 10MB size limit.', 'READ_FAILED');
132
+ }
133
+ return { base64Data: b64, mimeType: 'image/png' };
134
+ }
135
+ catch (err) {
136
+ if (err instanceof ClipboardError)
137
+ throw err;
138
+ if (err?.status === 2) {
139
+ throw new ClipboardError('Clipboard contains no image data. Copy an image first (Win+Shift+S), then press Alt+V.', 'NO_IMAGE');
140
+ }
141
+ if (isMaxBufferError(err)) {
142
+ throw new ClipboardError('Clipboard image exceeds 10MB size limit.', 'READ_FAILED');
143
+ }
144
+ const detail = [err?.message, err?.stderr?.toString?.()?.trim()]
145
+ .filter(Boolean)
146
+ .join(' — ');
147
+ throw new ClipboardError(`Failed to read clipboard image on Windows: ${detail || 'unknown error'}`, 'READ_FAILED');
148
+ }
149
+ }
96
150
  function readClipboardDarwin() {
97
151
  if (whichSync('pngpaste')) {
98
152
  try {
@@ -123,6 +177,8 @@ export function readClipboardImage(platform = process.platform) {
123
177
  return readClipboardLinux();
124
178
  case 'darwin':
125
179
  return readClipboardDarwin();
180
+ case 'win32':
181
+ return readClipboardWindows();
126
182
  default:
127
183
  throw new ClipboardError(`Clipboard image paste is not supported on ${platform}.`, 'NO_TOOL');
128
184
  }
@@ -114,13 +114,13 @@ export function resolveTuiBinaryPath() {
114
114
  export function spawnTuiProcess(options = {}) {
115
115
  const binaryPath = resolveTuiBinaryPath();
116
116
  return spawn(binaryPath, [], {
117
- stdio: ['pipe', 'pipe', 'inherit'],
117
+ stdio: ['pipe', 'inherit', 'pipe'],
118
118
  env: { ...process.env, ...options.env },
119
119
  });
120
120
  }
121
121
  export function createRatatuiBridge() {
122
122
  const child = spawnTuiProcess();
123
- const rl = createInterface({ input: child.stdout });
123
+ const rl = createInterface({ input: child.stderr });
124
124
  let eventHandler = null;
125
125
  let closed = false;
126
126
  rl.on('line', (line) => {
@@ -311,7 +311,11 @@ function composerFooterLines(state) {
311
311
  ? state.queuedMessage
312
312
  ? 'Enter re-queues • ↑ edit queued • Esc cancels queued'
313
313
  : 'Enter queues • Esc / Ctrl+C cancel turn'
314
- : 'Enter sends • Shift+Tab mode • Esc cancel turn • Ctrl+C quits';
314
+ : process.platform === 'win32'
315
+ ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C quits'
316
+ : process.platform === 'darwin'
317
+ ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits'
318
+ : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits';
315
319
  const agentLabel = agentModeLabel(state.agentMode).padEnd(AGENT_MODE_LABEL_WIDTH);
316
320
  const tokenUsageText = state.tokenUsage || formatClientTokenUsage(null);
317
321
  const footerSpans = [
@@ -1,6 +1,14 @@
1
1
  import { readClipboardImage, readClipboardText } from '../../core/clipboard.js';
2
2
  import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getApprovalChoiceForCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, isExactSlashCommandToken, navigatePromptHistory, resolveApprovalChoiceFromInput, shouldRemountLiveFrameForComposerInputChange, } from '../repl.js';
3
3
  import { buildPastePlaceholder, shouldCollapsePaste, } from '../paste-collapse.js';
4
+ function isClipboardImagePasteKey(key) {
5
+ if (process.platform === 'win32') {
6
+ return ((key.ctrl || key.meta) &&
7
+ key.input.toLowerCase() === 'v' &&
8
+ !key.shift);
9
+ }
10
+ return key.ctrl && key.input === 'v' && !key.shift && !key.meta;
11
+ }
4
12
  function shouldShowCommandPalette(state) {
5
13
  if (state.busy ||
6
14
  state.exiting ||
@@ -428,7 +436,7 @@ export function handleShellKeyEvent(store, handlers, event) {
428
436
  });
429
437
  return;
430
438
  }
431
- if (key.ctrl && key.input === 'v') {
439
+ if (isClipboardImagePasteKey(key)) {
432
440
  const current = store.getState();
433
441
  if (current.busy)
434
442
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "TheGitAI CLI client (source-visible, proprietary)",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://thegit.ai",
@@ -27,10 +27,10 @@
27
27
  "web-tree-sitter": "^0.26.6"
28
28
  },
29
29
  "optionalDependencies": {
30
- "@thegitai/tui-darwin-arm64": "1.0.0-beta.2",
31
- "@thegitai/tui-darwin-x64": "1.0.0-beta.2",
32
- "@thegitai/tui-linux-x64": "1.0.0-beta.2",
33
- "@thegitai/tui-win32-x64": "1.0.0-beta.2"
30
+ "@thegitai/tui-darwin-arm64": "1.0.0-beta.5",
31
+ "@thegitai/tui-darwin-x64": "1.0.0-beta.5",
32
+ "@thegitai/tui-linux-x64": "1.0.0-beta.5",
33
+ "@thegitai/tui-win32-x64": "1.0.0-beta.5"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"