@thegitai/cli 1.0.0-beta.9 → 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.
Files changed (64) hide show
  1. package/README.md +49 -3
  2. package/dist/bin/ai.js +83 -197
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +4 -4
  6. package/dist/src/api/browser-login.js +72 -19
  7. package/dist/src/api/chat.js +182 -35
  8. package/dist/src/api/http.js +65 -4
  9. package/dist/src/api/models.js +33 -22
  10. package/dist/src/artifact-policy.js +3 -0
  11. package/dist/src/background-jobs.js +410 -0
  12. package/dist/src/cli-args.js +0 -5
  13. package/dist/src/client-environment.js +2 -0
  14. package/dist/src/colors.js +50 -0
  15. package/dist/src/core/clipboard.js +19 -0
  16. package/dist/src/core/image-path-extractor.js +144 -0
  17. package/dist/src/executor.js +48 -12
  18. package/dist/src/help-text.js +30 -13
  19. package/dist/src/patcher.js +97 -12
  20. package/dist/src/project-index.js +13 -1
  21. package/dist/src/project-orientation.js +99 -0
  22. package/dist/src/scanner.js +50 -12
  23. package/dist/src/scratch-dir.js +75 -0
  24. package/dist/src/secret-preview.js +0 -10
  25. package/dist/src/session-safety.js +0 -19
  26. package/dist/src/session-store.js +52 -21
  27. package/dist/src/session.js +8 -0
  28. package/dist/src/todo-list.js +106 -0
  29. package/dist/src/tool-executor.js +194 -21
  30. package/dist/src/tools/delete-file.js +23 -5
  31. package/dist/src/tools/index.js +6 -0
  32. package/dist/src/tools/patch-file.js +33 -7
  33. package/dist/src/tools/path-suggest.js +81 -8
  34. package/dist/src/tools/read-document.js +2 -2
  35. package/dist/src/tools/read-file.js +17 -8
  36. package/dist/src/tools/replace-document-text.js +10 -12
  37. package/dist/src/tools/restore-checkpoint.js +1 -1
  38. package/dist/src/tools/run-command.js +109 -24
  39. package/dist/src/tools/run-node-script.js +27 -5
  40. package/dist/src/tools/shell-job-kill.js +48 -0
  41. package/dist/src/tools/shell-job-output.js +51 -0
  42. package/dist/src/tools/str-replace.js +33 -7
  43. package/dist/src/tools/undo-edit.js +1 -1
  44. package/dist/src/tools/update-todos.js +27 -0
  45. package/dist/src/tools/write-file.js +26 -6
  46. package/dist/src/tree-sitter-runtime.js +8 -1
  47. package/dist/src/turn-failure-marker.js +11 -0
  48. package/dist/src/ui/prompt-history-store.js +1 -1
  49. package/dist/src/ui/repl.js +500 -71
  50. package/dist/src/ui/tui/bridge.js +3 -4
  51. package/dist/src/ui/tui/build-frame.js +393 -100
  52. package/dist/src/ui/tui/markdown-render.js +72 -73
  53. package/dist/src/ui/tui/shell-input.js +75 -17
  54. package/dist/src/ui/tui/terminal-title.js +84 -0
  55. package/dist/src/ui/tui/terminal-writes.js +48 -0
  56. package/dist/src/ui/tui/text.js +158 -4
  57. package/dist/src/utils.js +9 -0
  58. package/dist/src/version.js +0 -6
  59. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  60. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  61. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  62. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  63. package/package.json +27 -16
  64. package/dist/src/markdown-renderer.js +0 -112
package/README.md CHANGED
@@ -1,7 +1,10 @@
1
- # TheGitAI
1
+ # TheGitAI — AI coding agent for your terminal
2
2
 
3
- Interactive terminal coding agent. Talk to your repo in plain English — it
4
- reads code, edits files, and runs commands with your approval.
3
+ TheGitAI is an AI coding agent for your terminal. It indexes your repository,
4
+ writes and edits files, runs commands, and builds features with you.
5
+
6
+ Talk to your repo in plain English. TheGitAI can keep long-running processes
7
+ going while you work, all with your approval.
5
8
 
6
9
  ## Install
7
10
 
@@ -25,6 +28,49 @@ ai --version print the version and exit
25
28
 
26
29
  Run `ai --help` for sessions, modes, keys, and chat commands.
27
30
 
31
+ Coding sessions require an interactive terminal on stdin and stdout. Piped
32
+ one-shot prompts are not supported. Saved history is local to the repo and can
33
+ always be viewed or resumed from that computer. Continuing it through the
34
+ service requires the TheGitAI account used for that session. Local sessions can
35
+ also be listed while signed out or offline.
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
+
41
+ ## Visible to-do list
42
+
43
+ For larger multi-step tasks, the agent keeps a compact to-do list on screen so
44
+ you can see what it plans to do, what it is working on right now, and what is
45
+ already done.
46
+
47
+ - While the agent works, the list sits at the bottom of the live **Working**
48
+ area, right above where you type, and updates as steps start and finish —
49
+ one step in progress at a time — so it stays visible even when tool output
50
+ above it runs long.
51
+ - When the turn ends, a final snapshot of the list stays readable in the
52
+ transcript, and the footer shows a small progress chip (e.g. `◐ 4/6 to-dos`)
53
+ while steps remain open.
54
+ - The agent's current reasoning stays visible too, right below the list, in a
55
+ compact one-line form so it doesn't compete with the list for space.
56
+ - The list is managed entirely by the agent; simple one-step requests skip it.
57
+
58
+ ## Background jobs
59
+
60
+ Some commands are meant to keep running — a dev server, a file watcher, a local
61
+ API. TheGitAI runs these as **background jobs**, so the agent can start a server,
62
+ work against it live, and stop it when the task is done, all in one session.
63
+
64
+ - Each job's block in the transcript updates on its own with a live tail of its
65
+ latest output, and a compact indicator keeps you aware of what's still running.
66
+ - Type `/jobs` to open a picker: **↑ / ↓** to move, **Enter** to expand a job and
67
+ read its output inline, **k** to stop it, **Esc** to close.
68
+ - `/jobs output <id>` prints a job's full output, and `/jobs kill <id>` stops one
69
+ by id.
70
+ - When you end the session, TheGitAI stops its background jobs for you.
71
+
72
+ Full documentation: <https://thegit.ai/docs>
73
+
28
74
  ## License
29
75
 
30
76
  Proprietary — see the LICENSE file included in this package. Source is
package/dist/bin/ai.js CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import chalk from 'chalk';
2
+ import chalk from '../src/colors.js';
3
3
  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 { formatCliHelpText, formatInteractiveHelpText, } from '../src/help-text.js';
8
- import { renderMarkdownForTerminal } from '../src/markdown-renderer.js';
7
+ import { authenticationErrorMessage, isAuthenticationError, isTransientNetworkError, } from '../src/api/http.js';
8
+ import { formatCliHelpText } from '../src/help-text.js';
9
9
  import { createIndex } from '../src/project-index.js';
10
- import { createSession, clearConversation } from '../src/session.js';
11
- import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../src/session-store.js';
12
- import { runClientInteractive, shouldUseClientRatatuiShell, } from '../src/ui/repl.js';
10
+ import { createSession } from '../src/session.js';
11
+ import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, sessionHasUserMessage, } from '../src/session-store.js';
12
+ import { runClientInteractive } from '../src/ui/repl.js';
13
13
  import { appendPromptToFile } from '../src/ui/prompt-history-store.js';
14
14
  import { formatSessionExitNotice } from '../src/session-exit.js';
15
15
  import { formatUsageText } from '../src/usage.js';
@@ -36,9 +36,6 @@ function appendPromptHistory(prompt, env = process.env) {
36
36
  }
37
37
  async function runAuthCommand(command, args) {
38
38
  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
39
  const serverUrl = DEFAULT_SERVER_URL;
43
40
  const noBrowser = args.includes('--no-browser');
44
41
  console.log(chalk.dim(noBrowser
@@ -111,45 +108,16 @@ function printSessionList(rootDir, sessions, serverModels) {
111
108
  }
112
109
  }
113
110
  }
114
- function printStartupBanner(rootDir, modelLabel, autoYes) {
115
- console.log(chalk.dim(`Project: ${rootDir}`));
116
- console.log(chalk.dim(`Model: ${modelLabel}`));
117
- if (autoYes) {
118
- console.log(chalk.dim('Auto-confirm: enabled'));
119
- }
120
- }
121
- function printSessionStartup(session) {
122
- console.log(chalk.dim(`Session: ${session.sessionName ? `${session.sessionName} ` : ''}${session.sessionId}`));
123
- }
124
111
  function printSessionExit(session) {
125
112
  console.log(chalk.dim(`\n${formatSessionExitNotice(session.sessionId)}\n`));
126
113
  }
127
114
  function modelLabel(serverModels, modelId) {
128
- return (serverModels.models.find((model) => model.id === modelId)?.label ??
129
- 'Unknown model');
130
- }
131
- function makeConfirmCommand(session) {
132
- return async (command) => {
133
- console.log(chalk.bold(`\nCommand approval needed:\n${command}\n`));
134
- const answer = (await promptText('Approve? [y]es/[a]ll/[n]o', 'n')).toLowerCase();
135
- if (answer === 'a' || answer === 'all') {
136
- session.autoYes = true;
137
- return true;
138
- }
139
- return answer === 'y' || answer === 'yes';
140
- };
141
- }
142
- function makeConfirmPatch(session) {
143
- return async (filePath) => {
144
- const answer = (await promptText(`Apply patch to ${filePath}? [y]es/[a]ll/[n]o`, 'n')).toLowerCase();
145
- if (answer === 'a' || answer === 'all') {
146
- session.autoYes = true;
147
- return true;
148
- }
149
- return answer === 'y' || answer === 'yes';
150
- };
115
+ return (serverModels?.models.find((model) => model.id === modelId)?.label ??
116
+ `Model ${modelId}`);
151
117
  }
152
118
  async function saveSessionBoth({ session, serverSessionClient, }) {
119
+ if (!sessionHasUserMessage(session))
120
+ return;
153
121
  saveSessionState(session);
154
122
  try {
155
123
  await serverSessionClient.save(session);
@@ -158,133 +126,18 @@ async function saveSessionBoth({ session, serverSessionClient, }) {
158
126
  console.error(chalk.yellow(`Warning: session save failed: ${error.message}`));
159
127
  }
160
128
  }
161
- async function promptForModelSelection(currentModelId, authConfig) {
162
- const serverModels = await models.fetchServerModels({ config: authConfig });
163
- console.log(chalk.bold('\nAvailable models'));
164
- for (const model of serverModels.models) {
165
- const marker = model.id === currentModelId ? '*' : ' ';
166
- console.log(`${marker} ${model.id} ${model.label}`);
167
- }
168
- console.log();
169
- const selected = (await promptText('Model id', '')).trim() || null;
170
- if (!selected) {
171
- return { selected: null, serverModels };
172
- }
173
- return {
174
- selected: models.validateServerModel(selected, serverModels),
175
- serverModels,
176
- };
177
- }
178
- async function promptForResumeSession(rootDir, serverModels) {
179
- const sessions = listSessionMetadata(rootDir);
180
- if (!sessions.length) {
181
- console.log(chalk.dim('No saved sessions for this repo.\n'));
182
- return null;
183
- }
184
- printSessionList(rootDir, sessions, serverModels);
185
- console.log();
186
- const identifier = (await promptText('Session id or name', '')).trim();
187
- if (!identifier) {
188
- return null;
189
- }
190
- return loadSessionSnapshot(rootDir, identifier);
191
- }
192
- async function runTurn({ authConfig, projectIndex, serverSessionClient, session, inputText, }) {
193
- const prompt = String(inputText ?? '').trim();
194
- if (!prompt)
195
- return;
196
- appendPromptHistory(prompt, session.env);
197
- const result = await chat.sendServerUserMessage({
198
- config: authConfig,
199
- projectIndex,
200
- session,
201
- input: prompt,
202
- });
203
- if (result.text) {
204
- console.log(`\n${chalk.green('TheGitAI>')}`);
205
- console.log(renderMarkdownForTerminal(result.text));
206
- console.log();
207
- }
208
- await saveSessionBoth({ session, serverSessionClient });
209
- }
210
- async function mainInteractive({ authConfig, projectIndex, serverModels, serverSessionClient, session, usageText, initialPrompt, }) {
211
- if (initialPrompt) {
212
- console.log(chalk.dim(`Prompt: "${initialPrompt}"`));
213
- await runTurn({
214
- authConfig,
215
- projectIndex,
216
- serverSessionClient,
217
- session,
218
- inputText: initialPrompt,
219
- });
129
+ function requireInteractiveTerminal() {
130
+ if (process.stdin.isTTY !== true) {
131
+ console.error('Error: stdin is not a terminal');
132
+ process.exitCode = 1;
133
+ return false;
220
134
  }
221
- while (true) {
222
- const inputText = await promptText('you');
223
- const trimmed = inputText.trim();
224
- if (!trimmed)
225
- continue;
226
- if (trimmed === '/exit')
227
- return;
228
- if (trimmed === '/help') {
229
- console.log(renderMarkdownForTerminal(formatInteractiveHelpText()));
230
- continue;
231
- }
232
- if (trimmed === '/usage') {
233
- console.log(await usageText());
234
- continue;
235
- }
236
- if (trimmed === '/clear') {
237
- clearConversation(session);
238
- await saveSessionBoth({ session, serverSessionClient });
239
- console.log(chalk.dim('Conversation cleared.\n'));
240
- continue;
241
- }
242
- if (trimmed === '/resume') {
243
- const snapshot = await promptForResumeSession(session.rootDir, serverModels);
244
- if (!snapshot) {
245
- console.log(chalk.dim('Resume cancelled.\n'));
246
- continue;
247
- }
248
- applySessionSnapshot(session, snapshot);
249
- await saveSessionBoth({ session, serverSessionClient });
250
- console.log(chalk.dim(`Resumed session${session.sessionName ? ` "${session.sessionName}"` : ''} (${session.sessionId})\n`));
251
- continue;
252
- }
253
- if (trimmed === '/model' || trimmed.startsWith('/model ')) {
254
- const inline = trimmed.slice('/model'.length).trim();
255
- let serverModels;
256
- let selected = null;
257
- if (inline) {
258
- serverModels = await models.fetchServerModels({ config: authConfig });
259
- selected = models.validateServerModel(inline, serverModels);
260
- }
261
- else {
262
- const response = await promptForModelSelection(session.modelId, authConfig);
263
- serverModels = response.serverModels;
264
- selected = response.selected;
265
- }
266
- if (!selected) {
267
- console.log(chalk.dim('Model selection cancelled.\n'));
268
- continue;
269
- }
270
- session.modelId = selected;
271
- models.updateSelectedModelCache({
272
- config: authConfig,
273
- selectedModelId: selected,
274
- serverModels,
275
- });
276
- await saveSessionBoth({ session, serverSessionClient });
277
- console.log(chalk.dim(`Switched to ${modelLabel(serverModels, selected)}.\n`));
278
- continue;
279
- }
280
- await runTurn({
281
- authConfig,
282
- projectIndex,
283
- serverSessionClient,
284
- session,
285
- inputText: trimmed,
286
- });
135
+ if (process.stdout.isTTY !== true) {
136
+ console.error('Error: stdout is not a terminal');
137
+ process.exitCode = 1;
138
+ return false;
287
139
  }
140
+ return true;
288
141
  }
289
142
  export async function main() {
290
143
  const { autoYes, help, version, usage, command, commandArgs, session: sessionIdentifier, listSessions, unknownOption, prompt, } = parseArgs(process.argv);
@@ -312,18 +165,64 @@ export async function main() {
312
165
  return;
313
166
  }
314
167
  const rootDir = process.cwd();
315
- const authConfig = requireCliAuthConfig();
316
- const serverSessionClient = sessions.createServerSessionClient({ config: authConfig });
317
- const cachedModels = models.readCachedServerModels();
318
- const serverModels = await models.fetchServerModels({ config: authConfig });
319
- const whoami = await auth.fetchWhoamiResponse({ config: authConfig });
320
168
  if (listSessions) {
321
- printSessionList(rootDir, listSessionMetadata(rootDir), serverModels);
169
+ const activeServerUrl = auth.readCliAuthConfig()?.serverUrl ?? DEFAULT_SERVER_URL;
170
+ printSessionList(rootDir, listSessionMetadata(rootDir), models.selectCacheForServer(models.readCachedServerModels(), activeServerUrl));
322
171
  return;
323
172
  }
324
173
  const sourceSnapshot = sessionIdentifier
325
174
  ? loadSessionSnapshot(rootDir, sessionIdentifier)
326
175
  : null;
176
+ if (sessionIdentifier && !sourceSnapshot) {
177
+ console.error(`Error: No saved session named or identified by "${sessionIdentifier}" is available for this repo. Run \`ai --list-sessions\`.`);
178
+ process.exitCode = 1;
179
+ return;
180
+ }
181
+ if (!requireInteractiveTerminal()) {
182
+ return;
183
+ }
184
+ const authConfig = requireCliAuthConfig();
185
+ const serverSessionClient = sessions.createServerSessionClient({ config: authConfig });
186
+ const cachedModels = models.selectCacheForServer(models.readCachedServerModels(), authConfig.serverUrl);
187
+ let offlineNotice = null;
188
+ let serverModels;
189
+ try {
190
+ serverModels = await models.fetchServerModels({ config: authConfig });
191
+ }
192
+ catch (error) {
193
+ if (isTransientNetworkError(error) && cachedModels?.models.length) {
194
+ serverModels = { models: cachedModels.models };
195
+ offlineNotice = error?.message ? String(error.message) : 'network error';
196
+ }
197
+ else {
198
+ throw error;
199
+ }
200
+ }
201
+ let whoami;
202
+ try {
203
+ whoami = await auth.fetchWhoamiResponse({ config: authConfig });
204
+ }
205
+ catch (error) {
206
+ if (isTransientNetworkError(error)) {
207
+ whoami = {
208
+ customer: {
209
+ id: '',
210
+ uuid: '',
211
+ email: authConfig.email,
212
+ customer_type: authConfig.customerType ?? 'USER',
213
+ scopes: [],
214
+ },
215
+ debugUi: { showSessionId: false },
216
+ };
217
+ offlineNotice ??= error?.message ? String(error.message) : 'network error';
218
+ }
219
+ else {
220
+ throw error;
221
+ }
222
+ }
223
+ if (offlineNotice) {
224
+ console.error(chalk.yellow(`⚠ Couldn't reach TheGitAI (${offlineNotice}). Starting with cached settings — it will reconnect on your next message.`));
225
+ }
327
226
  const selectedModelId = models.selectServerModel({
328
227
  requestedModelId: sourceSnapshot?.modelId ?? null,
329
228
  cached: cachedModels,
@@ -339,11 +238,8 @@ export async function main() {
339
238
  autoYes,
340
239
  modelId: selectedModelId,
341
240
  });
342
- session.confirmCommand = makeConfirmCommand(session);
343
- session.confirmPatch = makeConfirmPatch(session);
344
241
  if (sourceSnapshot) {
345
242
  applySessionSnapshot(session, sourceSnapshot);
346
- await saveSessionBoth({ session, serverSessionClient });
347
243
  }
348
244
  const projectIndex = createIndex({
349
245
  rootDir,
@@ -359,37 +255,27 @@ export async function main() {
359
255
  },
360
256
  });
361
257
  const initialPrompt = prompt || undefined;
362
- if (shouldUseClientRatatuiShell()) {
363
- await runClientInteractive({
364
- appendPromptHistory: (value) => appendPromptHistory(value, session.env),
365
- authConfig,
366
- debugUi: whoami.debugUi,
367
- projectIndex,
368
- serverModels,
369
- serverSessionClient,
370
- session,
371
- initialPrompt,
372
- usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
373
- });
374
- await saveSessionBoth({ session, serverSessionClient });
375
- printSessionExit(session);
376
- return;
377
- }
378
- printStartupBanner(rootDir, modelLabel(serverModels, session.modelId), session.autoYes);
379
- printSessionStartup(session);
380
- await mainInteractive({
258
+ await runClientInteractive({
259
+ appendPromptHistory: (value) => appendPromptHistory(value, session.env),
381
260
  authConfig,
261
+ debugUi: whoami.debugUi,
382
262
  projectIndex,
383
263
  serverModels,
384
264
  serverSessionClient,
385
265
  session,
386
- usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
387
266
  initialPrompt,
267
+ usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
388
268
  });
389
269
  await saveSessionBoth({ session, serverSessionClient });
390
270
  printSessionExit(session);
391
271
  }
392
272
  main().catch((error) => {
393
- console.error(chalk.red(`\n✖ Error: ${error.message}\n`));
273
+ if (isAuthenticationError(error)) {
274
+ auth.clearCliAuthConfig();
275
+ console.error(chalk.red(`\n✖ Error: ${authenticationErrorMessage(error)}\n`));
276
+ }
277
+ else {
278
+ console.error(chalk.red(`\n✖ Error: ${error.message}\n`));
279
+ }
394
280
  process.exit(1);
395
281
  });
@@ -0,0 +1,18 @@
1
+ Tree-sitter grammar parsers
2
+ ===========================
3
+
4
+ The `tree-sitter-*.wasm` files in this directory are precompiled tree-sitter
5
+ grammar parsers, vendored so the published `@thegitai/cli` package ships local
6
+ code intelligence without a runtime dependency. They are used unmodified, only
7
+ as parsing inputs to the vendored web-tree-sitter runtime
8
+ (see ../vendor/web-tree-sitter/NOTICE).
9
+
10
+ Each grammar is the work of its respective tree-sitter grammar project and is
11
+ distributed under that project's license (the tree-sitter grammars are
12
+ MIT-licensed). Grammars included:
13
+
14
+ c, c-sharp, cpp, css, go, html, java, javascript, objc, php, python, ruby,
15
+ rust, tsx, typescript
16
+
17
+ Upstream organization: https://github.com/tree-sitter
18
+ Individual grammars: https://github.com/tree-sitter/tree-sitter-<language>
@@ -12,6 +12,8 @@ const PLAN_MODE_TOOL_NAMES = new Set([
12
12
  'read_document',
13
13
  'analyze_image',
14
14
  'run_command',
15
+ 'shell_job_output',
16
+ 'update_todos',
15
17
  ]);
16
18
  const PLAN_MODE_RUN_COMMAND_NAMES = new Set([
17
19
  'pwd',
@@ -117,6 +119,9 @@ export function buildAgentModeToolBlockedResult(mode, call) {
117
119
  }
118
120
  if (call.name !== 'run_command')
119
121
  return null;
122
+ if (call.args?.background === true) {
123
+ return buildPlanModeToolBlockedResult(call.name, PLAN_MODE_RUN_COMMAND_ACTION);
124
+ }
120
125
  const command = String(call.args?.command ?? call.args?.cmd ?? '');
121
126
  const reason = planModeRunCommandBlockReason(command);
122
127
  return reason ? buildPlanModeToolBlockedResult(call.name, reason) : null;
@@ -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, 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) {
@@ -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
  }
@@ -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
  }
@@ -1,21 +1,18 @@
1
+ import { execSync } from 'node:child_process';
1
2
  import crypto from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
2
4
  import http from 'node:http';
3
5
  import os from 'node:os';
4
6
  import { openUrl } from '../core/open-url.js';
5
- import { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
7
+ import { ServerApiError, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
6
8
  const DEFAULT_WEBSITE_URL = 'https://thegit.ai';
7
9
  const DEFAULT_SERVER_URL = 'https://thegit.ai';
8
10
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
9
11
  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
12
  server.closeAllConnections?.();
13
13
  server.close();
14
14
  }
15
15
  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
16
  return DEFAULT_WEBSITE_URL.replace(/\/+$/, '');
20
17
  }
21
18
  function defaultDeviceName() {
@@ -26,7 +23,73 @@ function defaultDeviceName() {
26
23
  return os.hostname();
27
24
  }
28
25
  }
29
- /** PKCE (RFC 7636, S256): a random verifier and its SHA-256 challenge. */
26
+ function readLinuxOsPrettyName() {
27
+ try {
28
+ const content = readFileSync('/etc/os-release', 'utf8');
29
+ return content.match(/^PRETTY_NAME="?([^"\n]*)"?$/m)?.[1]?.trim() ?? '';
30
+ }
31
+ catch {
32
+ return '';
33
+ }
34
+ }
35
+ function macArchLabel() {
36
+ try {
37
+ if (process.arch === 'arm64')
38
+ return 'Apple Silicon';
39
+ if (os.cpus().some((cpu) => cpu.model.includes('Apple'))) {
40
+ return 'Apple Silicon';
41
+ }
42
+ }
43
+ catch {
44
+ }
45
+ return 'Intel';
46
+ }
47
+ export function describeOperatingSystem() {
48
+ try {
49
+ if (process.platform === 'linux') {
50
+ const base = readLinuxOsPrettyName() || `Linux ${os.release()}`;
51
+ return process.arch === 'arm64' ? `${base}, ARM64` : base;
52
+ }
53
+ if (process.platform === 'darwin') {
54
+ let version = '';
55
+ try {
56
+ version = execSync('sw_vers -productVersion', {
57
+ stdio: ['ignore', 'pipe', 'ignore'],
58
+ })
59
+ .toString()
60
+ .trim();
61
+ }
62
+ catch {
63
+ }
64
+ return `${version ? `macOS ${version}` : 'macOS'}, ${macArchLabel()}`;
65
+ }
66
+ if (process.platform === 'win32') {
67
+ let label = '';
68
+ try {
69
+ label = os.version();
70
+ }
71
+ catch {
72
+ }
73
+ const build = Number(os.release().split('.')[2] ?? '0');
74
+ if (build >= 22000)
75
+ label = label.replace(/Windows 10/i, 'Windows 11');
76
+ const base = label || `Windows ${os.release()}`;
77
+ return process.arch === 'arm64' ? `${base}, ARM64` : base;
78
+ }
79
+ return `${process.platform} ${os.release()}`;
80
+ }
81
+ catch {
82
+ return process.platform;
83
+ }
84
+ }
85
+ export function withOperatingSystemInfo(name) {
86
+ const trimmed = name.trim();
87
+ const osLabel = describeOperatingSystem();
88
+ const combined = osLabel && !trimmed.includes(osLabel)
89
+ ? `${trimmed} (${osLabel})`
90
+ : trimmed;
91
+ return combined.slice(0, 180);
92
+ }
30
93
  export function generatePkce() {
31
94
  const verifier = crypto.randomBytes(32).toString('base64url');
32
95
  const challenge = crypto
@@ -62,7 +125,7 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
62
125
  });
63
126
  const data = (await readJsonResponse(response));
64
127
  if (!response.ok) {
65
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
128
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
66
129
  }
67
130
  const token = String(data?.token ?? '').trim();
68
131
  const customer = data?.customer;
@@ -77,19 +140,13 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
77
140
  customer,
78
141
  };
79
142
  }
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
143
  export async function loginViaBrowser(options) {
87
144
  const serverUrl = normalizeServerUrl(options.serverUrl ?? DEFAULT_SERVER_URL);
88
145
  const websiteUrl = resolveWebsiteUrl();
89
146
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
90
147
  const openBrowser = options.openBrowser ?? openUrl;
91
148
  const onUrl = options.onUrl ?? (() => { });
92
- const deviceName = options.deviceName ?? defaultDeviceName();
149
+ const deviceName = withOperatingSystemInfo(options.deviceName ?? defaultDeviceName());
93
150
  const { verifier, challenge } = generatePkce();
94
151
  if (options.noBrowser) {
95
152
  const authUrl = buildAuthUrl(websiteUrl, {
@@ -97,8 +154,6 @@ export async function loginViaBrowser(options) {
97
154
  deviceName,
98
155
  paste: true,
99
156
  });
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
157
  onUrl(authUrl);
103
158
  if (!options.promptCode) {
104
159
  throw new Error('No way to read the authorization code in this context.');
@@ -125,8 +180,6 @@ export async function loginViaBrowser(options) {
125
180
  }
126
181
  const code = requestUrl.searchParams.get('code') ?? '';
127
182
  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
183
  if (!code || returnedState !== state) {
131
184
  res.writeHead(400, { 'content-type': 'text/html', connection: 'close' });
132
185
  res.end(RESULT_PAGE('Login failed', 'The request could not be verified. Please run ai login again.'));