@thegitai/cli 1.0.0-preview.2 → 1.0.0-preview.20

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 (38) hide show
  1. package/README.md +32 -4
  2. package/dist/bin/ai.js +57 -291
  3. package/dist/src/agent-mode.js +1 -1
  4. package/dist/src/api/auth.js +2 -2
  5. package/dist/src/api/browser-login.js +72 -3
  6. package/dist/src/api/chat.js +232 -33
  7. package/dist/src/api/contracts.js +55 -1
  8. package/dist/src/api/http.js +16 -3
  9. package/dist/src/api/models.js +9 -4
  10. package/dist/src/executor.js +1 -1
  11. package/dist/src/help-text.js +51 -11
  12. package/dist/src/permissions.js +243 -0
  13. package/dist/src/project-index.js +13 -1
  14. package/dist/src/session-store.js +57 -20
  15. package/dist/src/session.js +14 -3
  16. package/dist/src/tool-executor.js +2 -2
  17. package/dist/src/tools/delete-file.js +14 -0
  18. package/dist/src/tools/patch-file.js +12 -16
  19. package/dist/src/tools/replace-document-text.js +28 -18
  20. package/dist/src/tools/run-command.js +13 -27
  21. package/dist/src/tools/run-node-script.js +11 -26
  22. package/dist/src/tools/str-replace.js +12 -16
  23. package/dist/src/tools/write-file.js +66 -0
  24. package/dist/src/turn-failure-marker.js +11 -0
  25. package/dist/src/ui/prompt-history-store.js +1 -1
  26. package/dist/src/ui/repl.js +569 -151
  27. package/dist/src/ui/tui/bridge.js +10 -0
  28. package/dist/src/ui/tui/build-frame.js +535 -159
  29. package/dist/src/ui/tui/markdown-render.js +81 -73
  30. package/dist/src/ui/tui/shell-input.js +155 -45
  31. package/dist/src/ui/tui/terminal-theme.js +28 -0
  32. package/dist/src/ui/tui/terminal-title.js +3 -0
  33. package/dist/src/ui/tui/terminal-writes.js +48 -0
  34. package/dist/src/ui/tui/text.js +158 -4
  35. package/dist/src/ui/tui/user-input.js +568 -0
  36. package/dist/src/utils.js +9 -0
  37. package/package.json +18 -6
  38. package/dist/src/markdown-renderer.js +0 -112
package/README.md CHANGED
@@ -1,8 +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 reads
4
- your code, makes edits, runs the commands a task needs, and keeps long-running
5
- processes going while you work, all 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.
6
8
 
7
9
  ## Install
8
10
 
@@ -26,6 +28,32 @@ ai --version print the version and exit
26
28
 
27
29
  Run `ai --help` for sessions, modes, keys, and chat commands.
28
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
+ ## Structured questions
42
+
43
+ The agent can pause its current turn to ask up to four related questions in one
44
+ form. Use **↑ / ↓**, the displayed option number, or **Enter** to choose, and
45
+ **← / →** to move between questions. **Something else / add details** is the
46
+ numbered final option; focus it and type, paste, or press **Enter** to add a
47
+ single-line note. On single-select questions it is mutually exclusive with the
48
+ listed choices, and **↑ / ↓** leaves its editor when the note is empty.
49
+ Multi-select questions toggle choices. The last question has an explicit
50
+ **Submit answers** row. **Esc** closes an open note first and otherwise cancels
51
+ the form; **Ctrl+C** cancels the whole turn.
52
+
53
+ Answers return to the same turn, and completed question/answer records remain
54
+ readable when the session is resumed. Default, Auto-Accept, and Plan modes all
55
+ support the form; Auto-Accept does not choose answers for you.
56
+
29
57
  ## Visible to-do list
30
58
 
31
59
  For larger multi-step tasks, the agent keeps a compact to-do list on screen so
package/dist/bin/ai.js CHANGED
@@ -4,21 +4,17 @@ 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';
8
- import { formatCliHelpText, formatInteractiveHelpText, } from '../src/help-text.js';
9
- 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';
10
9
  import { createIndex } from '../src/project-index.js';
11
- import { createSession, clearConversation } from '../src/session.js';
12
- import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../src/session-store.js';
13
- 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';
14
13
  import { appendPromptToFile } from '../src/ui/prompt-history-store.js';
15
14
  import { formatSessionExitNotice } from '../src/session-exit.js';
16
15
  import { formatUsageText } from '../src/usage.js';
17
16
  import { formatVersionLine } from '../src/version.js';
18
17
  import { parseArgs } from '../src/cli-args.js';
19
- import { getJobBufferedOutput, killBackgroundJob, killAllBackgroundJobs, listBackgroundJobs, setBackgroundJobSession, } from '../src/background-jobs.js';
20
- import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../src/tool-executor.js';
21
- import { setScratchSession } from '../src/scratch-dir.js';
22
18
  const DEFAULT_SERVER_URL = 'https://thegit.ai';
23
19
  const { auth, chat, models, sessions } = ServerApi;
24
20
  function printUsage() {
@@ -112,54 +108,16 @@ function printSessionList(rootDir, sessions, serverModels) {
112
108
  }
113
109
  }
114
110
  }
115
- function printStartupBanner(rootDir, modelLabel, autoYes) {
116
- console.log(chalk.dim(`Project: ${rootDir}`));
117
- console.log(chalk.dim(`Model: ${modelLabel}`));
118
- if (autoYes) {
119
- console.log(chalk.dim('Auto-confirm: enabled'));
120
- }
121
- }
122
- function printSessionStartup(session) {
123
- console.log(chalk.dim(`Session: ${session.sessionName ? `${session.sessionName} ` : ''}${session.sessionId}`));
124
- }
125
111
  function printSessionExit(session) {
126
112
  console.log(chalk.dim(`\n${formatSessionExitNotice(session.sessionId)}\n`));
127
113
  }
128
114
  function modelLabel(serverModels, modelId) {
129
- return (serverModels.models.find((model) => model.id === modelId)?.label ??
130
- 'Unknown model');
131
- }
132
- function formatJobElapsedMs(ms) {
133
- const totalSeconds = Math.max(0, Math.floor(ms / 1000));
134
- if (totalSeconds < 60)
135
- return `${totalSeconds}s`;
136
- const minutes = Math.floor(totalSeconds / 60);
137
- if (minutes < 60)
138
- return `${minutes}m${String(totalSeconds % 60).padStart(2, '0')}s`;
139
- return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
140
- }
141
- function makeConfirmCommand(session) {
142
- return async (command) => {
143
- console.log(chalk.bold(`\nCommand approval needed:\n${command}\n`));
144
- const answer = (await promptText('Approve? [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
- };
151
- }
152
- function makeConfirmPatch(session) {
153
- return async (filePath) => {
154
- const answer = (await promptText(`Apply patch to ${filePath}? [y]es/[a]ll/[n]o`, 'n')).toLowerCase();
155
- if (answer === 'a' || answer === 'all') {
156
- session.autoYes = true;
157
- return true;
158
- }
159
- return answer === 'y' || answer === 'yes';
160
- };
115
+ return (serverModels?.models.find((model) => model.id === modelId)?.label ??
116
+ `Model ${modelId}`);
161
117
  }
162
118
  async function saveSessionBoth({ session, serverSessionClient, }) {
119
+ if (!sessionHasUserMessage(session))
120
+ return;
163
121
  saveSessionState(session);
164
122
  try {
165
123
  await serverSessionClient.save(session);
@@ -168,197 +126,18 @@ async function saveSessionBoth({ session, serverSessionClient, }) {
168
126
  console.error(chalk.yellow(`Warning: session save failed: ${error.message}`));
169
127
  }
170
128
  }
171
- async function promptForModelSelection(currentModelId, authConfig) {
172
- const serverModels = await models.fetchServerModels({ config: authConfig });
173
- console.log(chalk.bold('\nAvailable models'));
174
- for (const model of serverModels.models) {
175
- const marker = model.id === currentModelId ? '*' : ' ';
176
- console.log(`${marker} ${model.id} ${model.label}`);
177
- }
178
- console.log();
179
- const selected = (await promptText('Model id', '')).trim() || null;
180
- if (!selected) {
181
- return { selected: null, serverModels };
182
- }
183
- return {
184
- selected: models.validateServerModel(selected, serverModels),
185
- serverModels,
186
- };
187
- }
188
- async function promptForResumeSession(rootDir, serverModels) {
189
- const sessions = listSessionMetadata(rootDir);
190
- if (!sessions.length) {
191
- console.log(chalk.dim('No saved sessions for this repo.\n'));
192
- return null;
193
- }
194
- printSessionList(rootDir, sessions, serverModels);
195
- console.log();
196
- const identifier = (await promptText('Session id or name', '')).trim();
197
- if (!identifier) {
198
- return null;
199
- }
200
- return loadSessionSnapshot(rootDir, identifier);
201
- }
202
- async function runTurn({ authConfig, projectIndex, serverSessionClient, session, inputText, }) {
203
- const prompt = String(inputText ?? '').trim();
204
- if (!prompt)
205
- return;
206
- appendPromptHistory(prompt, session.env);
207
- const result = await chat.sendServerUserMessage({
208
- config: authConfig,
209
- projectIndex,
210
- session,
211
- input: prompt,
212
- });
213
- if (result.text) {
214
- console.log(`\n${chalk.green('TheGitAI>')}`);
215
- console.log(renderMarkdownForTerminal(result.text));
216
- console.log();
217
- }
218
- await saveSessionBoth({ session, serverSessionClient });
219
- }
220
- async function mainInteractive({ authConfig, projectIndex, serverModels, serverSessionClient, session, usageText, initialPrompt, }) {
221
- if (initialPrompt) {
222
- console.log(chalk.dim(`Prompt: "${initialPrompt}"`));
223
- await runTurn({
224
- authConfig,
225
- projectIndex,
226
- serverSessionClient,
227
- session,
228
- inputText: initialPrompt,
229
- });
230
- }
231
- while (true) {
232
- const inputText = await promptText('you');
233
- const trimmed = inputText.trim();
234
- if (!trimmed)
235
- continue;
236
- if (trimmed === '/exit')
237
- return;
238
- if (trimmed === '/help') {
239
- console.log(renderMarkdownForTerminal(formatInteractiveHelpText()));
240
- continue;
241
- }
242
- if (trimmed === '/usage') {
243
- console.log(await usageText());
244
- continue;
245
- }
246
- if (trimmed === '/clear') {
247
- clearConversation(session);
248
- await saveSessionBoth({ session, serverSessionClient });
249
- console.log(chalk.dim('Conversation cleared.\n'));
250
- continue;
251
- }
252
- if (trimmed === '/jobs' || trimmed.startsWith('/jobs ')) {
253
- const jobsArgs = trimmed.slice('/jobs'.length).trim();
254
- const killMatch = jobsArgs.match(/^kill\s+(\S+)$/);
255
- if (killMatch) {
256
- const jobId = killMatch[1];
257
- const killed = await killBackgroundJob(jobId);
258
- await collectBackgroundJobUiKillMutations({
259
- session,
260
- projectIndex,
261
- jobId,
262
- result: killed,
263
- });
264
- if (!killed.ok) {
265
- console.log(chalk.red(killed.error ?? 'Background job kill failed.'));
266
- }
267
- else if (killed.alreadyFinished) {
268
- console.log(chalk.dim(`${jobId} had already finished.`));
269
- }
270
- continue;
271
- }
272
- const outputMatch = jobsArgs.match(/^output\s+(\S+)$/);
273
- if (outputMatch) {
274
- const jobId = outputMatch[1];
275
- await collectBackgroundJobUiOutputMutations({
276
- session,
277
- projectIndex,
278
- jobId,
279
- });
280
- const job = listBackgroundJobs().find((candidate) => candidate.id === jobId);
281
- const buffered = getJobBufferedOutput(jobId);
282
- if (!job || !buffered) {
283
- console.log(chalk.red(`Unknown background job id: ${jobId}`));
284
- continue;
285
- }
286
- if (buffered.droppedChars > 0) {
287
- console.log(chalk.dim(`... (${buffered.droppedChars} chars of older output dropped) ...`));
288
- }
289
- console.log(buffered.output || chalk.dim('(no output captured)'));
290
- continue;
291
- }
292
- if (jobsArgs) {
293
- console.log(chalk.dim('Usage: /jobs — list · /jobs output <id> — full output · /jobs kill <id> — kill'));
294
- continue;
295
- }
296
- const jobsList = listBackgroundJobs();
297
- if (!jobsList.length) {
298
- console.log(chalk.dim('No background jobs in this session.'));
299
- continue;
300
- }
301
- for (const job of jobsList) {
302
- const elapsed = formatJobElapsedMs((job.endedAt ?? Date.now()) - job.startedAt);
303
- const stateText = job.status === 'running'
304
- ? `running · ${elapsed}`
305
- : job.status === 'killed'
306
- ? `killed · ran ${elapsed}`
307
- : job.status === 'error'
308
- ? 'failed to start'
309
- : `exited (code ${job.exitCode ?? 1}) · ran ${elapsed}`;
310
- console.log(`${job.id} · ${stateText}\n $ ${job.command}`);
311
- }
312
- continue;
313
- }
314
- if (trimmed === '/resume') {
315
- const snapshot = await promptForResumeSession(session.rootDir, serverModels);
316
- if (!snapshot) {
317
- console.log(chalk.dim('Resume cancelled.\n'));
318
- continue;
319
- }
320
- applySessionSnapshot(session, snapshot);
321
- setBackgroundJobSession(session.sessionId);
322
- setScratchSession(session.sessionId);
323
- await saveSessionBoth({ session, serverSessionClient });
324
- console.log(chalk.dim(`Resumed session${session.sessionName ? ` "${session.sessionName}"` : ''} (${session.sessionId})\n`));
325
- continue;
326
- }
327
- if (trimmed === '/model' || trimmed.startsWith('/model ')) {
328
- const inline = trimmed.slice('/model'.length).trim();
329
- let serverModels;
330
- let selected = null;
331
- if (inline) {
332
- serverModels = await models.fetchServerModels({ config: authConfig });
333
- selected = models.validateServerModel(inline, serverModels);
334
- }
335
- else {
336
- const response = await promptForModelSelection(session.modelId, authConfig);
337
- serverModels = response.serverModels;
338
- selected = response.selected;
339
- }
340
- if (!selected) {
341
- console.log(chalk.dim('Model selection cancelled.\n'));
342
- continue;
343
- }
344
- session.modelId = selected;
345
- models.updateSelectedModelCache({
346
- config: authConfig,
347
- selectedModelId: selected,
348
- serverModels,
349
- });
350
- await saveSessionBoth({ session, serverSessionClient });
351
- console.log(chalk.dim(`Switched to ${modelLabel(serverModels, selected)}.\n`));
352
- continue;
353
- }
354
- await runTurn({
355
- authConfig,
356
- projectIndex,
357
- serverSessionClient,
358
- session,
359
- inputText: trimmed,
360
- });
361
- }
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;
134
+ }
135
+ if (process.stdout.isTTY !== true) {
136
+ console.error('Error: stdout is not a terminal');
137
+ process.exitCode = 1;
138
+ return false;
139
+ }
140
+ return true;
362
141
  }
363
142
  export async function main() {
364
143
  const { autoYes, help, version, usage, command, commandArgs, session: sessionIdentifier, listSessions, unknownOption, prompt, } = parseArgs(process.argv);
@@ -386,6 +165,22 @@ export async function main() {
386
165
  return;
387
166
  }
388
167
  const rootDir = process.cwd();
168
+ if (listSessions) {
169
+ const activeServerUrl = auth.readCliAuthConfig()?.serverUrl ?? DEFAULT_SERVER_URL;
170
+ printSessionList(rootDir, listSessionMetadata(rootDir), models.selectCacheForServer(models.readCachedServerModels(), activeServerUrl));
171
+ return;
172
+ }
173
+ const sourceSnapshot = sessionIdentifier
174
+ ? loadSessionSnapshot(rootDir, sessionIdentifier)
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
+ }
389
184
  const authConfig = requireCliAuthConfig();
390
185
  const serverSessionClient = sessions.createServerSessionClient({ config: authConfig });
391
186
  const cachedModels = models.selectCacheForServer(models.readCachedServerModels(), authConfig.serverUrl);
@@ -428,13 +223,6 @@ export async function main() {
428
223
  if (offlineNotice) {
429
224
  console.error(chalk.yellow(`⚠ Couldn't reach TheGitAI (${offlineNotice}). Starting with cached settings — it will reconnect on your next message.`));
430
225
  }
431
- if (listSessions) {
432
- printSessionList(rootDir, listSessionMetadata(rootDir), serverModels);
433
- return;
434
- }
435
- const sourceSnapshot = sessionIdentifier
436
- ? loadSessionSnapshot(rootDir, sessionIdentifier)
437
- : null;
438
226
  const selectedModelId = models.selectServerModel({
439
227
  requestedModelId: sourceSnapshot?.modelId ?? null,
440
228
  cached: cachedModels,
@@ -450,13 +238,9 @@ export async function main() {
450
238
  autoYes,
451
239
  modelId: selectedModelId,
452
240
  });
453
- session.confirmCommand = makeConfirmCommand(session);
454
- session.confirmPatch = makeConfirmPatch(session);
455
241
  if (sourceSnapshot) {
456
242
  applySessionSnapshot(session, sourceSnapshot);
457
- await saveSessionBoth({ session, serverSessionClient });
458
243
  }
459
- setScratchSession(session.sessionId);
460
244
  const projectIndex = createIndex({
461
245
  rootDir,
462
246
  onStatus: (message) => {
@@ -471,45 +255,27 @@ export async function main() {
471
255
  },
472
256
  });
473
257
  const initialPrompt = prompt || undefined;
474
- if (shouldUseClientRatatuiShell()) {
475
- await runClientInteractive({
476
- appendPromptHistory: (value) => appendPromptHistory(value, session.env),
477
- authConfig,
478
- debugUi: whoami.debugUi,
479
- projectIndex,
480
- serverModels,
481
- serverSessionClient,
482
- session,
483
- initialPrompt,
484
- usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
485
- });
486
- await saveSessionBoth({ session, serverSessionClient });
487
- printSessionExit(session);
488
- return;
489
- }
490
- printStartupBanner(rootDir, modelLabel(serverModels, session.modelId), session.autoYes);
491
- printSessionStartup(session);
492
- setBackgroundJobSession(session.sessionId);
493
- try {
494
- await mainInteractive({
495
- authConfig,
496
- projectIndex,
497
- serverModels,
498
- serverSessionClient,
499
- session,
500
- usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
501
- initialPrompt,
502
- });
503
- await saveSessionBoth({ session, serverSessionClient });
504
- printSessionExit(session);
505
- }
506
- finally {
507
- killAllBackgroundJobs({ sessionId: session.sessionId, remove: true });
508
- setBackgroundJobSession(null);
509
- setScratchSession(null);
510
- }
258
+ await runClientInteractive({
259
+ appendPromptHistory: (value) => appendPromptHistory(value, session.env),
260
+ authConfig,
261
+ debugUi: whoami.debugUi,
262
+ projectIndex,
263
+ serverModels,
264
+ serverSessionClient,
265
+ session,
266
+ initialPrompt,
267
+ usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
268
+ });
269
+ await saveSessionBoth({ session, serverSessionClient });
270
+ printSessionExit(session);
511
271
  }
512
272
  main().catch((error) => {
513
- 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
+ }
514
280
  process.exit(1);
515
281
  });
@@ -46,7 +46,7 @@ export function nextAgentMode(mode) {
46
46
  export function agentModeAllowsTool(mode, toolName) {
47
47
  return mode !== 'plan' || PLAN_MODE_TOOL_NAMES.has(toolName);
48
48
  }
49
- function getUnquotedShellText(command) {
49
+ export function getUnquotedShellText(command) {
50
50
  let quote = null;
51
51
  let escaped = false;
52
52
  let text = '';
@@ -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
  }
@@ -1,8 +1,10 @@
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;
@@ -21,6 +23,73 @@ function defaultDeviceName() {
21
23
  return os.hostname();
22
24
  }
23
25
  }
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
+ }
24
93
  export function generatePkce() {
25
94
  const verifier = crypto.randomBytes(32).toString('base64url');
26
95
  const challenge = crypto
@@ -56,7 +125,7 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
56
125
  });
57
126
  const data = (await readJsonResponse(response));
58
127
  if (!response.ok) {
59
- 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));
60
129
  }
61
130
  const token = String(data?.token ?? '').trim();
62
131
  const customer = data?.customer;
@@ -77,7 +146,7 @@ export async function loginViaBrowser(options) {
77
146
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
78
147
  const openBrowser = options.openBrowser ?? openUrl;
79
148
  const onUrl = options.onUrl ?? (() => { });
80
- const deviceName = options.deviceName ?? defaultDeviceName();
149
+ const deviceName = withOperatingSystemInfo(options.deviceName ?? defaultDeviceName());
81
150
  const { verifier, challenge } = generatePkce();
82
151
  if (options.noBrowser) {
83
152
  const authUrl = buildAuthUrl(websiteUrl, {