@thegitai/cli 1.0.0-preview.25 → 1.0.0-preview.27

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
@@ -17,9 +17,9 @@ Requires Node.js 24 or newer.
17
17
  ## Usage
18
18
 
19
19
  ```text
20
- ai start an interactive session in the current repo
20
+ ai sign in if needed, then start a session in the current repo
21
21
  ai "<request>" start a session with <request> as the first message
22
- ai login sign in via your browser (--no-browser for SSH/headless)
22
+ ai login the same as `ai`; kept for muscle memory
23
23
  ai whoami show the signed-in account
24
24
  ai --usage show account usage and reset times
25
25
  ai logout sign out
@@ -34,9 +34,14 @@ 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
+ Signing in opens your browser. The same screen also prints a URL you can open on
38
+ any other device and a box for an authorization code, so SSH and headless
39
+ machines need no separate command: open the URL wherever you have a browser,
40
+ choose "Not on this machine?" on that page, and paste the code it shows.
41
+
37
42
  CLI login tokens use a rolling 48-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.
43
+ any server request, the CLI removes the expired credential and signs you in
44
+ again on the next run.
40
45
 
41
46
  ## Structured questions
42
47
 
package/dist/bin/ai.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import chalk from '../src/colors.js';
3
- import { stdin as input, stdout as output } from 'node:process';
4
- import readline from 'node:readline/promises';
5
3
  import { ServerApi } from '../src/api/index.js';
6
- import { loginViaBrowser } from '../src/api/browser-login.js';
4
+ import { DEFAULT_THEGITAI_HOST } from '../src/api/default-host.js';
5
+ import { isSignInCancelled } from '../src/api/browser-login.js';
6
+ import { runSignIn } from '../src/signin.js';
7
7
  import { STARTUP_RETRY_BUDGET, 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';
@@ -15,7 +15,6 @@ import { formatSessionExitNotice } from '../src/session-exit.js';
15
15
  import { formatUsageText } from '../src/usage.js';
16
16
  import { formatVersionLine } from '../src/version.js';
17
17
  import { parseArgs } from '../src/cli-args.js';
18
- const DEFAULT_SERVER_URL = 'https://thegit.ai';
19
18
  const { auth, chat, models, sessions } = ServerApi;
20
19
  function printUsage() {
21
20
  console.log(formatCliHelpText({ color: process.stdout.isTTY === true }));
@@ -34,49 +33,17 @@ function unreachableReason(error) {
34
33
  return 'connection reset';
35
34
  return err?.message ? String(err.message) : 'network error';
36
35
  }
37
- async function promptText(question, fallback = null) {
38
- const rl = readline.createInterface({ input, output });
39
- try {
40
- const suffix = fallback ? ` (${fallback})` : '';
41
- const answer = await rl.question(`${question}${suffix}: `);
42
- return answer.trim() || fallback || '';
43
- }
44
- finally {
45
- rl.close();
46
- }
47
- }
48
36
  function appendPromptHistory(prompt, env = process.env) {
49
37
  appendPromptToFile(prompt, env);
50
38
  }
51
- async function runAuthCommand(command, args) {
52
- if (command === 'login') {
53
- const serverUrl = DEFAULT_SERVER_URL;
54
- const noBrowser = args.includes('--no-browser');
55
- console.log(chalk.dim(noBrowser
56
- ? 'Sign in on the website, then paste the authorization code here.'
57
- : 'Opening your browser to sign in…'));
58
- const result = await loginViaBrowser({
59
- serverUrl,
60
- noBrowser,
61
- onUrl: (url) => {
62
- console.log(chalk.dim(noBrowser ? 'Open this URL to sign in:' : 'If your browser did not open, visit:'));
63
- console.log(` ${url}`);
64
- },
65
- onWaiting: () => console.log(chalk.dim('Waiting for you to finish signing in…')),
66
- promptCode: noBrowser
67
- ? () => promptText('Paste the authorization code')
68
- : undefined,
69
- deviceName: process.env.THEGITAI_DEVICE_NAME?.trim() || undefined,
70
- });
71
- auth.writeCliAuthConfig(result);
72
- console.log(chalk.green(`✓ Logged in as ${result.customer.email}`));
73
- console.log(chalk.dim(`Server: ${result.serverUrl}`));
74
- console.log(chalk.dim('You can close the browser tab. Run `ai` in a repo to start.'));
75
- return;
76
- }
39
+ async function runAuthCommand(command) {
77
40
  const config = auth.readCliAuthConfig();
78
41
  if (!config) {
79
- throw new Error('Not logged in. Run `ai login` first.');
42
+ if (command === 'logout') {
43
+ console.log(chalk.green('Already signed out.'));
44
+ return;
45
+ }
46
+ throw new Error('Not signed in. Run `ai` to sign in.');
80
47
  }
81
48
  if (command === 'whoami') {
82
49
  const customer = await auth.fetchWhoami({ config });
@@ -84,19 +51,25 @@ async function runAuthCommand(command, args) {
84
51
  return;
85
52
  }
86
53
  if (command === 'logout') {
87
- await auth.logoutFromServer({ config });
54
+ try {
55
+ await auth.logoutFromServer({ config });
56
+ }
57
+ catch {
58
+ }
88
59
  auth.clearCliAuthConfig();
89
60
  console.log(chalk.green('Logged out.'));
90
61
  return;
91
62
  }
92
63
  throw new Error(`Unknown auth command: ${command}`);
93
64
  }
94
- function requireCliAuthConfig() {
65
+ async function ensureCliAuthConfig() {
95
66
  const config = auth.readCliAuthConfig();
96
- if (!config) {
97
- throw new Error('Not logged in. Run `ai login` first.');
67
+ if (config)
68
+ return config;
69
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
70
+ throw new Error('Not signed in. Run `ai login` on a terminal to sign in.');
98
71
  }
99
- return config;
72
+ return await runSignIn();
100
73
  }
101
74
  function formatSessionName(name) {
102
75
  return name ? `"${name}"` : '(unnamed)';
@@ -154,7 +127,7 @@ function requireInteractiveTerminal() {
154
127
  return true;
155
128
  }
156
129
  export async function main() {
157
- const { autoYes, help, version, usage, command, commandArgs, session: sessionIdentifier, listSessions, unknownOption, prompt, } = parseArgs(process.argv);
130
+ const { autoYes, help, version, usage, command, session: sessionIdentifier, listSessions, unknownOption, prompt, } = parseArgs(process.argv);
158
131
  if (version) {
159
132
  console.log(formatVersionLine());
160
133
  return;
@@ -169,18 +142,18 @@ export async function main() {
169
142
  process.exitCode = 2;
170
143
  return;
171
144
  }
172
- if (command) {
173
- await runAuthCommand(command, commandArgs);
145
+ if (command && command !== 'login') {
146
+ await runAuthCommand(command);
174
147
  return;
175
148
  }
176
149
  if (usage) {
177
- const authConfig = requireCliAuthConfig();
150
+ const authConfig = await ensureCliAuthConfig();
178
151
  console.log(formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })));
179
152
  return;
180
153
  }
181
154
  const rootDir = process.cwd();
182
155
  if (listSessions) {
183
- const activeServerUrl = auth.readCliAuthConfig()?.serverUrl ?? DEFAULT_SERVER_URL;
156
+ const activeServerUrl = auth.readCliAuthConfig()?.serverUrl ?? DEFAULT_THEGITAI_HOST;
184
157
  printSessionList(rootDir, listSessionMetadata(rootDir), models.selectCacheForServer(models.readCachedServerModels(), activeServerUrl));
185
158
  return;
186
159
  }
@@ -195,7 +168,7 @@ export async function main() {
195
168
  if (!requireInteractiveTerminal()) {
196
169
  return;
197
170
  }
198
- const authConfig = requireCliAuthConfig();
171
+ const authConfig = await ensureCliAuthConfig();
199
172
  const serverSessionClient = sessions.createServerSessionClient({ config: authConfig });
200
173
  const cachedModels = models.selectCacheForServer(models.readCachedServerModels(), authConfig.serverUrl);
201
174
  const [modelsOutcome, whoamiOutcome] = await Promise.allSettled([
@@ -279,7 +252,7 @@ export async function main() {
279
252
  },
280
253
  });
281
254
  const initialPrompt = prompt || undefined;
282
- await runClientInteractive({
255
+ const outcome = await runClientInteractive({
283
256
  appendPromptHistory: (value) => appendPromptHistory(value, session.env),
284
257
  authConfig,
285
258
  debugUi: whoami.debugUi,
@@ -290,10 +263,21 @@ export async function main() {
290
263
  initialPrompt,
291
264
  usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
292
265
  });
266
+ if (outcome.signedOut) {
267
+ if (sessionHasUserMessage(session)) {
268
+ saveSessionState(session);
269
+ }
270
+ console.log(chalk.green('\n✓ Logged out.\n'));
271
+ return;
272
+ }
293
273
  await saveSessionBoth({ session, serverSessionClient });
294
274
  printSessionExit(session);
295
275
  }
296
276
  main().catch((error) => {
277
+ if (isSignInCancelled(error)) {
278
+ console.error(chalk.dim('\nSign-in cancelled. Nothing was saved.\n'));
279
+ process.exit(130);
280
+ }
297
281
  if (isAuthenticationError(error)) {
298
282
  auth.clearCliAuthConfig();
299
283
  console.error(chalk.red(`\n✖ Error: ${authenticationErrorMessage(error)}\n`));
@@ -5,15 +5,27 @@ import http from 'node:http';
5
5
  import os from 'node:os';
6
6
  import { openUrl } from '../core/open-url.js';
7
7
  import { ServerApiError, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
8
- const DEFAULT_WEBSITE_URL = 'https://thegit.ai';
9
- const DEFAULT_SERVER_URL = 'https://thegit.ai';
10
- const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
8
+ import { DEFAULT_THEGITAI_HOST } from './default-host.js';
9
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
11
10
  function shutDownServer(server) {
12
11
  server.closeAllConnections?.();
13
12
  server.close();
14
13
  }
14
+ export class SignInCancelledError extends Error {
15
+ constructor() {
16
+ super('Sign-in cancelled.');
17
+ this.name = 'SignInCancelledError';
18
+ }
19
+ }
20
+ export function isSignInCancelled(error) {
21
+ return error instanceof SignInCancelledError;
22
+ }
23
+ function isAbortError(error) {
24
+ const err = error;
25
+ return err?.name === 'AbortError' || err?.code === 'ABORT_ERR';
26
+ }
15
27
  export function resolveWebsiteUrl() {
16
- return DEFAULT_WEBSITE_URL.replace(/\/+$/, '');
28
+ return DEFAULT_THEGITAI_HOST.replace(/\/+$/, '');
17
29
  }
18
30
  function defaultDeviceName() {
19
31
  try {
@@ -102,12 +114,8 @@ function buildAuthUrl(websiteUrl, params) {
102
114
  const url = new URL(`${websiteUrl}/cli-auth`);
103
115
  url.searchParams.set('code_challenge', params.codeChallenge);
104
116
  url.searchParams.set('device_name', params.deviceName);
105
- if (params.redirectUri)
106
- url.searchParams.set('redirect_uri', params.redirectUri);
107
- if (params.state)
108
- url.searchParams.set('state', params.state);
109
- if (params.paste)
110
- url.searchParams.set('mode', 'paste');
117
+ url.searchParams.set('redirect_uri', params.redirectUri);
118
+ url.searchParams.set('state', params.state);
111
119
  return url.toString();
112
120
  }
113
121
  const RESULT_PAGE = (heading, detail) => `<!doctype html><html><head><meta charset="utf-8"><title>TheGitAI CLI</title>` +
@@ -140,36 +148,38 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
140
148
  customer,
141
149
  };
142
150
  }
151
+ async function readPastedResult({ promptCode, signal, serverUrl, codeVerifier, fetchImpl, onPasteRejected, }) {
152
+ while (!signal.aborted) {
153
+ const code = (await promptCode(signal)).trim();
154
+ if (code) {
155
+ try {
156
+ return await exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl });
157
+ }
158
+ catch (error) {
159
+ if (signal.aborted)
160
+ break;
161
+ onPasteRejected?.(error.message);
162
+ }
163
+ }
164
+ await new Promise((resolve) => setImmediate(resolve));
165
+ }
166
+ return await new Promise(() => { });
167
+ }
143
168
  export async function loginViaBrowser(options) {
144
- const serverUrl = normalizeServerUrl(options.serverUrl ?? DEFAULT_SERVER_URL);
169
+ const serverUrl = normalizeServerUrl(options.serverUrl ?? DEFAULT_THEGITAI_HOST);
145
170
  const websiteUrl = resolveWebsiteUrl();
146
171
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
147
172
  const openBrowser = options.openBrowser ?? openUrl;
148
173
  const onUrl = options.onUrl ?? (() => { });
149
174
  const deviceName = withOperatingSystemInfo(options.deviceName ?? defaultDeviceName());
150
175
  const { verifier, challenge } = generatePkce();
151
- if (options.noBrowser) {
152
- const authUrl = buildAuthUrl(websiteUrl, {
153
- codeChallenge: challenge,
154
- deviceName,
155
- paste: true,
156
- });
157
- onUrl(authUrl);
158
- if (!options.promptCode) {
159
- throw new Error('No way to read the authorization code in this context.');
160
- }
161
- const code = (await options.promptCode()).trim();
162
- if (!code) {
163
- throw new Error('No authorization code was entered.');
164
- }
165
- return exchangeCodeForToken({ serverUrl, code, codeVerifier: verifier, fetchImpl });
166
- }
167
176
  const state = crypto.randomBytes(16).toString('base64url');
168
177
  const server = http.createServer();
178
+ let timer;
169
179
  const codePromise = new Promise((resolve, reject) => {
170
- const timer = setTimeout(() => {
180
+ timer = setTimeout(() => {
171
181
  shutDownServer(server);
172
- reject(new Error('Timed out waiting for the browser login to complete.'));
182
+ reject(new Error('Timed out waiting for the login to complete.'));
173
183
  }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
174
184
  server.on('request', (req, res) => {
175
185
  const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
@@ -182,7 +192,7 @@ export async function loginViaBrowser(options) {
182
192
  const returnedState = requestUrl.searchParams.get('state') ?? '';
183
193
  if (!code || returnedState !== state) {
184
194
  res.writeHead(400, { 'content-type': 'text/html', connection: 'close' });
185
- res.end(RESULT_PAGE('Login failed', 'The request could not be verified. Please run ai login again.'));
195
+ res.end(RESULT_PAGE('Login failed', 'The request could not be verified. Please run `ai` again.'));
186
196
  clearTimeout(timer);
187
197
  shutDownServer(server);
188
198
  reject(new Error('The login callback could not be verified.'));
@@ -212,8 +222,44 @@ export async function loginViaBrowser(options) {
212
222
  state,
213
223
  });
214
224
  onUrl(authUrl);
215
- await openBrowser(authUrl).catch(() => false);
216
- options.onWaiting?.();
217
- const code = await codePromise;
218
- return exchangeCodeForToken({ serverUrl, code, codeVerifier: verifier, fetchImpl });
225
+ const opened = await openBrowser(authUrl).catch(() => false);
226
+ options.onBrowserOpen?.(opened);
227
+ let raceSettled = false;
228
+ const untilSettled = (promise) => promise.catch((error) => {
229
+ if (raceSettled)
230
+ return new Promise(() => { });
231
+ throw error;
232
+ });
233
+ const pasteAbort = new AbortController();
234
+ const routes = [
235
+ untilSettled(codePromise.then((code) => exchangeCodeForToken({ serverUrl, code, codeVerifier: verifier, fetchImpl }))),
236
+ ];
237
+ if (options.promptCode) {
238
+ const pasted = readPastedResult({
239
+ promptCode: options.promptCode,
240
+ signal: pasteAbort.signal,
241
+ serverUrl,
242
+ codeVerifier: verifier,
243
+ fetchImpl,
244
+ onPasteRejected: options.onPasteRejected,
245
+ }).catch((error) => {
246
+ if (!raceSettled && isAbortError(error))
247
+ throw new SignInCancelledError();
248
+ if (!raceSettled) {
249
+ return new Promise(() => { });
250
+ }
251
+ throw error;
252
+ });
253
+ routes.push(untilSettled(pasted));
254
+ }
255
+ try {
256
+ return await Promise.race(routes);
257
+ }
258
+ finally {
259
+ raceSettled = true;
260
+ pasteAbort.abort();
261
+ if (timer)
262
+ clearTimeout(timer);
263
+ shutDownServer(server);
264
+ }
219
265
  }
@@ -2,6 +2,7 @@ import { drainBackgroundJobNotifications } from '../background-jobs.js';
2
2
  import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
3
3
  import { applySessionSnapshot, saveSessionState, snapshotFromSession, } from '../session-store.js';
4
4
  import { executeLocalToolCall } from '../tool-executor.js';
5
+ import { saveGeneratedImage } from '../tools/save-generated-image.js';
5
6
  import { isUserInputQuestionArray } from './contracts.js';
6
7
  import { createTraceContext, gatewayFailureCategory, normalizeServerUrl, readErrorResponse, } from './http.js';
7
8
  import { collectClientEnvironment } from '../client-environment.js';
@@ -165,6 +166,9 @@ function publicStatusMessage(data) {
165
166
  if (event.phase === 'analyzing_image') {
166
167
  return (event.imageCount ?? 1) > 1 ? 'Analyzing images...' : 'Analyzing image...';
167
168
  }
169
+ if (event.phase === 'generating_image') {
170
+ return 'Generating image...';
171
+ }
168
172
  if (event.phase === 'running_tool')
169
173
  return `Running ${toolName}...`;
170
174
  if (event.phase === 'waiting_for_tool')
@@ -312,7 +316,14 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
312
316
  }
313
317
  try {
314
318
  const call = normalizeShellJobToolCall(event.call);
315
- const rawResult = await executeLocalToolCall({ projectIndex }, session, call);
319
+ const rawResult = event.generatedImage
320
+ ? saveGeneratedImage({
321
+ base64Data: event.generatedImage.base64Data,
322
+ mimeType: event.generatedImage.mimeType,
323
+ suggestedFilename: event.generatedImage.suggestedFilename ||
324
+ String(call.args?.filename ?? call.args?.file_name ?? ''),
325
+ })
326
+ : await executeLocalToolCall({ projectIndex }, session, call);
316
327
  preserveCancelledTurnToolResult(session, input, { ...event, call }, rawResult);
317
328
  if (signal?.aborted) {
318
329
  throw new TurnCancelledError();
@@ -383,9 +394,15 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
383
394
  const data = event.data;
384
395
  if (data?.phase === 'analyzing_image') {
385
396
  session.onImageAnalysis?.(Math.max(1, Number(data.imageCount ?? 1) || 1));
397
+ session.onImageGeneration?.(false);
398
+ }
399
+ else if (data?.phase === 'generating_image') {
400
+ session.onImageGeneration?.(true);
401
+ session.onImageAnalysis?.(0);
386
402
  }
387
403
  else if (data?.phase) {
388
404
  session.onImageAnalysis?.(0);
405
+ session.onImageGeneration?.(false);
389
406
  }
390
407
  const message = publicStatusMessage(event.data);
391
408
  if (message)
@@ -0,0 +1 @@
1
+ export const DEFAULT_THEGITAI_HOST = 'https://thegit.ai';
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- const DEFAULT_SERVER_URL = 'https://thegit.ai';
2
+ import { DEFAULT_THEGITAI_HOST } from './default-host.js';
3
3
  export const TRACE_ID_HEADER = 'x-thegitai-trace-id';
4
4
  export const CLIENT_HEADER = 'x-thegitai-client';
5
5
  export const CLIENT_PLATFORM_HEADER = 'x-thegitai-client-platform';
@@ -88,7 +88,7 @@ export async function retryTransient(run, { retries = 2, baseDelayMs = 400, dead
88
88
  }
89
89
  }
90
90
  export function normalizeServerUrl(serverUrl) {
91
- const normalized = String(serverUrl || DEFAULT_SERVER_URL)
91
+ const normalized = String(serverUrl || DEFAULT_THEGITAI_HOST)
92
92
  .trim()
93
93
  .replace(/\/+$/, '');
94
94
  if (!/^https?:\/\//i.test(normalized)) {
@@ -1,9 +1,24 @@
1
1
  export const AUTH_COMMANDS = new Set(['login', 'whoami', 'logout']);
2
+ function hasPromptWordsAfter(args) {
3
+ for (let i = 1; i < args.length; i++) {
4
+ const arg = args[i];
5
+ if (arg === '--session' || arg === '--resume') {
6
+ i += 1;
7
+ continue;
8
+ }
9
+ if (arg.startsWith('-'))
10
+ continue;
11
+ return true;
12
+ }
13
+ return false;
14
+ }
2
15
  export function parseArgs(argv) {
3
16
  const args = argv.slice(2);
4
17
  const firstArg = args[0];
5
- const command = firstArg && AUTH_COMMANDS.has(firstArg) ? firstArg : null;
6
- const commandArgs = command ? args.slice(1) : [];
18
+ const command = firstArg && AUTH_COMMANDS.has(firstArg) &&
19
+ (firstArg !== 'login' || !hasPromptWordsAfter(args))
20
+ ? firstArg
21
+ : null;
7
22
  let autoYes = false;
8
23
  let help = false;
9
24
  let version = false;
@@ -12,7 +27,7 @@ export function parseArgs(argv) {
12
27
  let listSessions = false;
13
28
  let unknownOption = null;
14
29
  const promptParts = [];
15
- for (let i = 0; i < args.length; i++) {
30
+ for (let i = command ? 1 : 0; i < args.length; i++) {
16
31
  const arg = args[i];
17
32
  if (arg === '--yes' || arg === '-y') {
18
33
  autoYes = true;
@@ -39,7 +54,7 @@ export function parseArgs(argv) {
39
54
  usage = true;
40
55
  continue;
41
56
  }
42
- if (command === null && unknownOption === null && /^-/.test(arg)) {
57
+ if (unknownOption === null && /^-/.test(arg)) {
43
58
  unknownOption = arg;
44
59
  continue;
45
60
  }
@@ -47,7 +62,6 @@ export function parseArgs(argv) {
47
62
  }
48
63
  return {
49
64
  command,
50
- commandArgs,
51
65
  autoYes,
52
66
  help,
53
67
  version,
@@ -26,8 +26,12 @@ const HELP_MARKDOWN = [
26
26
  '',
27
27
  '## Auth',
28
28
  '',
29
- '- `ai login` — sign in to TheGitAI (opens your browser to the website)',
30
- '- `ai login --no-browser` — print a URL and paste the code (for SSH/headless)',
29
+ '- `ai` — signs you in if you are not already, then starts a session',
30
+ '- `ai login` — the same thing; kept for muscle memory',
31
+ '- Signing in opens your browser. When it cannot open one — over SSH, in a',
32
+ ' container, or when the browser you want is on another device — the same',
33
+ ' screen prints a URL you can open anywhere and takes the code that page',
34
+ ' gives you back. There is no mode to pick and no flag to remember.',
31
35
  '- `ai whoami` — show the signed-in account',
32
36
  '- `ai --usage` — show account usage percentage and reset times',
33
37
  '- `ai logout` — sign out',
@@ -89,6 +93,7 @@ const HELP_MARKDOWN = [
89
93
  '- `/jobs output <id>` — print one job\'s full captured output',
90
94
  '- `/jobs kill <id>` — stop one background job',
91
95
  '- `/new` — start a new conversation; this session remains saved',
96
+ '- `/logout` — sign out and quit',
92
97
  '- `/exit` — quit the session',
93
98
  '',
94
99
  '## Safety & approvals',
@@ -131,12 +136,14 @@ const HELP_MARKDOWN = [
131
136
  '',
132
137
  '## Troubleshooting',
133
138
  '',
134
- '- "Not logged in" → run `ai login`.',
135
139
  '- Auth or permission errors → run `ai whoami` to confirm the signed-in',
136
140
  ' account.',
137
141
  '- Usage or quota errors → run `ai --usage`.',
138
- '- Signed in with the wrong credentials → `ai logout`, then `ai login` with',
139
- ' the account you intended to use.',
142
+ '- Signed in with the wrong account`/logout` (or `ai logout`), then run',
143
+ ' `ai` and sign in as the account you intended to use.',
144
+ '- The browser did not open, or opened on the wrong machine → open the URL',
145
+ " printed on the sign-in screen anywhere you like, choose \"Not on this",
146
+ ' machine?" on that page, and paste the code back into the terminal.',
140
147
  '- A local session was used with a different sign-in → sign in with the',
141
148
  ' account you used for that session or start a new session.',
142
149
  '- For anything else, re-run the command and report the printed error',
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, w
4
4
  import path from 'node:path';
5
5
  import { getClientStateDir } from './client-state.js';
6
6
  import { findStoredImageByContent, pruneSessionImages, readSessionImage, sweepOrphanSessionImages, } from './core/session-image-store.js';
7
+ import { sweepGeneratedImages } from './tools/save-generated-image.js';
7
8
  import { normalizeAssistantEditJournal } from './edit-journal.js';
8
9
  import { createSessionGrants } from './permissions.js';
9
10
  import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
@@ -224,6 +225,7 @@ export function pruneSavedSessions(rootDir, env = process.env) {
224
225
  activeSessionIds: listAllSessionIds(env),
225
226
  env,
226
227
  });
228
+ sweepGeneratedImages({ env });
227
229
  }
228
230
  export function readGitBranch(rootDir) {
229
231
  try {
@@ -0,0 +1,56 @@
1
+ import { stdin as input, stdout as output } from 'node:process';
2
+ import readline from 'node:readline/promises';
3
+ import chalk from './colors.js';
4
+ import { auth } from './api/index.js';
5
+ import { DEFAULT_THEGITAI_HOST } from './api/default-host.js';
6
+ import { loginViaBrowser } from './api/browser-login.js';
7
+ function hyperlink(url, label) {
8
+ if (process.stdout.isTTY !== true)
9
+ return label;
10
+ return `\x1b]8;;${url}\x07${label}\x1b]8;;\x07`;
11
+ }
12
+ export function formatSignInScreen({ url }) {
13
+ return [
14
+ '',
15
+ ` ${chalk.bold.cyan('TheGitAI')}`,
16
+ '',
17
+ ` ${chalk.bold('Sign in to continue.')}`,
18
+ ' Your browser should open automatically. If not, copy this URL:',
19
+ '',
20
+ ` ${chalk.cyan(url)}`,
21
+ '',
22
+ ` ${hyperlink(url, chalk.cyan('→ Click here to authenticate'))}`,
23
+ '',
24
+ chalk.dim(" Signing in on a different machine? Choose \"Not on this machine?\""),
25
+ chalk.dim(' on that page, authorize, and paste the code it gives you below.'),
26
+ '',
27
+ ].join('\n');
28
+ }
29
+ async function promptForCode(signal) {
30
+ const rl = readline.createInterface({ input, output });
31
+ try {
32
+ return await rl.question(` ${chalk.bold('Authorization code:')} `, { signal });
33
+ }
34
+ finally {
35
+ rl.close();
36
+ }
37
+ }
38
+ export async function runSignIn({ env = process.env, login = loginViaBrowser, write = auth.writeCliAuthConfig, log = (line) => console.log(line), } = {}) {
39
+ const result = await login({
40
+ serverUrl: DEFAULT_THEGITAI_HOST,
41
+ deviceName: env.THEGITAI_DEVICE_NAME?.trim() || undefined,
42
+ onUrl: (url) => log(formatSignInScreen({ url })),
43
+ onBrowserOpen: (opened) => {
44
+ if (!opened) {
45
+ log(chalk.dim(' (no browser opened — use the URL or the code box above)'));
46
+ }
47
+ },
48
+ promptCode: promptForCode,
49
+ onPasteRejected: (message) => log(chalk.red(` ✖ ${message}`) + chalk.dim(' Try pasting it again.')),
50
+ });
51
+ write(result, env);
52
+ log('');
53
+ log(chalk.green(` ✓ Signed in as ${result.customer.email}`));
54
+ log('');
55
+ return result;
56
+ }
@@ -23,6 +23,7 @@ import { strReplace } from './str-replace.js';
23
23
  import { undoEdit } from './undo-edit.js';
24
24
  import { updateTodos } from './update-todos.js';
25
25
  import { readImageFile } from './read-image-file.js';
26
+ import { saveGeneratedImage } from './save-generated-image.js';
26
27
  import { writeFile } from './write-file.js';
27
28
  export const TOOL_MAP = {
28
29
  search_code: (context, args) => searchCode(context.projectIndex, args),
@@ -52,6 +53,16 @@ export const TOOL_MAP = {
52
53
  shell_job_kill: shellJobKill,
53
54
  update_todos: updateTodos,
54
55
  analyze_image: (context, args) => readImageFile(context, args),
56
+ generate_image: (_context, args) => {
57
+ if (!String(args.base64Data ?? '').trim()) {
58
+ return {
59
+ ok: false,
60
+ error: 'This CLI build cannot generate images without server-supplied image bytes. Update TheGitAI CLI.',
61
+ failureCategory: 'tool_exception',
62
+ };
63
+ }
64
+ return saveGeneratedImage(args);
65
+ },
55
66
  };
56
67
  function invalidToolCall(error) {
57
68
  return {
@@ -0,0 +1,120 @@
1
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getClientStateDir } from '../client-state.js';
4
+ import { MAX_IMAGE_SIZE_BYTES, sniffImageMimeType, } from '../core/image-limits.js';
5
+ const IMAGE_FILE_MODE = 0o600;
6
+ const IMAGE_DIR_MODE = 0o700;
7
+ export const GENERATED_IMAGES_SUBDIR = 'generated_images';
8
+ export const GENERATED_IMAGE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
9
+ export function getGeneratedImagesDir(env = process.env) {
10
+ return path.join(getClientStateDir(env), GENERATED_IMAGES_SUBDIR);
11
+ }
12
+ export function sanitizeGeneratedImageBasename(raw) {
13
+ const trimmed = String(raw ?? '').trim();
14
+ const base = trimmed
15
+ .replace(/\\/g, '/')
16
+ .split('/')
17
+ .pop()
18
+ ?.replace(/^\.+/, '')
19
+ .replace(/[^\w.\-]+/g, '-')
20
+ .replace(/-+/g, '-')
21
+ .replace(/^-+|-+$/g, '')
22
+ .replace(/-\.|\.-/g, '.')
23
+ .replace(/^-+|-+$/g, '')
24
+ .slice(0, 80);
25
+ if (!base || base === '.' || base === '..') {
26
+ return `generated-${Date.now()}.png`;
27
+ }
28
+ if (/\.png$/i.test(base)) {
29
+ return base.replace(/\.png$/i, '.png');
30
+ }
31
+ const withoutExt = base.replace(/\.[^.]+$/, '').replace(/-+$/g, '');
32
+ return `${withoutExt || 'generated'}.png`;
33
+ }
34
+ function uniquePath(dir, basename) {
35
+ const candidate = path.join(dir, basename);
36
+ if (!existsSync(candidate)) {
37
+ return candidate;
38
+ }
39
+ const ext = path.extname(basename) || '.png';
40
+ const stem = path.basename(basename, ext);
41
+ for (let i = 2; i < 10_000; i += 1) {
42
+ const next = path.join(dir, `${stem}-${i}${ext}`);
43
+ if (!existsSync(next)) {
44
+ return next;
45
+ }
46
+ }
47
+ return path.join(dir, `${stem}-${Date.now()}${ext}`);
48
+ }
49
+ export function sweepGeneratedImages({ maxAgeMs = GENERATED_IMAGE_MAX_AGE_MS, env = process.env, now = Date.now(), } = {}) {
50
+ const dir = getGeneratedImagesDir(env);
51
+ if (!existsSync(dir))
52
+ return 0;
53
+ let removed = 0;
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(dir);
57
+ }
58
+ catch {
59
+ return 0;
60
+ }
61
+ for (const entry of entries) {
62
+ const filePath = path.join(dir, entry);
63
+ try {
64
+ if (now - statSync(filePath).mtimeMs < maxAgeMs)
65
+ continue;
66
+ rmSync(filePath, { force: true });
67
+ removed += 1;
68
+ }
69
+ catch {
70
+ }
71
+ }
72
+ return removed;
73
+ }
74
+ export function saveGeneratedImage(args) {
75
+ const base64Data = String(args.base64Data ?? '').trim();
76
+ if (!base64Data) {
77
+ return {
78
+ ok: false,
79
+ error: 'This CLI build cannot generate images without server-supplied image bytes. Update TheGitAI CLI.',
80
+ failureCategory: 'tool_exception',
81
+ };
82
+ }
83
+ let bytes;
84
+ try {
85
+ bytes = Buffer.from(base64Data, 'base64');
86
+ }
87
+ catch {
88
+ return {
89
+ ok: false,
90
+ error: 'Generated image bytes are invalid.',
91
+ failureCategory: 'invalid_argument',
92
+ };
93
+ }
94
+ if (bytes.length === 0 || bytes.length > MAX_IMAGE_SIZE_BYTES) {
95
+ return {
96
+ ok: false,
97
+ error: 'Generated image exceeded the allowed size limit.',
98
+ failureCategory: 'invalid_argument',
99
+ };
100
+ }
101
+ const sniffed = sniffImageMimeType(bytes);
102
+ if (sniffed !== 'image/png') {
103
+ return {
104
+ ok: false,
105
+ error: 'Generated image must be a PNG.',
106
+ failureCategory: 'invalid_argument',
107
+ };
108
+ }
109
+ const env = args.env ?? process.env;
110
+ const dir = getGeneratedImagesDir(env);
111
+ mkdirSync(dir, { recursive: true, mode: IMAGE_DIR_MODE });
112
+ const basename = sanitizeGeneratedImageBasename(args.suggestedFilename ?? args.filename);
113
+ const target = uniquePath(dir, basename);
114
+ writeFileSync(target, bytes, { mode: IMAGE_FILE_MODE });
115
+ return {
116
+ ok: true,
117
+ path: target,
118
+ message: `Image saved to ${target}`,
119
+ };
120
+ }
@@ -13,7 +13,7 @@ import { setScratchSession } from '../scratch-dir.js';
13
13
  import { setImageStoreSession } from '../core/session-image-store.js';
14
14
  import { cancelActiveCommand } from '../executor.js';
15
15
  import { isTurnFailureMarker } from '../turn-failure-marker.js';
16
- import { clearCliAuthConfig } from '../api/auth.js';
16
+ import { clearCliAuthConfig, logoutFromServer, } from '../api/auth.js';
17
17
  import { authenticationErrorMessage, isAuthenticationError, } from '../api/http.js';
18
18
  import { createUserInputPromptState, formatUserInputTranscript, } from './tui/user-input.js';
19
19
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
@@ -105,6 +105,10 @@ export const SLASH_COMMANDS = [
105
105
  command: '/new',
106
106
  description: 'start a new conversation; this session remains saved',
107
107
  },
108
+ {
109
+ command: '/logout',
110
+ description: 'Sign out and quit',
111
+ },
108
112
  {
109
113
  command: '/exit',
110
114
  description: 'Quit the current session',
@@ -982,6 +986,7 @@ function createInitialShellState(session, serverModels, debugUi) {
982
986
  activeTurnInputPreformatted: false,
983
987
  agentMode: session.agentMode,
984
988
  analyzingImages: 0,
989
+ generatingImage: false,
985
990
  approvalCursor: 0,
986
991
  approvalPrompt: null,
987
992
  approvalScrollOffset: 0,
@@ -1347,6 +1352,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1347
1352
  throw new Error('stdout is not a terminal');
1348
1353
  }
1349
1354
  let fatalError = null;
1355
+ let signedOut = false;
1350
1356
  await withTuiMode(async () => {
1351
1357
  setBackgroundJobSession(session.sessionId);
1352
1358
  setScratchSession(session.sessionId);
@@ -2404,6 +2410,22 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2404
2410
  requestExit();
2405
2411
  return;
2406
2412
  }
2413
+ if (input === '/logout') {
2414
+ store.update((current) => ({
2415
+ ...current,
2416
+ busy: true,
2417
+ status: 'Signing out...',
2418
+ }));
2419
+ try {
2420
+ await logoutFromServer({ config: authConfig });
2421
+ }
2422
+ catch {
2423
+ }
2424
+ clearCliAuthConfig(session.env);
2425
+ signedOut = true;
2426
+ requestExit();
2427
+ return;
2428
+ }
2407
2429
  if (input === '/help') {
2408
2430
  appendStaticEntry({
2409
2431
  body: formatInteractiveHelpText(),
@@ -2623,6 +2645,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2623
2645
  activeTurnInput: input,
2624
2646
  activeTurnInputPreformatted: preformatted,
2625
2647
  analyzingImages: 0,
2648
+ generatingImage: false,
2626
2649
  busy: true,
2627
2650
  busyPausedAt: null,
2628
2651
  busySince: turnStartedAt,
@@ -2777,6 +2800,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2777
2800
  store.update((current) => ({
2778
2801
  ...current,
2779
2802
  analyzingImages: Math.max(0, activeImageCount),
2803
+ generatingImage: activeImageCount > 0 ? false : current.generatingImage,
2804
+ }));
2805
+ };
2806
+ session.onImageGeneration = (active) => {
2807
+ store.update((current) => ({
2808
+ ...current,
2809
+ generatingImage: Boolean(active),
2810
+ analyzingImages: active ? 0 : current.analyzingImages,
2780
2811
  }));
2781
2812
  };
2782
2813
  session.onStatus = (message) => {
@@ -2993,4 +3024,5 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2993
3024
  });
2994
3025
  if (fatalError)
2995
3026
  throw fatalError;
3027
+ return { signedOut };
2996
3028
  }
@@ -181,6 +181,7 @@ const CLIENT_SLASH_COMMANDS = [
181
181
  { command: '/resume', description: 'Open the session picker to resume a previous session' },
182
182
  { command: '/jobs', description: 'Background jobs: pick to view output or kill' },
183
183
  { command: '/new', description: 'start a new conversation; this session remains saved' },
184
+ { command: '/logout', description: 'Sign out and quit' },
184
185
  { command: '/exit', description: 'Quit the current session' },
185
186
  ];
186
187
  function buildModelPickerOptions(currentModelId, serverModels) {
@@ -953,11 +954,13 @@ export function buildWorkingClockLine(state, elapsedSeconds) {
953
954
  if (state.busyPausedAt != null) {
954
955
  return `${WORKING_CLOCK_ICON} Paused · ${elapsed} · waiting for your response`;
955
956
  }
956
- const label = state.analyzingImages > 0
957
- ? state.analyzingImages > 1
958
- ? 'Analyzing images'
959
- : 'Analyzing image'
960
- : 'Working';
957
+ const label = state.generatingImage
958
+ ? 'Generating image'
959
+ : state.analyzingImages > 0
960
+ ? state.analyzingImages > 1
961
+ ? 'Analyzing images'
962
+ : 'Analyzing image'
963
+ : 'Working';
961
964
  return `${WORKING_CLOCK_ICON} ${label} · ${elapsed}`;
962
965
  }
963
966
  export function approvalPanelInnerWidth(width) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.25",
3
+ "version": "1.0.0-preview.27",
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.25",
41
- "@thegitai/tui-darwin-x64": "1.0.0-preview.25",
42
- "@thegitai/tui-linux-x64": "1.0.0-preview.25",
43
- "@thegitai/tui-win32-x64": "1.0.0-preview.25",
40
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.27",
41
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.27",
42
+ "@thegitai/tui-linux-x64": "1.0.0-preview.27",
43
+ "@thegitai/tui-win32-x64": "1.0.0-preview.27",
44
44
  "@vscode/ripgrep": "1.18.0"
45
45
  },
46
46
  "publishConfig": {