@thegitai/cli 1.0.0-preview.5 → 1.0.0-preview.7

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 CHANGED
@@ -34,6 +34,10 @@ always be viewed or resumed from that computer. Continuing it through the
34
34
  service requires the TheGitAI account used for that session. Local sessions can
35
35
  also be listed while signed out or offline.
36
36
 
37
+ CLI login tokens use a rolling 24-hour inactivity timeout. If one expires during
38
+ any server request, the CLI saves the local session, removes the expired
39
+ credential, and asks you to run `ai login` before resuming.
40
+
37
41
  ## Visible to-do list
38
42
 
39
43
  For larger multi-step tasks, the agent keeps a compact to-do list on screen so
package/dist/bin/ai.js CHANGED
@@ -4,7 +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
+ import { authenticationErrorMessage, isAuthenticationError, isTransientNetworkError, } from '../src/api/http.js';
8
8
  import { formatCliHelpText } from '../src/help-text.js';
9
9
  import { createIndex } from '../src/project-index.js';
10
10
  import { createSession } from '../src/session.js';
@@ -268,6 +268,12 @@ export async function main() {
268
268
  printSessionExit(session);
269
269
  }
270
270
  main().catch((error) => {
271
- console.error(chalk.red(`\n✖ Error: ${error.message}\n`));
271
+ if (isAuthenticationError(error)) {
272
+ auth.clearCliAuthConfig();
273
+ console.error(chalk.red(`\n✖ Error: ${authenticationErrorMessage(error)}\n`));
274
+ }
275
+ else {
276
+ console.error(chalk.red(`\n✖ Error: ${error.message}\n`));
277
+ }
272
278
  process.exit(1);
273
279
  });
@@ -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, retryTransient, } from './http.js';
4
+ import { ServerApiError, authorizedJson, createTraceContext, failureCode, 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) {
@@ -78,6 +78,6 @@ export async function logoutFromServer({ config, fetchImpl = globalThis.fetch, }
78
78
  });
79
79
  if (!response.ok && response.status !== 401) {
80
80
  const data = (await readJsonResponse(response));
81
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
81
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
82
82
  }
83
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 { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
5
+ import { ServerApiError, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
6
6
  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;
@@ -56,7 +56,7 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
56
56
  });
57
57
  const data = (await readJsonResponse(response));
58
58
  if (!response.ok) {
59
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
59
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
60
60
  }
61
61
  const token = String(data?.token ?? '').trim();
62
62
  const customer = data?.customer;
@@ -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
- constructor(message, status, traceId) {
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
  }
@@ -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;
@@ -75,7 +75,7 @@ export async function fetchServerModels({ config, fetchImpl = globalThis.fetch,
75
75
  });
76
76
  const data = (await readJsonResponse(response));
77
77
  if (!response.ok) {
78
- 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));
79
79
  }
80
80
  const models = Array.isArray(data?.models)
81
81
  ? data.models.map(sanitizeModelInfo).filter(Boolean)
@@ -57,7 +57,8 @@ const HELP_MARKDOWN = [
57
57
  '## Keys & clipboard',
58
58
  '',
59
59
  '- **Enter** sends • **Shift+Tab** cycles modes • **Esc** cancels the turn •',
60
- ' **Ctrl+C** quits. These are the same on macOS, Linux, and Windows.',
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.',
61
62
  `- **Paste** into the composer with your terminal's paste shortcut (\`${PASTE_SHORTCUT}\``,
62
63
  ' on this system) or by right-clicking the composer.',
63
64
  '- **Copy** from the transcript by dragging to select; double-click copies a',
@@ -11,6 +11,8 @@ import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
11
11
  import { setScratchSession } from '../scratch-dir.js';
12
12
  import { cancelActiveCommand } from '../executor.js';
13
13
  import { isTurnFailureMarker } from '../turn-failure-marker.js';
14
+ import { clearCliAuthConfig } from '../api/auth.js';
15
+ import { authenticationErrorMessage, isAuthenticationError, } from '../api/http.js';
14
16
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
15
17
  import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
16
18
  import { clearConversation, } from '../session.js';
@@ -1290,6 +1292,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1290
1292
  if (process.stdout.isTTY !== true) {
1291
1293
  throw new Error('stdout is not a terminal');
1292
1294
  }
1295
+ let fatalError = null;
1293
1296
  await withTuiMode(async () => {
1294
1297
  setBackgroundJobSession(session.sessionId);
1295
1298
  setScratchSession(session.sessionId);
@@ -1562,14 +1565,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1562
1565
  scheduleLiveFrameRemount();
1563
1566
  void remountTui();
1564
1567
  };
1565
- const saveActiveSession = async () => {
1566
- try {
1567
- await saveSessionBoth({ serverSessionClient, session });
1568
- }
1569
- catch (error) {
1570
- appendError(`Session save failed: ${error.message}`);
1571
- }
1572
- };
1573
1568
  const syncShellStateFromSession = () => {
1574
1569
  store.update((current) => ({
1575
1570
  ...current,
@@ -1661,6 +1656,27 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1661
1656
  resolveDone?.();
1662
1657
  });
1663
1658
  };
1659
+ const exitForAuthenticationError = (error) => {
1660
+ if (!isAuthenticationError(error))
1661
+ return false;
1662
+ clearCliAuthConfig(session.env);
1663
+ saveSessionState(session);
1664
+ fatalError = new Error(authenticationErrorMessage(error));
1665
+ requestExit();
1666
+ return true;
1667
+ };
1668
+ const saveActiveSession = async () => {
1669
+ try {
1670
+ await saveSessionBoth({ serverSessionClient, session });
1671
+ return true;
1672
+ }
1673
+ catch (error) {
1674
+ if (exitForAuthenticationError(error))
1675
+ return false;
1676
+ appendError(`Session save failed: ${error.message}`);
1677
+ return true;
1678
+ }
1679
+ };
1664
1680
  const openSudoPasswordPrompt = (command, prompt, signal) => new Promise((resolve) => {
1665
1681
  if (exiting || signal?.aborted) {
1666
1682
  resolve(null);
@@ -1797,7 +1813,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1797
1813
  selectedModelId: selected,
1798
1814
  serverModels: fresh,
1799
1815
  });
1800
- await saveActiveSession();
1816
+ if (!(await saveActiveSession()))
1817
+ return;
1801
1818
  syncShellStateFromSession();
1802
1819
  appendStaticEntry({
1803
1820
  body: `Switched to ${formatModelLabel(selected, fresh.models)}. Conversation history preserved.`,
@@ -1916,6 +1933,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1916
1933
  await switchToModel(selected.id);
1917
1934
  }
1918
1935
  catch (error) {
1936
+ if (exitForAuthenticationError(error))
1937
+ return;
1919
1938
  appendError(error.message);
1920
1939
  }
1921
1940
  };
@@ -1999,6 +2018,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1999
2018
  await remountTui();
2000
2019
  }
2001
2020
  catch (error) {
2021
+ if (exitForAuthenticationError(error))
2022
+ return;
2002
2023
  store.update((next) => ({
2003
2024
  ...next,
2004
2025
  busy: false,
@@ -2151,14 +2172,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2151
2172
  });
2152
2173
  }
2153
2174
  catch (error) {
2175
+ if (exitForAuthenticationError(error))
2176
+ return;
2154
2177
  appendError(error.message);
2155
2178
  }
2156
2179
  finally {
2157
- store.update((current) => ({
2158
- ...current,
2159
- busy: false,
2160
- status: 'Ready',
2161
- }));
2180
+ if (!exiting) {
2181
+ store.update((current) => ({
2182
+ ...current,
2183
+ busy: false,
2184
+ status: 'Ready',
2185
+ }));
2186
+ }
2162
2187
  }
2163
2188
  return;
2164
2189
  }
@@ -2172,14 +2197,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2172
2197
  await openResumePicker();
2173
2198
  }
2174
2199
  catch (error) {
2200
+ if (exitForAuthenticationError(error))
2201
+ return;
2175
2202
  appendError(error.message);
2176
2203
  }
2177
2204
  finally {
2178
- store.update((current) => ({
2179
- ...current,
2180
- busy: false,
2181
- status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2182
- }));
2205
+ if (!exiting) {
2206
+ store.update((current) => ({
2207
+ ...current,
2208
+ busy: false,
2209
+ status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2210
+ }));
2211
+ }
2183
2212
  }
2184
2213
  return;
2185
2214
  }
@@ -2199,14 +2228,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2199
2228
  }
2200
2229
  }
2201
2230
  catch (error) {
2231
+ if (exitForAuthenticationError(error))
2232
+ return;
2202
2233
  appendError(error.message);
2203
2234
  }
2204
2235
  finally {
2205
- store.update((current) => ({
2206
- ...current,
2207
- busy: false,
2208
- status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2209
- }));
2236
+ if (!exiting) {
2237
+ store.update((current) => ({
2238
+ ...current,
2239
+ busy: false,
2240
+ status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2241
+ }));
2242
+ }
2210
2243
  }
2211
2244
  return;
2212
2245
  }
@@ -2215,7 +2248,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2215
2248
  clearTodos();
2216
2249
  syncTodosState();
2217
2250
  latestUsageSummary = null;
2218
- await saveActiveSession();
2251
+ if (!(await saveActiveSession()))
2252
+ return;
2219
2253
  store.replaceTranscript([
2220
2254
  {
2221
2255
  body: 'Conversation cleared.',
@@ -2274,7 +2308,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2274
2308
  return;
2275
2309
  latestUsageSummary = result.usageSummary ?? null;
2276
2310
  const turnEntries = takePendingTurnEntries();
2277
- await saveActiveSession();
2311
+ if (!(await saveActiveSession()))
2312
+ return;
2278
2313
  syncShellStateFromSession();
2279
2314
  store.update((current) => ({
2280
2315
  ...current,
@@ -2325,6 +2360,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2325
2360
  return;
2326
2361
  }
2327
2362
  const cancelled = isTurnCancelledError(error);
2363
+ if (exitForAuthenticationError(error))
2364
+ return;
2328
2365
  store.update((current) => ({
2329
2366
  ...current,
2330
2367
  activeTurnInput: '',
@@ -2349,7 +2386,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2349
2386
  title: 'System',
2350
2387
  },
2351
2388
  ]);
2352
- await saveActiveSession();
2389
+ if (!(await saveActiveSession()))
2390
+ return;
2353
2391
  }
2354
2392
  else {
2355
2393
  appendStaticEntries(turnEntries);
@@ -2581,4 +2619,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2581
2619
  setCommandOutputHook(null);
2582
2620
  }
2583
2621
  });
2622
+ if (fatalError)
2623
+ throw fatalError;
2584
2624
  }
@@ -405,15 +405,18 @@ function composerFooterLines(state) {
405
405
  }));
406
406
  return lines;
407
407
  }
408
+ const busyHelperText = state.queuedMessage
409
+ ? 'Enter re-queues • ↑ edit queued • Esc / Ctrl+C clear queued'
410
+ : state.input
411
+ ? 'Enter queues • Esc cancels turn • Ctrl+C clears draft'
412
+ : 'Enter queues • Esc / Ctrl+C cancel turn';
408
413
  const helperText = state.busy
409
- ? state.queuedMessage
410
- ? 'Enter re-queues • ↑ edit queued • Esc cancels queued'
411
- : 'Enter queues • Esc / Ctrl+C cancel turn'
414
+ ? busyHelperText
412
415
  : process.platform === 'win32'
413
- ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C quits'
416
+ ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C clears / quits'
414
417
  : process.platform === 'darwin'
415
- ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits'
416
- : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits';
418
+ ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits'
419
+ : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits';
417
420
  const agentLabel = agentModeLabel(state.agentMode).padEnd(AGENT_MODE_LABEL_WIDTH);
418
421
  const tokenUsageText = state.tokenUsage || formatClientTokenUsage(null);
419
422
  const footerSpans = [
@@ -9,6 +9,17 @@ function isClipboardImagePasteKey(key) {
9
9
  }
10
10
  return key.ctrl && key.input === 'v' && !key.shift && !key.meta;
11
11
  }
12
+ function composerIsEmpty(state) {
13
+ return (!state.input &&
14
+ state.cursor === 0 &&
15
+ state.pastedChunks.length === 0 &&
16
+ state.promptHistoryCursor === null);
17
+ }
18
+ function composerHasDiscardableDraft(state) {
19
+ if (!composerIsEmpty(state))
20
+ return true;
21
+ return !state.busy && state.imageAttachments.length > 0;
22
+ }
12
23
  function shouldShowCommandPalette(state) {
13
24
  if (state.busy ||
14
25
  state.exiting ||
@@ -132,11 +143,41 @@ export function handleShellKeyEvent(store, handlers, event) {
132
143
  return;
133
144
  const key = event;
134
145
  const state = store.getState();
146
+ const commandPaletteActive = shouldShowCommandPalette(state);
147
+ const commandSuggestions = commandPaletteActive
148
+ ? getSlashCommandSuggestions(state.input)
149
+ : [];
150
+ const prepareForComposerInputChange = (current, nextInput) => {
151
+ if (shouldRemountLiveFrameForComposerInputChange(current, nextInput)) {
152
+ handlers.onLiveFrameShapeChange();
153
+ }
154
+ };
135
155
  if (key.ctrl && key.input === 'c') {
136
156
  if (state.sudoPrompt) {
137
157
  handlers.onSudoPasswordInput({ kind: 'cancel' });
138
158
  return;
139
159
  }
160
+ if (state.queuedMessage) {
161
+ handlers.onLiveFrameShapeChange();
162
+ store.update((current) => ({ ...current, queuedMessage: null }));
163
+ return;
164
+ }
165
+ if (composerHasDiscardableDraft(state)) {
166
+ store.update((current) => {
167
+ prepareForComposerInputChange(current, '');
168
+ return {
169
+ ...current,
170
+ commandCursor: 0,
171
+ cursor: 0,
172
+ imageAttachments: current.busy ? current.imageAttachments : [],
173
+ input: '',
174
+ pastedChunks: [],
175
+ promptHistoryCursor: null,
176
+ promptHistoryDraft: '',
177
+ };
178
+ });
179
+ return;
180
+ }
140
181
  if (handlers.onCtrlC) {
141
182
  handlers.onCtrlC();
142
183
  return;
@@ -144,15 +185,6 @@ export function handleShellKeyEvent(store, handlers, event) {
144
185
  handlers.onRequestExit();
145
186
  return;
146
187
  }
147
- const commandPaletteActive = shouldShowCommandPalette(state);
148
- const commandSuggestions = commandPaletteActive
149
- ? getSlashCommandSuggestions(state.input)
150
- : [];
151
- const prepareForComposerInputChange = (current, nextInput) => {
152
- if (shouldRemountLiveFrameForComposerInputChange(current, nextInput)) {
153
- handlers.onLiveFrameShapeChange();
154
- }
155
- };
156
188
  if (state.sudoPrompt) {
157
189
  if (key.escape) {
158
190
  handlers.onSudoPasswordInput({ kind: 'cancel' });
@@ -317,10 +349,7 @@ export function handleShellKeyEvent(store, handlers, event) {
317
349
  return;
318
350
  }
319
351
  store.update((current) => {
320
- if (!current.input &&
321
- current.cursor === 0 &&
322
- current.pastedChunks.length === 0 &&
323
- current.promptHistoryCursor === null) {
352
+ if (composerIsEmpty(current)) {
324
353
  return current;
325
354
  }
326
355
  prepareForComposerInputChange(current, '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.5",
3
+ "version": "1.0.0-preview.7",
4
4
  "description": "TheGitAI is an AI coding agent for your terminal. It indexes your repository, writes and edits files, runs commands, and builds features with you.",
5
5
  "keywords": [
6
6
  "ai",
@@ -37,10 +37,10 @@
37
37
  "@lydell/node-pty-linux-x64": "1.1.0",
38
38
  "@lydell/node-pty-win32-arm64": "1.1.0",
39
39
  "@lydell/node-pty-win32-x64": "1.1.0",
40
- "@thegitai/tui-darwin-arm64": "1.0.0-preview.5",
41
- "@thegitai/tui-darwin-x64": "1.0.0-preview.5",
42
- "@thegitai/tui-linux-x64": "1.0.0-preview.5",
43
- "@thegitai/tui-win32-x64": "1.0.0-preview.5",
40
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.7",
41
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.7",
42
+ "@thegitai/tui-linux-x64": "1.0.0-preview.7",
43
+ "@thegitai/tui-win32-x64": "1.0.0-preview.7",
44
44
  "@vscode/ripgrep": "1.18.0"
45
45
  },
46
46
  "publishConfig": {