@agentuity/cli 0.0.12 → 0.0.13

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.
@@ -1,4 +1,4 @@
1
- import { APIClient } from '@/api';
1
+ import { APIClient, APIError } from '@/api';
2
2
  import type { Config } from '@/types';
3
3
 
4
4
  interface APIResponse<T> {
@@ -23,6 +23,18 @@ export interface LoginResult {
23
23
  expires: Date;
24
24
  }
25
25
 
26
+ interface SignupCompleteData {
27
+ userId: string;
28
+ apiKey: string;
29
+ expiresAt: number;
30
+ }
31
+
32
+ export interface SignupResult {
33
+ apiKey: string;
34
+ userId: string;
35
+ expires: Date;
36
+ }
37
+
26
38
  export async function generateLoginOTP(apiUrl: string, config?: Config | null): Promise<string> {
27
39
  const client = new APIClient(apiUrl, undefined, config);
28
40
  const resp = await client.request<APIResponse<OTPStartData>>('GET', '/cli/auth/start');
@@ -64,8 +76,60 @@ export async function pollForLoginCompletion(
64
76
  };
65
77
  }
66
78
 
67
- await new Promise((resolve) => setTimeout(resolve, 2000));
79
+ await Bun.sleep(2000);
68
80
  }
69
81
 
70
82
  throw new Error('Login timed out');
71
83
  }
84
+
85
+ export function generateSignupOTP(): string {
86
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
87
+ let result = '';
88
+ const array = new Uint8Array(5);
89
+ crypto.getRandomValues(array);
90
+ for (let i = 0; i < 5; i++) {
91
+ result += chars[array[i] % chars.length];
92
+ }
93
+ return result;
94
+ }
95
+
96
+ export async function pollForSignupCompletion(
97
+ apiUrl: string,
98
+ otp: string,
99
+ config?: Config | null,
100
+ timeoutMs = 300000
101
+ ): Promise<SignupResult> {
102
+ const client = new APIClient(apiUrl, undefined, config);
103
+ const started = Date.now();
104
+
105
+ while (Date.now() - started < timeoutMs) {
106
+ try {
107
+ const resp = await client.request<APIResponse<SignupCompleteData>>(
108
+ 'GET',
109
+ `/cli/auth/signup/${otp}`
110
+ );
111
+
112
+ if (!resp.success) {
113
+ throw new Error(resp.message);
114
+ }
115
+
116
+ if (resp.data) {
117
+ return {
118
+ apiKey: resp.data.apiKey,
119
+ userId: resp.data.userId,
120
+ expires: new Date(resp.data.expiresAt),
121
+ };
122
+ }
123
+ } catch (error) {
124
+ if (error instanceof APIError && error.status === 404) {
125
+ await Bun.sleep(2000);
126
+ continue;
127
+ }
128
+ throw error;
129
+ }
130
+
131
+ await Bun.sleep(2000);
132
+ }
133
+
134
+ throw new Error('Signup timed out');
135
+ }
@@ -1,9 +1,10 @@
1
1
  import { createCommand } from '@/types';
2
2
  import { loginCommand } from './login';
3
3
  import { logoutCommand } from './logout';
4
+ import { signupCommand } from './signup';
4
5
 
5
6
  export const command = createCommand({
6
7
  name: 'auth',
7
8
  description: 'Authentication and authorization related commands',
8
- subcommands: [loginCommand, logoutCommand],
9
+ subcommands: [loginCommand, logoutCommand, signupCommand],
9
10
  });
@@ -15,9 +15,16 @@ export const loginCommand: SubcommandDefinition = {
15
15
  const appUrl = getAppBaseURL(config);
16
16
 
17
17
  try {
18
- console.log('Generating login OTP...');
18
+ let otp: string | undefined;
19
+
20
+ await tui.spinner('Generating login one time code...', async () => {
21
+ otp = await generateLoginOTP(apiUrl, config);
22
+ });
23
+
24
+ if (!otp) {
25
+ return;
26
+ }
19
27
 
20
- const otp = await generateLoginOTP(apiUrl, config);
21
28
  const authURL = `${appUrl}/auth/cli`;
22
29
 
23
30
  const copied = await tui.copyToClipboard(otp);
@@ -39,7 +46,8 @@ export const loginCommand: SubcommandDefinition = {
39
46
  tui.newline();
40
47
 
41
48
  if (process.platform === 'darwin') {
42
- await tui.waitForAnyKey('Press Enter to open the URL...');
49
+ await tui.waitForAnyKey('Press any key to open the URL in your browser...');
50
+ tui.newline();
43
51
  try {
44
52
  Bun.spawn(['open', authURL], {
45
53
  stdio: ['ignore', 'ignore', 'ignore'],
@@ -0,0 +1,51 @@
1
+ import type { SubcommandDefinition } from '@/types';
2
+ import { getAPIBaseURL, getAppBaseURL, UpgradeRequiredError } from '@/api';
3
+ import { saveAuth } from '@/config';
4
+ import { generateSignupOTP, pollForSignupCompletion } from './api';
5
+ import * as tui from '@/tui';
6
+
7
+ export const signupCommand: SubcommandDefinition = {
8
+ name: 'signup',
9
+ description: 'Create a new Agentuity Cloud Platform account',
10
+ toplevel: true,
11
+
12
+ async handler(ctx) {
13
+ const { logger, config } = ctx;
14
+ const apiUrl = getAPIBaseURL(config);
15
+ const appUrl = getAppBaseURL(config);
16
+
17
+ try {
18
+ const otp = generateSignupOTP();
19
+
20
+ const signupURL = `${appUrl}/sign-up?code=${otp}`;
21
+
22
+ const bannerBody = `Please open the URL in your browser:\n\n${tui.link(signupURL)}\n\n${tui.muted('Once you have completed the signup process, you will be given a one-time password to complete the signup process.')}`;
23
+
24
+ tui.banner('Signup for Agentuity', bannerBody);
25
+ tui.newline();
26
+
27
+ await tui.spinner('Waiting for signup to complete...', async () => {
28
+ const result = await pollForSignupCompletion(apiUrl, otp, config);
29
+
30
+ await saveAuth({
31
+ apiKey: result.apiKey,
32
+ userId: result.userId,
33
+ expires: result.expires,
34
+ });
35
+ });
36
+
37
+ tui.newline();
38
+ tui.success('Welcome to Agentuity! You are now logged in');
39
+ } catch (error) {
40
+ if (error instanceof UpgradeRequiredError) {
41
+ const bannerBody = `${error.message}\n\nVisit: ${tui.link('https://agentuity.dev/CLI/installation')}`;
42
+ tui.banner('CLI Upgrade Required', bannerBody);
43
+ process.exit(1);
44
+ } else if (error instanceof Error) {
45
+ logger.fatal(`Signup failed: ${error.message}`);
46
+ } else {
47
+ logger.fatal('Signup failed');
48
+ }
49
+ }
50
+ },
51
+ };
@@ -1,8 +1,8 @@
1
1
  import { createCommand } from '@/types';
2
2
  import { z } from 'zod';
3
- import { resolve } from 'node:path';
3
+ import { resolve, join } from 'node:path';
4
4
  import { bundle } from '../bundle/bundler';
5
- import { existsSync } from 'node:fs';
5
+ import { existsSync, FSWatcher, watch } from 'node:fs';
6
6
  import * as tui from '@/tui';
7
7
 
8
8
  export const command = createCommand({
@@ -13,69 +13,156 @@ export const command = createCommand({
13
13
  dir: z.string().optional().describe('Root directory of the project'),
14
14
  }),
15
15
  },
16
+ optionalAuth: 'Continue without an account (local only)',
16
17
 
17
18
  async handler(ctx) {
18
- const { opts } = ctx;
19
+ const { opts, logger } = ctx;
20
+
19
21
  const rootDir = resolve(opts.dir || process.cwd());
22
+ const appTs = join(rootDir, 'app.ts');
23
+ const srcDir = join(rootDir, 'src');
24
+ const mustHaves = [join(rootDir, 'package.json'), appTs, srcDir];
25
+ const missing: string[] = [];
26
+
27
+ for (const filename of mustHaves) {
28
+ if (!existsSync(filename)) {
29
+ missing.push(filename);
30
+ }
31
+ }
32
+
33
+ if (missing.length) {
34
+ tui.error(`${rootDir} does not appear to be a valid Agentuity project`);
35
+ for (const filename of missing) {
36
+ tui.bullet(`Missing ${filename}`);
37
+ }
38
+ process.exit(1);
39
+ }
40
+
20
41
  const agentuityDir = resolve(rootDir, '.agentuity');
21
42
  const appPath = resolve(agentuityDir, 'app.js');
22
43
 
23
- try {
24
- await tui.spinner('Building project...', async () => {
25
- await bundle({
26
- rootDir,
27
- dev: true,
28
- });
29
- });
44
+ const watches = [appTs, srcDir];
45
+ const watchers: FSWatcher[] = [];
46
+ let failures = 0;
47
+ let running = false;
48
+ let pid = 0;
49
+ let failed = false;
50
+ let devServer: Bun.Subprocess | undefined;
30
51
 
31
- tui.success('Build complete');
52
+ function failure(msg: string) {
53
+ failed = true;
54
+ failures++;
55
+ if (failures >= 5) {
56
+ tui.error(msg);
57
+ tui.fatal('too many failures, exiting');
58
+ } else {
59
+ setImmediate(() => tui.error(msg));
60
+ }
61
+ }
32
62
 
33
- if (!existsSync(appPath)) {
34
- tui.error(`App file not found: ${appPath}`);
35
- process.exit(1);
63
+ const kill = () => {
64
+ running = false;
65
+ try {
66
+ // Kill the process group (negative PID kills entire group)
67
+ process.kill(-pid, 'SIGTERM');
68
+ } catch {
69
+ // Fallback: kill the direct process
70
+ try {
71
+ if (devServer) {
72
+ devServer.kill();
73
+ }
74
+ } catch {
75
+ // Ignore if already dead
76
+ }
77
+ } finally {
78
+ devServer = undefined;
36
79
  }
80
+ };
37
81
 
38
- tui.info('Starting development server...');
39
-
40
- // Use shell to run in a process group for proper cleanup
41
- // The 'exec' ensures the shell is replaced by the actual process
42
- const devServer = Bun.spawn(['sh', '-c', `exec bun run "${appPath}"`], {
43
- cwd: rootDir,
44
- stdout: 'inherit',
45
- stderr: 'inherit',
46
- stdin: 'inherit',
47
- });
48
-
49
- // Handle signals to ensure entire process tree is killed
50
- const cleanup = () => {
51
- if (devServer.pid) {
52
- try {
53
- // Kill the process group (negative PID kills entire group)
54
- process.kill(-devServer.pid, 'SIGTERM');
55
- } catch {
56
- // Fallback: kill the direct process
82
+ // Handle signals to ensure entire process tree is killed
83
+ const cleanup = () => {
84
+ if (pid && running) {
85
+ kill();
86
+ }
87
+ for (const watcher of watchers) {
88
+ watcher.close();
89
+ }
90
+ watchers.length = 0;
91
+ process.exit(0);
92
+ };
93
+
94
+ process.on('SIGINT', cleanup);
95
+ process.on('SIGTERM', cleanup);
96
+
97
+ async function restart() {
98
+ try {
99
+ if (running) {
100
+ tui.info('Restarting on file change');
101
+ kill();
102
+ return;
103
+ }
104
+ await Promise.all([
105
+ tui.runCommand({
106
+ command: 'tsc',
107
+ cmd: ['bunx', 'tsc', '--noEmit'],
108
+ cwd: rootDir,
109
+ clearOnSuccess: true,
110
+ truncate: false,
111
+ maxLinesOutput: 1,
112
+ maxLinesOnFailure: 15,
113
+ }),
114
+ tui.spinner('Building project', async () => {
57
115
  try {
58
- devServer.kill();
116
+ await bundle({
117
+ rootDir,
118
+ dev: true,
119
+ });
59
120
  } catch {
60
- // Ignore if already dead
121
+ failure('Build failed');
61
122
  }
62
- }
123
+ }),
124
+ ]);
125
+
126
+ if (failed) {
127
+ return;
63
128
  }
64
- process.exit(0);
65
- };
66
129
 
67
- process.on('SIGINT', cleanup);
68
- process.on('SIGTERM', cleanup);
130
+ if (!existsSync(appPath)) {
131
+ failure(`App file not found: ${appPath}`);
132
+ return;
133
+ }
69
134
 
70
- const exitCode = await devServer.exited;
71
- process.exit(exitCode);
72
- } catch (error) {
73
- if (error instanceof Error) {
74
- tui.error(`Dev server failed: ${error.message}`);
75
- } else {
76
- tui.error('Dev server failed');
135
+ // Use shell to run in a process group for proper cleanup
136
+ // The 'exec' ensures the shell is replaced by the actual process
137
+ const devServer = Bun.spawn(['sh', '-c', `exec bun run "${appPath}"`], {
138
+ cwd: rootDir,
139
+ stdout: 'inherit',
140
+ stderr: 'inherit',
141
+ stdin: 'inherit',
142
+ });
143
+
144
+ running = true;
145
+ failed = false;
146
+ pid = devServer.pid;
147
+
148
+ const exitCode = await devServer.exited;
149
+ if (exitCode === 0) {
150
+ process.exit(exitCode);
151
+ }
152
+ } catch (error) {
153
+ if (error instanceof Error) {
154
+ failure(`Dev server failed: ${error.message}`);
155
+ } else {
156
+ failure('Dev server failed');
157
+ }
158
+ } finally {
159
+ running = false;
77
160
  }
78
- process.exit(1);
161
+ }
162
+ await restart();
163
+ for (const filename of watches) {
164
+ logger.trace('watching %s', filename);
165
+ watchers.push(watch(filename, { recursive: true }, restart));
79
166
  }
80
167
  },
81
168
  });
@@ -8,6 +8,7 @@ import { versionSubcommand } from './version';
8
8
  import { createUserSubcommand } from './create-user';
9
9
  import { runCommandSubcommand } from './run-command';
10
10
  import { soundSubcommand } from './sound';
11
+ import { optionalAuthSubcommand } from './optional-auth';
11
12
 
12
13
  export const command = createCommand({
13
14
  name: 'example',
@@ -23,5 +24,6 @@ export const command = createCommand({
23
24
  createUserSubcommand,
24
25
  runCommandSubcommand,
25
26
  soundSubcommand,
27
+ optionalAuthSubcommand,
26
28
  ],
27
29
  });
@@ -0,0 +1,38 @@
1
+ import type { SubcommandDefinition, CommandContext, AuthData } from '@/types';
2
+ import * as tui from '@/tui';
3
+
4
+ export const optionalAuthSubcommand: SubcommandDefinition = {
5
+ name: 'optional-auth',
6
+ description: 'Test optional authentication flow',
7
+ optionalAuth: 'Continue with local features only',
8
+ handler: async (ctx: CommandContext) => {
9
+ tui.newline();
10
+
11
+ // Type guard to check if auth is present
12
+ const ctxWithAuth = ctx as CommandContext<true>;
13
+ if ('auth' in ctx && ctxWithAuth.auth) {
14
+ const auth = ctxWithAuth.auth as AuthData;
15
+ // User chose to authenticate
16
+ tui.success('You are authenticated!');
17
+ tui.info(`User ID: ${auth.userId}`);
18
+ tui.info(`Session expires: ${auth.expires.toLocaleString()}`);
19
+ tui.newline();
20
+ tui.info('You can now access cloud features:');
21
+ tui.bullet('Deploy to production');
22
+ tui.bullet('Access remote resources');
23
+ tui.bullet('View team analytics');
24
+ } else {
25
+ // User chose to continue without auth
26
+ tui.info('Running in local mode (no authentication)');
27
+ tui.newline();
28
+ tui.info('Available local features:');
29
+ tui.bullet('Local development');
30
+ tui.bullet('Offline testing');
31
+ tui.bullet('Build and bundle');
32
+ tui.newline();
33
+ tui.warning('Some cloud features are unavailable without authentication');
34
+ }
35
+
36
+ tui.newline();
37
+ },
38
+ };
package/src/config.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { YAML } from 'bun';
2
- import { join, extname } from 'node:path';
2
+ import { join, extname, basename } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { mkdir, readdir, readFile, writeFile, chmod } from 'node:fs/promises';
5
5
  import type { Config, Profile, AuthData } from './types';
@@ -104,6 +104,13 @@ export async function loadConfig(customPath?: string): Promise<Config | null> {
104
104
  const content = await file.text();
105
105
  const config = YAML.parse(content);
106
106
 
107
+ // check to see if this is a legacy config file that might not have the required name
108
+ // and in this case we can just use the filename
109
+ const _config = config as { name?: string };
110
+ if (!_config.name) {
111
+ _config.name = basename(configPath).replace(extname(configPath), '');
112
+ }
113
+
107
114
  const result = ConfigSchema.safeParse(config);
108
115
  if (!result.success) {
109
116
  tui.error(`Invalid config in ${configPath}:`);
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { createCLI, registerCommands } from './cli';
2
2
  export { validateRuntime, isBun } from './runtime';
3
3
  export { getVersion, getRevision, getPackageName, getPackage } from './version';
4
+ export { requireAuth, optionalAuth, withAuth, withOptionalAuth } from './auth';
4
5
  export {
5
6
  loadConfig,
6
7
  saveConfig,
package/src/tui.ts CHANGED
@@ -82,6 +82,16 @@ export function error(message: string): void {
82
82
  console.error(`${color}${ICONS.error} ${message}${reset}`);
83
83
  }
84
84
 
85
+ /**
86
+ * Print an error message with a red X and then exit
87
+ */
88
+ export function fatal(message: string): never {
89
+ const color = getColor('error');
90
+ const reset = COLORS.reset;
91
+ console.error(`${color}${ICONS.error} ${message}${reset}`);
92
+ process.exit(1);
93
+ }
94
+
85
95
  /**
86
96
  * Print a warning message with a yellow warning icon
87
97
  */
@@ -200,9 +210,13 @@ export function padLeft(str: string, length: number, pad = ' '): string {
200
210
  * Creates a bordered box around the content
201
211
  *
202
212
  * Uses Bun.stringWidth() for accurate width calculation with ANSI codes and unicode
213
+ * Responsive to terminal width - adapts to narrow terminals
203
214
  */
204
215
  export function banner(title: string, body: string): void {
205
- const maxWidth = 80;
216
+ // Get terminal width, default to 80 if not available, minimum 40
217
+ const termWidth = process.stdout.columns || 80;
218
+ const maxWidth = Math.max(40, Math.min(termWidth - 2, 80)); // Between 40 and 80, with 2 char margin
219
+
206
220
  const border = {
207
221
  topLeft: '╭',
208
222
  topRight: '╮',
@@ -377,6 +391,33 @@ export async function confirm(message: string, defaultValue = true): Promise<boo
377
391
  });
378
392
  }
379
393
 
394
+ /**
395
+ * Display a signup benefits box with cyan border
396
+ * Shows the value proposition for creating an Agentuity account
397
+ */
398
+ export function showSignupBenefits(): void {
399
+ const CYAN = Bun.color('cyan', 'ansi-16m');
400
+ const TEXT =
401
+ currentColorScheme === 'dark' ? Bun.color('white', 'ansi') : Bun.color('black', 'ansi');
402
+ const RESET = '\x1b[0m';
403
+
404
+ const lines = [
405
+ '╔════════════════════════════════════════════╗',
406
+ `║ ⨺ Signup for Agentuity ${muted('free')}${CYAN} ║`,
407
+ '║ ║',
408
+ `║ ✓ ${TEXT}Cloud deployment, previews and CI/CD${CYAN} ║`,
409
+ `║ ✓ ${TEXT}AI Gateway, KV, Vector and more${CYAN} ║`,
410
+ `║ ✓ ${TEXT}Observability, Tracing and Logging${CYAN} ║`,
411
+ `║ ✓ ${TEXT}Organization and Team support${CYAN} ║`,
412
+ `║ ✓ ${TEXT}And much more!${CYAN} ║`,
413
+ '╚════════════════════════════════════════════╝',
414
+ ];
415
+
416
+ console.log('');
417
+ lines.forEach((line) => console.log(CYAN + line + RESET));
418
+ console.log('');
419
+ }
420
+
380
421
  /**
381
422
  * Copy text to clipboard
382
423
  * Returns true if successful, false otherwise
@@ -443,9 +484,36 @@ function getDisplayWidth(str: string): number {
443
484
  return Bun.stringWidth(withoutOSC8);
444
485
  }
445
486
 
487
+ /**
488
+ * Extract ANSI codes from the beginning of a string
489
+ */
490
+ function extractLeadingAnsiCodes(str: string): string {
491
+ // Match ANSI escape sequences at the start of the string
492
+ // eslint-disable-next-line no-control-regex
493
+ const match = str.match(/^(\x1b\[[0-9;]*m)+/);
494
+ return match ? match[0] : '';
495
+ }
496
+
497
+ /**
498
+ * Strip ANSI codes from a string
499
+ */
500
+ function stripAnsiCodes(str: string): string {
501
+ // Remove all ANSI escape sequences
502
+ // eslint-disable-next-line no-control-regex
503
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
504
+ }
505
+
506
+ /**
507
+ * Check if a string ends with ANSI reset code
508
+ */
509
+ function endsWithReset(str: string): boolean {
510
+ return str.endsWith('\x1b[0m') || str.endsWith(COLORS.reset);
511
+ }
512
+
446
513
  /**
447
514
  * Wrap text to a maximum width
448
515
  * Handles explicit newlines and word wrapping
516
+ * Preserves ANSI color codes across wrapped lines
449
517
  */
450
518
  function wrapText(text: string, maxWidth: number): string[] {
451
519
  const allLines: string[] = [];
@@ -460,6 +528,13 @@ function wrapText(text: string, maxWidth: number): string[] {
460
528
  continue;
461
529
  }
462
530
 
531
+ // Record starting index for this paragraph's lines
532
+ const paragraphStart = allLines.length;
533
+
534
+ // Extract any leading ANSI codes from the paragraph
535
+ const leadingCodes = extractLeadingAnsiCodes(paragraph);
536
+ const hasReset = endsWithReset(paragraph);
537
+
463
538
  // Wrap each paragraph
464
539
  const words = paragraph.split(' ');
465
540
  let currentLine = '';
@@ -477,13 +552,30 @@ function wrapText(text: string, maxWidth: number): string[] {
477
552
  }
478
553
  // If the word itself is longer than maxWidth, just use it as is
479
554
  // (better to have a long line than break in the middle)
480
- currentLine = word;
555
+ // But if we have leading codes and this isn't the first line, apply them
556
+ if (leadingCodes && currentLine) {
557
+ // Strip any existing codes from the word to avoid duplication
558
+ const strippedWord = stripAnsiCodes(word);
559
+ currentLine = leadingCodes + strippedWord;
560
+ } else {
561
+ currentLine = word;
562
+ }
481
563
  }
482
564
  }
483
565
 
484
566
  if (currentLine) {
485
567
  allLines.push(currentLine);
486
568
  }
569
+
570
+ // If the original paragraph had ANSI codes and ended with reset,
571
+ // ensure each wrapped line ends with reset (only for this paragraph's lines)
572
+ if (leadingCodes && hasReset) {
573
+ for (let i = paragraphStart; i < allLines.length; i++) {
574
+ if (!endsWithReset(allLines[i])) {
575
+ allLines[i] += COLORS.reset;
576
+ }
577
+ }
578
+ }
487
579
  }
488
580
 
489
581
  return allLines.length > 0 ? allLines : [''];
@@ -658,6 +750,20 @@ export interface CommandRunnerOptions {
658
750
  * Defaults to false
659
751
  */
660
752
  clearOnSuccess?: boolean;
753
+ /**
754
+ * If true or undefined, will truncate each line of output
755
+ */
756
+ truncate?: boolean;
757
+
758
+ /**
759
+ * If undefined, will show up to 3 last lines of output while running. Customize the number with this property.
760
+ */
761
+ maxLinesOutput?: number;
762
+
763
+ /**
764
+ * If undefined, will show up to 10 last lines on failure. Customize the number with this property.
765
+ */
766
+ maxLinesOnFailure?: number;
661
767
  }
662
768
 
663
769
  /**
@@ -671,7 +777,16 @@ export interface CommandRunnerOptions {
671
777
  * Shows the last 3 lines of output as it streams.
672
778
  */
673
779
  export async function runCommand(options: CommandRunnerOptions): Promise<number> {
674
- const { command, cmd, cwd, env, clearOnSuccess = false } = options;
780
+ const {
781
+ command,
782
+ cmd,
783
+ cwd,
784
+ env,
785
+ clearOnSuccess = false,
786
+ truncate = true,
787
+ maxLinesOutput = 3,
788
+ maxLinesOnFailure = 10,
789
+ } = options;
675
790
  const isTTY = process.stdout.isTTY;
676
791
 
677
792
  // If not a TTY, just run the command normally and log output
@@ -746,7 +861,7 @@ export async function runCommand(options: CommandRunnerOptions): Promise<number>
746
861
  };
747
862
 
748
863
  // Initial display
749
- renderOutput(3);
864
+ renderOutput(maxLinesOutput);
750
865
 
751
866
  try {
752
867
  // Spawn the command
@@ -810,7 +925,7 @@ export async function runCommand(options: CommandRunnerOptions): Promise<number>
810
925
  );
811
926
  } else {
812
927
  // Determine how many lines to show in final output
813
- const finalLinesToShow = exitCode === 0 ? 3 : 10;
928
+ const finalLinesToShow = exitCode === 0 ? maxLinesOutput : maxLinesOnFailure;
814
929
 
815
930
  // Show final status with appropriate color
816
931
  const statusColor = exitCode === 0 ? green : red;
@@ -820,7 +935,7 @@ export async function runCommand(options: CommandRunnerOptions): Promise<number>
820
935
  const finalOutputLines = allOutputLines.slice(-finalLinesToShow);
821
936
  for (const line of finalOutputLines) {
822
937
  let displayLine = line;
823
- if (getDisplayWidth(displayLine) > maxLineWidth) {
938
+ if (truncate && getDisplayWidth(displayLine) > maxLineWidth) {
824
939
  displayLine = displayLine.slice(0, maxLineWidth - 3) + '...';
825
940
  }
826
941
  process.stdout.write(`\r\x1b[K${mutedColor}${displayLine}${reset}\n`);