@agentuity/cli 0.0.72 → 0.0.73

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 (71) hide show
  1. package/bin/cli.ts +19 -5
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +77 -19
  4. package/dist/cli.js.map +1 -1
  5. package/dist/cmd/auth/api.d.ts +2 -2
  6. package/dist/cmd/auth/api.d.ts.map +1 -1
  7. package/dist/cmd/auth/api.js +15 -14
  8. package/dist/cmd/auth/api.js.map +1 -1
  9. package/dist/cmd/auth/login.d.ts.map +1 -1
  10. package/dist/cmd/auth/login.js +37 -16
  11. package/dist/cmd/auth/login.js.map +1 -1
  12. package/dist/cmd/auth/ssh/api.d.ts.map +1 -1
  13. package/dist/cmd/auth/ssh/api.js +3 -2
  14. package/dist/cmd/auth/ssh/api.js.map +1 -1
  15. package/dist/cmd/build/ast.d.ts.map +1 -1
  16. package/dist/cmd/build/ast.js +56 -8
  17. package/dist/cmd/build/ast.js.map +1 -1
  18. package/dist/cmd/build/bundler.d.ts.map +1 -1
  19. package/dist/cmd/build/bundler.js +2 -0
  20. package/dist/cmd/build/bundler.js.map +1 -1
  21. package/dist/cmd/build/format-schema.d.ts +6 -0
  22. package/dist/cmd/build/format-schema.d.ts.map +1 -0
  23. package/dist/cmd/build/format-schema.js +60 -0
  24. package/dist/cmd/build/format-schema.js.map +1 -0
  25. package/dist/cmd/build/index.d.ts.map +1 -1
  26. package/dist/cmd/build/index.js +13 -0
  27. package/dist/cmd/build/index.js.map +1 -1
  28. package/dist/cmd/build/plugin.d.ts.map +1 -1
  29. package/dist/cmd/build/plugin.js +72 -2
  30. package/dist/cmd/build/plugin.js.map +1 -1
  31. package/dist/cmd/cloud/deployment/show.d.ts.map +1 -1
  32. package/dist/cmd/cloud/deployment/show.js +34 -10
  33. package/dist/cmd/cloud/deployment/show.js.map +1 -1
  34. package/dist/cmd/dev/agents.d.ts.map +1 -1
  35. package/dist/cmd/dev/agents.js +2 -2
  36. package/dist/cmd/dev/agents.js.map +1 -1
  37. package/dist/cmd/dev/sync.d.ts.map +1 -1
  38. package/dist/cmd/dev/sync.js +2 -2
  39. package/dist/cmd/dev/sync.js.map +1 -1
  40. package/dist/cmd/project/show.d.ts.map +1 -1
  41. package/dist/cmd/project/show.js +8 -7
  42. package/dist/cmd/project/show.js.map +1 -1
  43. package/dist/cmd/project/template-flow.d.ts.map +1 -1
  44. package/dist/cmd/project/template-flow.js +14 -2
  45. package/dist/cmd/project/template-flow.js.map +1 -1
  46. package/dist/config.d.ts.map +1 -1
  47. package/dist/config.js +9 -0
  48. package/dist/config.js.map +1 -1
  49. package/dist/tui.d.ts +20 -1
  50. package/dist/tui.d.ts.map +1 -1
  51. package/dist/tui.js +85 -14
  52. package/dist/tui.js.map +1 -1
  53. package/dist/types.d.ts +10 -0
  54. package/dist/types.d.ts.map +1 -1
  55. package/package.json +3 -3
  56. package/src/cli.ts +85 -25
  57. package/src/cmd/auth/api.ts +20 -22
  58. package/src/cmd/auth/login.ts +36 -17
  59. package/src/cmd/auth/ssh/api.ts +5 -6
  60. package/src/cmd/build/ast.ts +67 -8
  61. package/src/cmd/build/bundler.ts +2 -0
  62. package/src/cmd/build/format-schema.ts +66 -0
  63. package/src/cmd/build/index.ts +14 -0
  64. package/src/cmd/build/plugin.ts +86 -2
  65. package/src/cmd/cloud/deployment/show.ts +42 -10
  66. package/src/cmd/dev/agents.ts +2 -4
  67. package/src/cmd/dev/sync.ts +6 -8
  68. package/src/cmd/project/show.ts +8 -6
  69. package/src/cmd/project/template-flow.ts +21 -2
  70. package/src/config.ts +10 -0
  71. package/src/tui.ts +119 -16
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Simple formatter for schema code strings.
3
+ * Adds basic indentation and line breaks for readability.
4
+ */
5
+ export function formatSchemaCode(code: string): string {
6
+ if (!code) return code;
7
+
8
+ let indentLevel = 0;
9
+ const indentSize = 2;
10
+ const lines: string[] = [];
11
+ let currentLine = '';
12
+
13
+ for (let i = 0; i < code.length; i++) {
14
+ const char = code[i];
15
+ const nextChar = code[i + 1];
16
+ const prevChar = i > 0 ? code[i - 1] : '';
17
+
18
+ // Skip existing whitespace/newlines
19
+ if (char === '\n' || char === '\r' || (char === ' ' && prevChar === ' ')) {
20
+ continue;
21
+ }
22
+
23
+ // Handle opening braces
24
+ if (char === '{') {
25
+ currentLine += char;
26
+ lines.push(' '.repeat(indentLevel * indentSize) + currentLine.trim());
27
+ indentLevel++;
28
+ currentLine = '';
29
+ continue;
30
+ }
31
+
32
+ // Handle closing braces
33
+ if (char === '}') {
34
+ if (currentLine.trim()) {
35
+ lines.push(' '.repeat(indentLevel * indentSize) + currentLine.trim());
36
+ currentLine = '';
37
+ }
38
+ indentLevel--;
39
+ // Check if next char is closing paren - if so, put on same line
40
+ if (nextChar === ')') {
41
+ currentLine = '}';
42
+ } else {
43
+ lines.push(' '.repeat(indentLevel * indentSize) + char);
44
+ }
45
+ continue;
46
+ }
47
+
48
+ // Handle commas - add line break after
49
+ if (char === ',') {
50
+ currentLine += char;
51
+ lines.push(' '.repeat(indentLevel * indentSize) + currentLine.trim());
52
+ currentLine = '';
53
+ continue;
54
+ }
55
+
56
+ // Accumulate characters
57
+ currentLine += char;
58
+ }
59
+
60
+ // Add any remaining content
61
+ if (currentLine.trim()) {
62
+ lines.push(' '.repeat(indentLevel * indentSize) + currentLine.trim());
63
+ }
64
+
65
+ return lines.join('\n');
66
+ }
@@ -77,6 +77,20 @@ export const command = createCommand({
77
77
  logger: ctx.logger,
78
78
  });
79
79
 
80
+ // Copy profile-specific .env file AFTER bundling (bundler clears outDir first)
81
+ if (opts.dev && ctx.config?.name) {
82
+ const envSourcePath = join(absoluteProjectDir, `.env.${ctx.config.name}`);
83
+ const envDestPath = join(outDir, '.env');
84
+
85
+ const envFile = Bun.file(envSourcePath);
86
+ if (await envFile.exists()) {
87
+ await Bun.write(envDestPath, envFile);
88
+ ctx.logger.debug(`Copied ${envSourcePath} to ${envDestPath}`);
89
+ } else {
90
+ ctx.logger.debug(`No .env.${ctx.config.name} file found, skipping env copy`);
91
+ }
92
+ }
93
+
80
94
  // Run TypeScript type checking after registry generation (skip in dev mode)
81
95
  if (!opts.dev && !opts.skipTypeCheck) {
82
96
  try {
@@ -371,10 +371,30 @@ import { readFileSync, existsSync } from 'node:fs';
371
371
  }
372
372
  }
373
373
  const webstatic = serveStatic({ root: import.meta.dir + '/web' });
374
+ // In dev mode, serve from source; in prod, serve from build output
375
+ const publicRoot = ${isDevMode} ? ${JSON.stringify(join(srcDir, 'web', 'public'))} : import.meta.dir + '/web/public';
376
+ const publicstatic = serveStatic({ root: publicRoot, rewriteRequestPath: (path) => path });
374
377
  router.get('/', (c) => c.html(index));
375
378
  router.get('/web/chunk/*', webstatic);
376
379
  router.get('/web/asset/*', webstatic);
377
- router.get('/public/*', webstatic);
380
+ // Serve public assets at root (e.g., /favicon.ico) - must be last
381
+ router.get('/*', async (c, next) => {
382
+ const path = c.req.path;
383
+ // Prevent directory traversal attacks
384
+ if (path.includes('..') || path.includes('%2e%2e')) {
385
+ return c.notFound();
386
+ }
387
+ // Only serve from public folder at root (skip /web/* routes and /)
388
+ if (path !== '/' && !path.startsWith('/web/')) {
389
+ try {
390
+ // serveStatic calls next() internally if file not found
391
+ return await publicstatic(c, next);
392
+ } catch (err) {
393
+ return next();
394
+ }
395
+ }
396
+ return next();
397
+ });
378
398
  })();`);
379
399
  }
380
400
 
@@ -415,7 +435,11 @@ import { readFileSync, existsSync } from 'node:fs';
415
435
 
416
436
  for (const subdir of subdirs) {
417
437
  const fullPath = join(agentBaseDir, subdir);
418
- if (!agentDirs.has(fullPath)) {
438
+ // Check if this directory or any subdirectory contains agents
439
+ const hasAgentInTree = Array.from(agentDirs).some((agentDir) =>
440
+ agentDir.startsWith(fullPath)
441
+ );
442
+ if (!hasAgentInTree) {
419
443
  throw new Error(
420
444
  `Directory ${subdir} in src/agent must contain at least one agent (a file with a createAgent export)`
421
445
  );
@@ -447,6 +471,56 @@ import { readFileSync, existsSync } from 'node:fs';
447
471
  if (statSync(apiFile).isFile()) {
448
472
  try {
449
473
  const routes = await parseRoute(rootDir, apiFile, projectId, deploymentId);
474
+
475
+ // Extract schemas from agents for routes that use validators
476
+ for (const route of routes) {
477
+ // Check if route has custom schema overrides from validator({ input, output })
478
+ const hasCustomInput = route.config?.inputSchemaVariable;
479
+ const hasCustomOutput = route.config?.outputSchemaVariable;
480
+
481
+ // If route uses agent.validator(), get schemas from the agent (unless overridden)
482
+ if (
483
+ route.config?.agentImportPath &&
484
+ (!hasCustomInput || !hasCustomOutput)
485
+ ) {
486
+ const agentImportPath = route.config.agentImportPath as string;
487
+ // Match by import path: @agent/zod-test -> src/agent/zod-test/agent.ts
488
+ // Normalize import path by removing leading '@' -> agent/zod-test
489
+ const importPattern = agentImportPath.replace(/^@/, '');
490
+ // Escape regex special characters for safe pattern matching
491
+ const escapedPattern = importPattern.replace(
492
+ /[.*+?^${}()|[\]\\]/g,
493
+ '\\$&'
494
+ );
495
+ // Match as complete path segment to avoid false positives (e.g., "agent/hello" matching "agent/hello-world")
496
+ const segmentPattern = new RegExp(`(^|/)${escapedPattern}(/|$)`);
497
+
498
+ for (const [, agentMd] of agentMetadata) {
499
+ const agentFilename = agentMd.get('filename');
500
+ if (agentFilename && segmentPattern.test(agentFilename)) {
501
+ // Use agent schemas unless overridden
502
+ const inputSchemaCode = hasCustomInput
503
+ ? undefined
504
+ : agentMd.get('inputSchemaCode');
505
+ const outputSchemaCode = hasCustomOutput
506
+ ? undefined
507
+ : agentMd.get('outputSchemaCode');
508
+
509
+ if (inputSchemaCode || outputSchemaCode) {
510
+ route.schema = {
511
+ input: inputSchemaCode,
512
+ output: outputSchemaCode,
513
+ };
514
+ }
515
+ break;
516
+ }
517
+ }
518
+ }
519
+
520
+ // TODO: Extract inline schema code from custom validator({ input: z.string(), output: ... })
521
+ // For now, custom schema overrides with inline code are not extracted (would require parsing the validator call's object expression)
522
+ }
523
+
450
524
  apiRoutesMetadata.push(...routes);
451
525
 
452
526
  // Collect route info for RouteRegistry generation
@@ -594,6 +668,16 @@ await (async() => {
594
668
  projectId,
595
669
  };
596
670
 
671
+ // Extract schema codes if available
672
+ const inputSchemaCode = v.get('inputSchemaCode');
673
+ const outputSchemaCode = v.get('outputSchemaCode');
674
+ if (inputSchemaCode || outputSchemaCode) {
675
+ agentData.schema = {
676
+ input: inputSchemaCode,
677
+ output: outputSchemaCode,
678
+ };
679
+ }
680
+
597
681
  const evalsStr = v.get('evals');
598
682
  if (evalsStr) {
599
683
  logger.trace(
@@ -15,6 +15,10 @@ const DeploymentShowResponseSchema = z.object({
15
15
  tags: z.array(z.string()).describe('Deployment tags'),
16
16
  customDomains: z.array(z.string()).optional().describe('Custom domains'),
17
17
  cloudRegion: z.string().optional().describe('Cloud region'),
18
+ resourceDb: z.string().nullable().optional().describe('the database name'),
19
+ resourceStorage: z.string().nullable().optional().describe('the storage name'),
20
+ deploymentLogsURL: z.string().nullable().optional().describe('the url to the deployment logs'),
21
+ buildLogsURL: z.string().nullable().optional().describe('the url to the build logs'),
18
22
  metadata: z
19
23
  .object({
20
24
  git: z
@@ -87,27 +91,51 @@ export const showSubcommand = createSubcommand({
87
91
 
88
92
  // Skip TUI output in JSON mode
89
93
  if (!options.json) {
90
- console.log(tui.bold('ID: ') + deployment.id);
91
- console.log(tui.bold('Project: ') + projectId);
92
- console.log(tui.bold('State: ') + (deployment.state || 'unknown'));
93
- console.log(tui.bold('Active: ') + (deployment.active ? 'Yes' : 'No'));
94
- console.log(tui.bold('Created: ') + new Date(deployment.createdAt).toLocaleString());
94
+ const maxWidth = 18;
95
+ console.log(tui.bold('ID:'.padEnd(maxWidth)) + deployment.id);
96
+ console.log(tui.bold('Project:'.padEnd(maxWidth)) + projectId);
97
+ console.log(tui.bold('State:'.padEnd(maxWidth)) + (deployment.state || 'unknown'));
98
+ console.log(tui.bold('Active:'.padEnd(maxWidth)) + (deployment.active ? 'Yes' : 'No'));
99
+ console.log(
100
+ tui.bold('Created:'.padEnd(maxWidth)) +
101
+ new Date(deployment.createdAt).toLocaleString()
102
+ );
95
103
  if (deployment.updatedAt) {
96
104
  console.log(
97
- tui.bold('Updated: ') + new Date(deployment.updatedAt).toLocaleString()
105
+ tui.bold('Updated:'.padEnd(maxWidth)) +
106
+ new Date(deployment.updatedAt).toLocaleString()
98
107
  );
99
108
  }
100
109
  if (deployment.message) {
101
- console.log(tui.bold('Message: ') + deployment.message);
110
+ console.log(tui.bold('Message:'.padEnd(maxWidth)) + deployment.message);
102
111
  }
103
112
  if (deployment.tags.length > 0) {
104
- console.log(tui.bold('Tags: ') + deployment.tags.join(', '));
113
+ console.log(tui.bold('Tags:'.padEnd(maxWidth)) + deployment.tags.join(', '));
105
114
  }
106
115
  if (deployment.customDomains && deployment.customDomains.length > 0) {
107
- console.log(tui.bold('Domains: ') + deployment.customDomains.join(', '));
116
+ console.log(
117
+ tui.bold('Domains:'.padEnd(maxWidth)) + deployment.customDomains.join(', ')
118
+ );
108
119
  }
109
120
  if (deployment.cloudRegion) {
110
- console.log(tui.bold('Region: ') + deployment.cloudRegion);
121
+ console.log(tui.bold('Region:'.padEnd(maxWidth)) + deployment.cloudRegion);
122
+ }
123
+ if (deployment.resourceDb) {
124
+ console.log(tui.bold('Database:'.padEnd(maxWidth)) + deployment.resourceDb);
125
+ }
126
+ if (deployment.resourceStorage) {
127
+ console.log(tui.bold('Storage:'.padEnd(maxWidth)) + deployment.resourceStorage);
128
+ }
129
+ if (deployment.deploymentLogsURL) {
130
+ console.log(
131
+ tui.bold('Deployment Logs:'.padEnd(maxWidth)) +
132
+ tui.link(deployment.deploymentLogsURL)
133
+ );
134
+ }
135
+ if (deployment.buildLogsURL) {
136
+ console.log(
137
+ tui.bold('Build Logs:'.padEnd(maxWidth)) + tui.link(deployment.buildLogsURL)
138
+ );
111
139
  }
112
140
 
113
141
  // Git metadata
@@ -153,6 +181,10 @@ export const showSubcommand = createSubcommand({
153
181
  customDomains: deployment.customDomains ?? undefined,
154
182
  cloudRegion: deployment.cloudRegion ?? undefined,
155
183
  metadata: deployment.metadata ?? undefined,
184
+ resourceDb: deployment.resourceDb ?? undefined,
185
+ resourceStorage: deployment.resourceStorage ?? undefined,
186
+ deploymentLogsURL: deployment.deploymentLogsURL ?? undefined,
187
+ buildLogsURL: deployment.buildLogsURL ?? undefined,
156
188
  };
157
189
  } catch (ex) {
158
190
  tui.fatal(`Failed to show deployment: ${ex}`);
@@ -63,14 +63,12 @@ export const agentsSubcommand = createSubcommand({
63
63
  const queryParams = deploymentId ? `?deploymentId=${deploymentId}` : '';
64
64
 
65
65
  const response = options.json
66
- ? await apiClient.request(
67
- 'GET',
66
+ ? await apiClient.get(
68
67
  `/cli/agent/${projectId}${queryParams}`,
69
68
  AgentsResponseSchema
70
69
  )
71
70
  : await tui.spinner('Fetching agents', async () => {
72
- return apiClient.request(
73
- 'GET',
71
+ return apiClient.get(
74
72
  `/cli/agent/${projectId}${queryParams}`,
75
73
  AgentsResponseSchema
76
74
  );
@@ -253,11 +253,10 @@ class DevmodeSyncService implements IDevmodeSyncService {
253
253
  JSON.stringify(payload, null, 2)
254
254
  );
255
255
 
256
- await this.apiClient.request(
257
- 'POST',
256
+ await this.apiClient.post(
258
257
  '/cli/devmode/agent',
259
- z.object({ success: z.boolean() }),
260
- payload
258
+ payload,
259
+ z.object({ success: z.boolean() })
261
260
  );
262
261
  }
263
262
 
@@ -280,11 +279,10 @@ class DevmodeSyncService implements IDevmodeSyncService {
280
279
  JSON.stringify(payload, null, 2)
281
280
  );
282
281
 
283
- await this.apiClient.request(
284
- 'POST',
282
+ await this.apiClient.post(
285
283
  '/cli/devmode/eval',
286
- z.object({ success: z.boolean() }),
287
- payload
284
+ payload,
285
+ z.object({ success: z.boolean() })
288
286
  );
289
287
  }
290
288
  }
@@ -6,8 +6,10 @@ import { getCommand } from '../../command-prefix';
6
6
 
7
7
  const ProjectShowResponseSchema = z.object({
8
8
  id: z.string().describe('Project ID'),
9
+ name: z.string().describe('Project name'),
10
+ description: z.string().nullable().optional().describe('Project description'),
11
+ tags: z.array(z.string()).nullable().optional().describe('Project tags'),
9
12
  orgId: z.string().describe('Organization ID'),
10
- name: z.string().optional().describe('Project name'),
11
13
  secrets: z.record(z.string(), z.string()).optional().describe('Project secrets (masked)'),
12
14
  env: z.record(z.string(), z.string()).optional().describe('Environment variables'),
13
15
  });
@@ -45,16 +47,16 @@ export const showSubcommand = createSubcommand({
45
47
  tui.fatal('Project not found');
46
48
  }
47
49
 
48
- if (options.json) {
49
- console.log(JSON.stringify(project, null, 2));
50
- } else {
51
- tui.table([project], ['id', 'orgId']);
50
+ if (!options.json) {
51
+ tui.table([project], ['id', 'name', 'description', 'tags', 'orgId']);
52
52
  }
53
53
 
54
54
  return {
55
55
  id: project.id,
56
+ name: project.name,
57
+ description: project.description,
58
+ tags: project.tags,
56
59
  orgId: project.orgId,
57
- name: undefined,
58
60
  secrets: project.secrets,
59
61
  env: project.env,
60
62
  };
@@ -177,7 +177,13 @@ export async function runCreateFlow(options: CreateFlowOptions): Promise<void> {
177
177
  if (initialTemplate) {
178
178
  const found = templates.find((t) => t.id === initialTemplate);
179
179
  if (!found) {
180
- logger.fatal(`Template "${initialTemplate}" not found`, ErrorCode.RESOURCE_NOT_FOUND);
180
+ const availableTemplates = templates
181
+ .map((t) => ` - ${t.id.padEnd(20)} ${t.description}`)
182
+ .join('\n');
183
+ logger.fatal(
184
+ `Template "${initialTemplate}" not found\n\nAvailable templates:\n${availableTemplates}`,
185
+ ErrorCode.RESOURCE_NOT_FOUND
186
+ );
181
187
  return;
182
188
  }
183
189
  selectedTemplate = found;
@@ -232,7 +238,7 @@ export async function runCreateFlow(options: CreateFlowOptions): Promise<void> {
232
238
 
233
239
  const resourceConfig: ResourcesTypes = Resources.parse({});
234
240
 
235
- if (auth && apiClient && catalystClient && orgId && region) {
241
+ if (auth && apiClient && catalystClient && orgId && region && !skipPrompts) {
236
242
  // Fetch resources for selected org and region using Catalyst API
237
243
  const resources = await tui.spinner({
238
244
  message: 'Fetching resources',
@@ -316,12 +322,25 @@ export async function runCreateFlow(options: CreateFlowOptions): Promise<void> {
316
322
 
317
323
  const cloudRegion = region ?? process.env.AGENTUITY_REGION ?? 'usc';
318
324
 
325
+ const pkgJsonPath = resolve(dest, 'package.json');
326
+ let pkgJson: { description?: string; keywords?: string[] } = {};
327
+ if (existsSync(pkgJsonPath)) {
328
+ pkgJson = await Bun.file(pkgJsonPath).json();
329
+ }
330
+
331
+ const keywords = Array.isArray(pkgJson.keywords) ? pkgJson.keywords : [];
332
+ const tags = keywords.filter(
333
+ (tag) => tag.toLowerCase() !== 'agentuity' && !tag.toLowerCase().startsWith('agentuity')
334
+ );
335
+
319
336
  await tui.spinner({
320
337
  message: 'Registering your project',
321
338
  clearOnSuccess: true,
322
339
  callback: async () => {
323
340
  const project = await projectCreate(apiClient, {
324
341
  name: projectName,
342
+ description: pkgJson.description,
343
+ tags: tags.length > 0 ? tags : undefined,
325
344
  orgId,
326
345
  cloudRegion,
327
346
  });
package/src/config.ts CHANGED
@@ -51,6 +51,16 @@ export async function saveProfile(path: string): Promise<void> {
51
51
  }
52
52
 
53
53
  export async function getProfile(): Promise<string> {
54
+ // Check environment variable first
55
+ if (process.env.AGENTUITY_PROFILE) {
56
+ const profileName = process.env.AGENTUITY_PROFILE;
57
+ const envProfilePath = join(getDefaultConfigDir(), `${profileName}.yaml`);
58
+ const envFile = Bun.file(envProfilePath);
59
+ if (await envFile.exists()) {
60
+ return envProfilePath;
61
+ }
62
+ }
63
+
54
64
  const profilePath = getProfilePath();
55
65
  const defaultConfigPath = getDefaultConfigPath();
56
66
 
package/src/tui.ts CHANGED
@@ -927,13 +927,34 @@ export interface LoggerSpinnerOptions<T> {
927
927
  maxLines?: number;
928
928
  }
929
929
 
930
+ /**
931
+ * Spinner options (with countdown timer)
932
+ */
933
+ export interface CountdownSpinnerOptions<T> {
934
+ type: 'countdown';
935
+ message: string;
936
+ timeoutMs: number;
937
+ callback: () => Promise<T>;
938
+ /**
939
+ * If true, clear the spinner output on success (no icon, no message)
940
+ * Defaults to false
941
+ */
942
+ clearOnSuccess?: boolean;
943
+ /**
944
+ * Optional callback to handle Enter key press
945
+ * Can be used to open a URL in the browser
946
+ */
947
+ onEnterPress?: () => void;
948
+ }
949
+
930
950
  /**
931
951
  * Spinner options (discriminated union)
932
952
  */
933
953
  export type SpinnerOptions<T> =
934
954
  | SimpleSpinnerOptions<T>
935
955
  | ProgressSpinnerOptions<T>
936
- | LoggerSpinnerOptions<T>;
956
+ | LoggerSpinnerOptions<T>
957
+ | CountdownSpinnerOptions<T>;
937
958
 
938
959
  /**
939
960
  * Run a callback with an animated spinner (simple overload)
@@ -993,9 +1014,11 @@ export async function spinner<T>(
993
1014
  // In non-TTY mode, just write logs directly to stdout
994
1015
  process.stdout.write(logMessage + '\n');
995
1016
  })
996
- : typeof options.callback === 'function'
1017
+ : options.type === 'countdown'
997
1018
  ? await options.callback()
998
- : await options.callback;
1019
+ : typeof options.callback === 'function'
1020
+ ? await options.callback()
1021
+ : await options.callback;
999
1022
 
1000
1023
  // If clearOnSuccess is true, don't show success message
1001
1024
  // Also skip success message in JSON mode
@@ -1025,6 +1048,7 @@ export async function spinner<T>(
1025
1048
 
1026
1049
  let frameIndex = 0;
1027
1050
  let currentProgress: number | undefined;
1051
+ let remainingTime: number | undefined;
1028
1052
  const logLines: string[] = [];
1029
1053
  const maxLines = options.type === 'logger' ? (options.maxLines ?? 3) : 0;
1030
1054
  const mutedColor = getColor('muted');
@@ -1045,14 +1069,19 @@ export async function spinner<T>(
1045
1069
  const color = colorDef[currentColorScheme];
1046
1070
  const frame = `${color}${bold}${frames[frameIndex % frames.length]}${reset}`;
1047
1071
 
1048
- // Add progress indicator if available
1049
- const progressIndicator =
1050
- currentProgress !== undefined
1051
- ? ` ${cyanColor}${Math.floor(currentProgress)}%${reset}`
1052
- : '';
1072
+ // Add progress indicator or countdown timer if available
1073
+ let indicator = '';
1074
+ if (currentProgress !== undefined) {
1075
+ indicator = ` ${cyanColor}${Math.floor(currentProgress)}%${reset}`;
1076
+ } else if (remainingTime !== undefined) {
1077
+ const minutes = Math.floor(remainingTime / 60);
1078
+ const seconds = Math.floor(remainingTime % 60);
1079
+ const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
1080
+ indicator = ` ${mutedColor}(${timeStr} remaining)${reset}`;
1081
+ }
1053
1082
 
1054
1083
  // Render spinner line
1055
- process.stderr.write(`\r\x1b[K${frame} ${message}${progressIndicator}\n`);
1084
+ process.stderr.write(`\r\x1b[K${frame} ${message}${indicator}\n`);
1056
1085
 
1057
1086
  // Render log lines if in logger mode
1058
1087
  if (options.type === 'logger') {
@@ -1089,16 +1118,88 @@ export async function spinner<T>(
1089
1118
  logLines.push(logMessage);
1090
1119
  };
1091
1120
 
1121
+ // Countdown interval tracking
1122
+ let countdownInterval: NodeJS.Timeout | undefined;
1123
+ let keypressListener: ((chunk: Buffer) => void) | undefined;
1124
+
1125
+ // Helper to clean up all resources
1126
+ const cleanup = () => {
1127
+ if (countdownInterval) {
1128
+ clearInterval(countdownInterval);
1129
+ }
1130
+ if (keypressListener) {
1131
+ process.stdin.off('data', keypressListener);
1132
+ if (process.stdin.isTTY) {
1133
+ process.stdin.setRawMode(false);
1134
+ process.stdin.pause();
1135
+ }
1136
+ }
1137
+ process.off('SIGINT', cleanupAndExit);
1138
+ };
1139
+
1140
+ // Set up SIGINT handler for clean exit
1141
+ const cleanupAndExit = () => {
1142
+ cleanup();
1143
+
1144
+ // Stop animation
1145
+ clearInterval(interval);
1146
+
1147
+ // Move cursor to start of output, clear all lines
1148
+ if (linesRendered > 0) {
1149
+ process.stderr.write(`\x1b[${linesRendered}A`);
1150
+ }
1151
+ process.stderr.write('\x1b[J'); // Clear from cursor to end of screen
1152
+ process.stderr.write('\x1B[?25h'); // Show cursor
1153
+
1154
+ process.exit(130); // Standard exit code for SIGINT
1155
+ };
1156
+
1157
+ process.on('SIGINT', cleanupAndExit);
1158
+
1092
1159
  try {
1160
+ // For countdown, set up timer tracking and optional keyboard listener
1161
+ if (options.type === 'countdown') {
1162
+ const startTime = Date.now();
1163
+ remainingTime = options.timeoutMs / 1000;
1164
+ countdownInterval = setInterval(() => {
1165
+ const elapsed = Date.now() - startTime;
1166
+ remainingTime = Math.max(0, (options.timeoutMs - elapsed) / 1000);
1167
+ }, 100);
1168
+
1169
+ // Set up Enter key listener if callback provided
1170
+ if (options.onEnterPress && process.stdin.isTTY) {
1171
+ process.stdin.setRawMode(true);
1172
+ process.stdin.resume();
1173
+
1174
+ keypressListener = (chunk: Buffer) => {
1175
+ const key = chunk.toString();
1176
+ // Check for Enter key (both \r and \n)
1177
+ if (key === '\r' || key === '\n') {
1178
+ options.onEnterPress!();
1179
+ }
1180
+ // Check for Ctrl+C - let it propagate as SIGINT
1181
+ if (key === '\x03') {
1182
+ process.kill(process.pid, 'SIGINT');
1183
+ }
1184
+ };
1185
+
1186
+ process.stdin.on('data', keypressListener);
1187
+ }
1188
+ }
1189
+
1093
1190
  // Execute callback
1094
1191
  const result =
1095
- options.type === 'progress'
1096
- ? await options.callback(progressCallback)
1097
- : options.type === 'logger'
1098
- ? await options.callback(logCallback)
1099
- : typeof options.callback === 'function'
1100
- ? await options.callback()
1101
- : await options.callback;
1192
+ options.type === 'countdown'
1193
+ ? await options.callback()
1194
+ : options.type === 'progress'
1195
+ ? await options.callback(progressCallback)
1196
+ : options.type === 'logger'
1197
+ ? await options.callback(logCallback)
1198
+ : typeof options.callback === 'function'
1199
+ ? await options.callback()
1200
+ : await options.callback;
1201
+
1202
+ cleanup();
1102
1203
 
1103
1204
  // Stop animation first
1104
1205
  clearInterval(interval);
@@ -1119,6 +1220,8 @@ export async function spinner<T>(
1119
1220
 
1120
1221
  return result;
1121
1222
  } catch (err) {
1223
+ cleanup();
1224
+
1122
1225
  // Stop animation first
1123
1226
  clearInterval(interval);
1124
1227