@thegitai/cli 1.0.0-beta.2 → 1.0.0-beta.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 (55) hide show
  1. package/README.md +37 -2
  2. package/dist/bin/ai.js +148 -75
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +6 -4
  6. package/dist/src/api/browser-login.js +7 -41
  7. package/dist/src/api/chat.js +77 -20
  8. package/dist/src/api/http.js +81 -4
  9. package/dist/src/api/models.js +26 -18
  10. package/dist/src/artifact-policy.js +12 -0
  11. package/dist/src/background-jobs.js +410 -0
  12. package/dist/src/cli-args.js +60 -0
  13. package/dist/src/client-environment.js +129 -0
  14. package/dist/src/colors.js +50 -0
  15. package/dist/src/core/clipboard.js +75 -0
  16. package/dist/src/core/image-path-extractor.js +144 -0
  17. package/dist/src/edit-journal.js +39 -6
  18. package/dist/src/executor.js +48 -12
  19. package/dist/src/help-text.js +24 -5
  20. package/dist/src/markdown-renderer.js +1 -1
  21. package/dist/src/patcher.js +17 -2
  22. package/dist/src/scanner.js +58 -17
  23. package/dist/src/scratch-dir.js +57 -0
  24. package/dist/src/secret-preview.js +0 -10
  25. package/dist/src/session-safety.js +64 -31
  26. package/dist/src/session-store.js +0 -1
  27. package/dist/src/todo-list.js +106 -0
  28. package/dist/src/tool-executor.js +164 -18
  29. package/dist/src/tools/delete-file.js +1 -1
  30. package/dist/src/tools/index.js +8 -0
  31. package/dist/src/tools/patch-file.js +16 -2
  32. package/dist/src/tools/path-suggest.js +139 -0
  33. package/dist/src/tools/read-document.js +15 -4
  34. package/dist/src/tools/read-file.js +23 -7
  35. package/dist/src/tools/replace-document-text.js +234 -0
  36. package/dist/src/tools/restore-checkpoint.js +1 -1
  37. package/dist/src/tools/run-command.js +83 -16
  38. package/dist/src/tools/run-node-script.js +3 -1
  39. package/dist/src/tools/shell-job-kill.js +48 -0
  40. package/dist/src/tools/shell-job-output.js +51 -0
  41. package/dist/src/tools/str-replace.js +16 -2
  42. package/dist/src/tools/undo-edit.js +7 -5
  43. package/dist/src/tools/update-todos.js +27 -0
  44. package/dist/src/tools/write-file.js +14 -1
  45. package/dist/src/tree-sitter-runtime.js +8 -1
  46. package/dist/src/ui/repl.js +315 -24
  47. package/dist/src/ui/tui/bridge.js +2 -6
  48. package/dist/src/ui/tui/build-frame.js +224 -25
  49. package/dist/src/ui/tui/shell-input.js +42 -5
  50. package/dist/src/version.js +29 -0
  51. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  52. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  53. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  54. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  55. package/package.json +14 -15
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # TheGitAI
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
+ 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.
5
6
 
6
7
  ## Install
7
8
 
@@ -20,10 +21,44 @@ ai login sign in via your browser (--no-browser for SSH/headless)
20
21
  ai whoami show the signed-in account
21
22
  ai --usage show account usage and reset times
22
23
  ai logout sign out
24
+ ai --version print the version and exit
23
25
  ```
24
26
 
25
27
  Run `ai --help` for sessions, modes, keys, and chat commands.
26
28
 
29
+ ## Visible to-do list
30
+
31
+ For larger multi-step tasks, the agent keeps a compact to-do list on screen so
32
+ you can see what it plans to do, what it is working on right now, and what is
33
+ already done.
34
+
35
+ - While the agent works, the list sits at the bottom of the live **Working**
36
+ area, right above where you type, and updates as steps start and finish —
37
+ one step in progress at a time — so it stays visible even when tool output
38
+ above it runs long.
39
+ - When the turn ends, a final snapshot of the list stays readable in the
40
+ transcript, and the footer shows a small progress chip (e.g. `◐ 4/6 to-dos`)
41
+ while steps remain open.
42
+ - The agent's current reasoning stays visible too, right below the list, in a
43
+ compact one-line form so it doesn't compete with the list for space.
44
+ - The list is managed entirely by the agent; simple one-step requests skip it.
45
+
46
+ ## Background jobs
47
+
48
+ Some commands are meant to keep running — a dev server, a file watcher, a local
49
+ API. TheGitAI runs these as **background jobs**, so the agent can start a server,
50
+ work against it live, and stop it when the task is done, all in one session.
51
+
52
+ - Each job's block in the transcript updates on its own with a live tail of its
53
+ latest output, and a compact indicator keeps you aware of what's still running.
54
+ - Type `/jobs` to open a picker: **↑ / ↓** to move, **Enter** to expand a job and
55
+ read its output inline, **k** to stop it, **Esc** to close.
56
+ - `/jobs output <id>` prints a job's full output, and `/jobs kill <id>` stops one
57
+ by id.
58
+ - When you end the session, TheGitAI stops its background jobs for you.
59
+
60
+ Full documentation: <https://thegit.ai/docs>
61
+
27
62
  ## License
28
63
 
29
64
  Proprietary — see the LICENSE file included in this package. Source is
package/dist/bin/ai.js CHANGED
@@ -1,9 +1,10 @@
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 { isTransientNetworkError } from '../src/api/http.js';
7
8
  import { formatCliHelpText, formatInteractiveHelpText, } from '../src/help-text.js';
8
9
  import { renderMarkdownForTerminal } from '../src/markdown-renderer.js';
9
10
  import { createIndex } from '../src/project-index.js';
@@ -13,65 +14,15 @@ import { runClientInteractive, shouldUseClientRatatuiShell, } from '../src/ui/re
13
14
  import { appendPromptToFile } from '../src/ui/prompt-history-store.js';
14
15
  import { formatSessionExitNotice } from '../src/session-exit.js';
15
16
  import { formatUsageText } from '../src/usage.js';
17
+ import { formatVersionLine } from '../src/version.js';
18
+ 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';
16
21
  const DEFAULT_SERVER_URL = 'https://thegit.ai';
17
- const AUTH_COMMANDS = new Set(['login', 'whoami', 'logout']);
18
22
  const { auth, chat, models, sessions } = ServerApi;
19
23
  function printUsage() {
20
24
  console.log(formatCliHelpText({ color: process.stdout.isTTY === true }));
21
25
  }
22
- export function parseArgs(argv) {
23
- const args = argv.slice(2);
24
- const firstArg = args[0];
25
- const command = firstArg && AUTH_COMMANDS.has(firstArg) ? firstArg : null;
26
- const commandArgs = command ? args.slice(1) : [];
27
- let autoYes = false;
28
- let help = false;
29
- let usage = false;
30
- let session = null;
31
- let listSessions = false;
32
- const promptParts = [];
33
- for (let i = 0; i < args.length; i++) {
34
- const arg = args[i];
35
- if (arg === '--yes' || arg === '-y') {
36
- autoYes = true;
37
- continue;
38
- }
39
- if ((arg === '--session' || arg === '--resume') && i + 1 < args.length) {
40
- session = args[i + 1] ?? null;
41
- i += 1;
42
- continue;
43
- }
44
- if (arg === '--list-sessions') {
45
- listSessions = true;
46
- continue;
47
- }
48
- if (arg === '--help' || arg === '-h') {
49
- help = true;
50
- continue;
51
- }
52
- if (arg === '--usage') {
53
- usage = true;
54
- continue;
55
- }
56
- promptParts.push(arg);
57
- }
58
- return {
59
- command,
60
- commandArgs,
61
- autoYes,
62
- help,
63
- usage,
64
- session,
65
- listSessions,
66
- prompt: promptParts.join(' ').trim(),
67
- };
68
- }
69
- function commandFlagValue(args, name) {
70
- const index = args.indexOf(name);
71
- if (index === -1)
72
- return null;
73
- return args[index + 1] ?? null;
74
- }
75
26
  async function promptText(question, fallback = null) {
76
27
  const rl = readline.createInterface({ input, output });
77
28
  try {
@@ -88,17 +39,13 @@ function appendPromptHistory(prompt, env = process.env) {
88
39
  }
89
40
  async function runAuthCommand(command, args) {
90
41
  if (command === 'login') {
91
- const serverUrl = commandFlagValue(args, '--server') ??
92
- process.env.THEGITAI_SERVER_URL ??
93
- DEFAULT_SERVER_URL;
94
- const websiteUrl = commandFlagValue(args, '--website') ?? undefined;
42
+ const serverUrl = DEFAULT_SERVER_URL;
95
43
  const noBrowser = args.includes('--no-browser');
96
44
  console.log(chalk.dim(noBrowser
97
45
  ? 'Sign in on the website, then paste the authorization code here.'
98
46
  : 'Opening your browser to sign in…'));
99
47
  const result = await loginViaBrowser({
100
48
  serverUrl,
101
- websiteUrl,
102
49
  noBrowser,
103
50
  onUrl: (url) => {
104
51
  console.log(chalk.dim(noBrowser ? 'Open this URL to sign in:' : 'If your browser did not open, visit:'));
@@ -181,6 +128,15 @@ function modelLabel(serverModels, modelId) {
181
128
  return (serverModels.models.find((model) => model.id === modelId)?.label ??
182
129
  'Unknown model');
183
130
  }
131
+ function formatJobElapsedMs(ms) {
132
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
133
+ if (totalSeconds < 60)
134
+ return `${totalSeconds}s`;
135
+ const minutes = Math.floor(totalSeconds / 60);
136
+ if (minutes < 60)
137
+ return `${minutes}m${String(totalSeconds % 60).padStart(2, '0')}s`;
138
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
139
+ }
184
140
  function makeConfirmCommand(session) {
185
141
  return async (command) => {
186
142
  console.log(chalk.bold(`\nCommand approval needed:\n${command}\n`));
@@ -292,6 +248,68 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
292
248
  console.log(chalk.dim('Conversation cleared.\n'));
293
249
  continue;
294
250
  }
251
+ if (trimmed === '/jobs' || trimmed.startsWith('/jobs ')) {
252
+ const jobsArgs = trimmed.slice('/jobs'.length).trim();
253
+ const killMatch = jobsArgs.match(/^kill\s+(\S+)$/);
254
+ if (killMatch) {
255
+ const jobId = killMatch[1];
256
+ const killed = await killBackgroundJob(jobId);
257
+ await collectBackgroundJobUiKillMutations({
258
+ session,
259
+ projectIndex,
260
+ jobId,
261
+ result: killed,
262
+ });
263
+ if (!killed.ok) {
264
+ console.log(chalk.red(killed.error ?? 'Background job kill failed.'));
265
+ }
266
+ else if (killed.alreadyFinished) {
267
+ console.log(chalk.dim(`${jobId} had already finished.`));
268
+ }
269
+ continue;
270
+ }
271
+ const outputMatch = jobsArgs.match(/^output\s+(\S+)$/);
272
+ if (outputMatch) {
273
+ const jobId = outputMatch[1];
274
+ await collectBackgroundJobUiOutputMutations({
275
+ session,
276
+ projectIndex,
277
+ jobId,
278
+ });
279
+ const job = listBackgroundJobs().find((candidate) => candidate.id === jobId);
280
+ const buffered = getJobBufferedOutput(jobId);
281
+ if (!job || !buffered) {
282
+ console.log(chalk.red(`Unknown background job id: ${jobId}`));
283
+ continue;
284
+ }
285
+ if (buffered.droppedChars > 0) {
286
+ console.log(chalk.dim(`... (${buffered.droppedChars} chars of older output dropped) ...`));
287
+ }
288
+ console.log(buffered.output || chalk.dim('(no output captured)'));
289
+ continue;
290
+ }
291
+ if (jobsArgs) {
292
+ console.log(chalk.dim('Usage: /jobs — list · /jobs output <id> — full output · /jobs kill <id> — kill'));
293
+ continue;
294
+ }
295
+ const jobsList = listBackgroundJobs();
296
+ if (!jobsList.length) {
297
+ console.log(chalk.dim('No background jobs in this session.'));
298
+ continue;
299
+ }
300
+ for (const job of jobsList) {
301
+ const elapsed = formatJobElapsedMs((job.endedAt ?? Date.now()) - job.startedAt);
302
+ const stateText = job.status === 'running'
303
+ ? `running · ${elapsed}`
304
+ : job.status === 'killed'
305
+ ? `killed · ran ${elapsed}`
306
+ : job.status === 'error'
307
+ ? 'failed to start'
308
+ : `exited (code ${job.exitCode ?? 1}) · ran ${elapsed}`;
309
+ console.log(`${job.id} · ${stateText}\n $ ${job.command}`);
310
+ }
311
+ continue;
312
+ }
295
313
  if (trimmed === '/resume') {
296
314
  const snapshot = await promptForResumeSession(session.rootDir, serverModels);
297
315
  if (!snapshot) {
@@ -299,6 +317,7 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
299
317
  continue;
300
318
  }
301
319
  applySessionSnapshot(session, snapshot);
320
+ setBackgroundJobSession(session.sessionId);
302
321
  await saveSessionBoth({ session, serverSessionClient });
303
322
  console.log(chalk.dim(`Resumed session${session.sessionName ? ` "${session.sessionName}"` : ''} (${session.sessionId})\n`));
304
323
  continue;
@@ -340,11 +359,21 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
340
359
  }
341
360
  }
342
361
  export async function main() {
343
- const { autoYes, help, usage, command, commandArgs, session: sessionIdentifier, listSessions, prompt, } = parseArgs(process.argv);
362
+ const { autoYes, help, version, usage, command, commandArgs, session: sessionIdentifier, listSessions, unknownOption, prompt, } = parseArgs(process.argv);
363
+ if (version) {
364
+ console.log(formatVersionLine());
365
+ return;
366
+ }
344
367
  if (help) {
345
368
  printUsage();
346
369
  return;
347
370
  }
371
+ if (unknownOption) {
372
+ console.error(`Unknown option: ${unknownOption}`);
373
+ console.error("Run 'ai --help' to see available commands and options.");
374
+ process.exitCode = 2;
375
+ return;
376
+ }
348
377
  if (command) {
349
378
  await runAuthCommand(command, commandArgs);
350
379
  return;
@@ -357,9 +386,46 @@ export async function main() {
357
386
  const rootDir = process.cwd();
358
387
  const authConfig = requireCliAuthConfig();
359
388
  const serverSessionClient = sessions.createServerSessionClient({ config: authConfig });
360
- const cachedModels = models.readCachedServerModels();
361
- const serverModels = await models.fetchServerModels({ config: authConfig });
362
- const whoami = await auth.fetchWhoamiResponse({ config: authConfig });
389
+ const cachedModels = models.selectCacheForServer(models.readCachedServerModels(), authConfig.serverUrl);
390
+ let offlineNotice = null;
391
+ let serverModels;
392
+ try {
393
+ serverModels = await models.fetchServerModels({ config: authConfig });
394
+ }
395
+ catch (error) {
396
+ if (isTransientNetworkError(error) && cachedModels?.models.length) {
397
+ serverModels = { models: cachedModels.models };
398
+ offlineNotice = error?.message ? String(error.message) : 'network error';
399
+ }
400
+ else {
401
+ throw error;
402
+ }
403
+ }
404
+ let whoami;
405
+ try {
406
+ whoami = await auth.fetchWhoamiResponse({ config: authConfig });
407
+ }
408
+ catch (error) {
409
+ if (isTransientNetworkError(error)) {
410
+ whoami = {
411
+ customer: {
412
+ id: '',
413
+ uuid: '',
414
+ email: authConfig.email,
415
+ customer_type: authConfig.customerType ?? 'USER',
416
+ scopes: [],
417
+ },
418
+ debugUi: { showSessionId: false },
419
+ };
420
+ offlineNotice ??= error?.message ? String(error.message) : 'network error';
421
+ }
422
+ else {
423
+ throw error;
424
+ }
425
+ }
426
+ if (offlineNotice) {
427
+ console.error(chalk.yellow(`⚠ Couldn't reach TheGitAI (${offlineNotice}). Starting with cached settings — it will reconnect on your next message.`));
428
+ }
363
429
  if (listSessions) {
364
430
  printSessionList(rootDir, listSessionMetadata(rootDir), serverModels);
365
431
  return;
@@ -420,17 +486,24 @@ export async function main() {
420
486
  }
421
487
  printStartupBanner(rootDir, modelLabel(serverModels, session.modelId), session.autoYes);
422
488
  printSessionStartup(session);
423
- await mainInteractive({
424
- authConfig,
425
- projectIndex,
426
- serverModels,
427
- serverSessionClient,
428
- session,
429
- usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
430
- initialPrompt,
431
- });
432
- await saveSessionBoth({ session, serverSessionClient });
433
- printSessionExit(session);
489
+ setBackgroundJobSession(session.sessionId);
490
+ try {
491
+ await mainInteractive({
492
+ authConfig,
493
+ projectIndex,
494
+ serverModels,
495
+ serverSessionClient,
496
+ session,
497
+ usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
498
+ initialPrompt,
499
+ });
500
+ await saveSessionBoth({ session, serverSessionClient });
501
+ printSessionExit(session);
502
+ }
503
+ finally {
504
+ killAllBackgroundJobs({ sessionId: session.sessionId, remove: true });
505
+ setBackgroundJobSession(null);
506
+ }
434
507
  }
435
508
  main().catch((error) => {
436
509
  console.error(chalk.red(`\n✖ Error: ${error.message}\n`));
@@ -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 { authorizedJson, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
4
+ import { ServerApiError, authorizedJson, createTraceContext, 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
  }
@@ -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,40 +2,16 @@ 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
- const DEFAULT_DEV_WEBSITE_URL = 'http://localhost:3002';
8
7
  const DEFAULT_SERVER_URL = 'https://thegit.ai';
9
8
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
10
9
  function shutDownServer(server) {
11
- // Drop any lingering (keep-alive) connections so the event loop empties and
12
- // the CLI exits instead of hanging after a successful login.
13
10
  server.closeAllConnections?.();
14
11
  server.close();
15
12
  }
16
- function isLocalhostUrl(url) {
17
- if (!url)
18
- return false;
19
- try {
20
- const host = new URL(url).hostname;
21
- return host === 'localhost' || host === '127.0.0.1' || host === '::1';
22
- }
23
- catch {
24
- return false;
25
- }
26
- }
27
- export function resolveWebsiteUrl(websiteUrl, env = process.env, serverUrl) {
28
- const explicit = String(websiteUrl ?? '').trim() ||
29
- String(env.THEGITAI_WEBSITE_URL ?? '').trim();
30
- // With no explicit override, point at production — unless we're clearly in
31
- // local dev (talking to a localhost server), in which case default to the
32
- // local website so `ai login` works without any flags or env vars.
33
- const value = explicit || (isLocalhostUrl(serverUrl) ? DEFAULT_DEV_WEBSITE_URL : DEFAULT_WEBSITE_URL);
34
- const normalized = value.replace(/\/+$/, '');
35
- if (!/^https?:\/\//i.test(normalized)) {
36
- throw new Error('Website URL must start with http:// or https://.');
37
- }
38
- return normalized;
13
+ export function resolveWebsiteUrl() {
14
+ return DEFAULT_WEBSITE_URL.replace(/\/+$/, '');
39
15
  }
40
16
  function defaultDeviceName() {
41
17
  try {
@@ -45,7 +21,6 @@ function defaultDeviceName() {
45
21
  return os.hostname();
46
22
  }
47
23
  }
48
- /** PKCE (RFC 7636, S256): a random verifier and its SHA-256 challenge. */
49
24
  export function generatePkce() {
50
25
  const verifier = crypto.randomBytes(32).toString('base64url');
51
26
  const challenge = crypto
@@ -73,14 +48,15 @@ const RESULT_PAGE = (heading, detail) => `<!doctype html><html><head><meta chars
73
48
  `p{color:#9aa0a6}</style></head><body><div class="card"><h1>${heading}</h1>` +
74
49
  `<p>${detail}</p></div></body></html>`;
75
50
  async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl, }) {
51
+ const trace = createTraceContext();
76
52
  const response = await fetchImpl(`${serverUrl}/v1/cli/auth/token`, {
77
53
  method: 'POST',
78
- headers: { 'content-type': 'application/json' },
54
+ headers: { 'content-type': 'application/json', ...trace.headers },
79
55
  body: JSON.stringify({ code, code_verifier: codeVerifier }),
80
56
  });
81
57
  const data = (await readJsonResponse(response));
82
58
  if (!response.ok) {
83
- throw new Error(failureMessage(data, response.status));
59
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
84
60
  }
85
61
  const token = String(data?.token ?? '').trim();
86
62
  const customer = data?.customer;
@@ -95,15 +71,9 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
95
71
  customer,
96
72
  };
97
73
  }
98
- /**
99
- * Browser-based login. Starts a loopback server so the website can redirect the
100
- * one-time code back automatically; the code is then exchanged for a token
101
- * using the PKCE verifier. With `noBrowser`, the user pastes the code instead.
102
- * The CLI never sees the user's credentials.
103
- */
104
74
  export async function loginViaBrowser(options) {
105
75
  const serverUrl = normalizeServerUrl(options.serverUrl ?? DEFAULT_SERVER_URL);
106
- const websiteUrl = resolveWebsiteUrl(options.websiteUrl, process.env, serverUrl);
76
+ const websiteUrl = resolveWebsiteUrl();
107
77
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
108
78
  const openBrowser = options.openBrowser ?? openUrl;
109
79
  const onUrl = options.onUrl ?? (() => { });
@@ -115,8 +85,6 @@ export async function loginViaBrowser(options) {
115
85
  deviceName,
116
86
  paste: true,
117
87
  });
118
- // Headless mode: only print the URL for the user to open on another device.
119
- // Never launch a browser here — that is the whole point of --no-browser.
120
88
  onUrl(authUrl);
121
89
  if (!options.promptCode) {
122
90
  throw new Error('No way to read the authorization code in this context.');
@@ -143,8 +111,6 @@ export async function loginViaBrowser(options) {
143
111
  }
144
112
  const code = requestUrl.searchParams.get('code') ?? '';
145
113
  const returnedState = requestUrl.searchParams.get('state') ?? '';
146
- // `Connection: close` plus closeAllConnections() ensures the browser's
147
- // keep-alive socket is torn down so the process can exit after login.
148
114
  if (!code || returnedState !== state) {
149
115
  res.writeHead(400, { 'content-type': 'text/html', connection: 'close' });
150
116
  res.end(RESULT_PAGE('Login failed', 'The request could not be verified. Please run ai login again.'));