@exagent/agent 0.3.0 → 0.3.2

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.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Secret scrubbing utility — defense-in-depth protection against LLM responses
3
+ * or log messages accidentally containing API keys, private keys, or tokens.
4
+ *
5
+ * Applied to:
6
+ * - LLM response content before parsing (strategy/loader.ts)
7
+ * - Strategy log output (runtime.ts context.log)
8
+ */
9
+
10
+ const SECRET_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
11
+ // OpenAI API keys
12
+ { pattern: /sk-[a-zA-Z0-9]{20,}/g, label: '[REDACTED:openai-key]' },
13
+ // Anthropic API keys
14
+ { pattern: /sk-ant-[a-zA-Z0-9_-]{20,}/g, label: '[REDACTED:anthropic-key]' },
15
+ // Google API keys
16
+ { pattern: /AIza[a-zA-Z0-9_-]{35}/g, label: '[REDACTED:google-key]' },
17
+ // Private keys (64 hex chars after 0x)
18
+ { pattern: /0x[a-fA-F0-9]{64}/g, label: '[REDACTED:private-key]' },
19
+ // Agent tokens
20
+ { pattern: /exg_[a-fA-F0-9]{64}/g, label: '[REDACTED:agent-token]' },
21
+ // Bootstrap tokens
22
+ { pattern: /exb_[a-fA-F0-9]{64}/g, label: '[REDACTED:bootstrap-token]' },
23
+ // Generic long bearer tokens (base64-ish, 40+ chars)
24
+ { pattern: /Bearer\s+[A-Za-z0-9_-]{40,}/g, label: '[REDACTED:bearer-token]' },
25
+ ];
26
+
27
+ /**
28
+ * Scrub known secret patterns from text. Returns the scrubbed string.
29
+ * Safe to call on any text — if no patterns match, returns the original unchanged.
30
+ */
31
+ export function scrubSecrets(text: string): string {
32
+ let result = text;
33
+ for (const { pattern, label } of SECRET_PATTERNS) {
34
+ // Reset lastIndex for global regexes
35
+ pattern.lastIndex = 0;
36
+ result = result.replace(pattern, label);
37
+ }
38
+ return result;
39
+ }
package/src/setup.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, resolve } from 'node:path';
4
- import { createInterface } from 'node:readline/promises';
5
- import { stdin as input, stdout as output } from 'node:process';
6
4
  import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
5
+ import * as clack from '@clack/prompts';
7
6
  import {
8
7
  encryptSecretPayload,
9
8
  getDefaultSecureStorePath,
@@ -12,6 +11,7 @@ import {
12
11
  type RuntimeConfigFile,
13
12
  writeConfigFile,
14
13
  } from './config.js';
14
+ import { printBanner, printStep, printDone, printInfo, printError, printSuccess, pc } from './ui.js';
15
15
 
16
16
  interface BootstrapPayload {
17
17
  apiToken: string;
@@ -28,106 +28,23 @@ function expandHomeDir(path: string): string {
28
28
  return resolve(homedir(), path.slice(2));
29
29
  }
30
30
 
31
- async function prompt(question: string): Promise<string> {
32
- const rl = createInterface({ input, output });
33
- try {
34
- return (await rl.question(question)).trim();
35
- } finally {
36
- rl.close();
37
- }
38
- }
39
-
40
- async function promptSecret(question: string): Promise<string> {
41
- const rl = createInterface({ input, output, terminal: true }) as ReturnType<typeof createInterface> & {
42
- stdoutMuted?: boolean;
43
- _writeToOutput?: (text: string) => void;
44
- };
45
- rl.stdoutMuted = true;
46
- const originalWrite = rl._writeToOutput?.bind(rl);
47
- rl._writeToOutput = (text: string) => {
48
- if (!rl.stdoutMuted) {
49
- originalWrite?.(text);
50
- }
51
- };
52
-
53
- try {
54
- const answer = (await rl.question(question)).trim();
55
- output.write('\n');
56
- return answer;
57
- } finally {
58
- rl.close();
59
- }
60
- }
61
-
62
- export async function promptSecretPassword(question: string = 'Device password: '): Promise<string> {
63
- return promptSecret(question);
31
+ function cancelled(): never {
32
+ clack.cancel('Setup cancelled.');
33
+ process.exit(0);
64
34
  }
65
35
 
66
- async function promptPasswordWithConfirmation(): Promise<string> {
67
- output.write('\n');
68
- output.write('Important: this password encrypts the local wallet key, relay token, and agent LLM key for this device.\n');
69
- output.write('If you lose this password and the agent wallet holds funds, those funds cannot be recovered.\n\n');
70
-
71
- const ack = await prompt('Type "I UNDERSTAND" to continue: ');
72
- if (ack !== 'I UNDERSTAND') {
73
- throw new Error('Secure setup aborted');
74
- }
75
-
76
- while (true) {
77
- const password = await promptSecret('Create a device password (min 12 chars): ');
78
- if (password.length < 12) {
79
- output.write('Password must be at least 12 characters.\n');
80
- continue;
81
- }
82
-
83
- const confirm = await promptSecret('Confirm device password: ');
84
- if (password !== confirm) {
85
- output.write('Passwords did not match. Try again.\n');
86
- continue;
87
- }
88
-
89
- return password;
90
- }
91
- }
92
-
93
- async function promptWalletPrivateKey(): Promise<string> {
94
- while (true) {
95
- const choice = (await prompt('Wallet setup — [1] generate new wallet locally, [2] use existing private key: ')).trim();
96
- if (choice === '1') {
97
- const privateKey = generatePrivateKey();
98
- const address = privateKeyToAccount(privateKey).address;
99
- output.write(`Generated wallet address: ${address}\n`);
100
- return privateKey;
101
- }
102
-
103
- if (choice === '2') {
104
- const privateKey = await promptSecret('Wallet private key (0x...): ');
105
- if (/^0x[a-fA-F0-9]{64}$/.test(privateKey)) {
106
- return privateKey;
107
- }
108
- output.write('Invalid private key. Expected a 32-byte hex string prefixed with 0x.\n');
109
- continue;
110
- }
111
-
112
- output.write('Enter 1 or 2.\n');
113
- }
114
- }
115
-
116
- async function promptLlmProvider(): Promise<string> {
117
- while (true) {
118
- const provider = (await prompt('Agent LLM provider (openai/anthropic/google/deepseek/mistral/groq/together/ollama): ')).toLowerCase();
119
- if (['openai', 'anthropic', 'google', 'deepseek', 'mistral', 'groq', 'together', 'ollama'].includes(provider)) {
120
- return provider;
121
- }
122
- output.write('Unsupported provider.\n');
123
- }
124
- }
36
+ // ---------------------------------------------------------------------------
37
+ // Step 1: Bootstrap
38
+ // ---------------------------------------------------------------------------
125
39
 
126
40
  async function consumeBootstrapPackage(config: RuntimeConfigFile): Promise<BootstrapPayload> {
127
41
  if (!config.secrets?.bootstrapToken) {
128
42
  return { apiToken: '' };
129
43
  }
130
44
 
45
+ const apiHost = new URL(config.apiUrl).host;
46
+ printInfo(`Connecting to ${pc.cyan(apiHost)}...`);
47
+
131
48
  const res = await fetch(`${config.apiUrl}/v1/agents/bootstrap/consume`, {
132
49
  method: 'POST',
133
50
  headers: { 'Content-Type': 'application/json' },
@@ -139,52 +56,157 @@ async function consumeBootstrapPackage(config: RuntimeConfigFile): Promise<Boots
139
56
 
140
57
  if (!res.ok) {
141
58
  const body = await res.text();
142
- throw new Error(`Failed to consume secure setup package: ${body}`);
59
+ throw new Error(`Failed to consume bootstrap package: ${body}`);
143
60
  }
144
61
 
145
62
  const data = await res.json() as { payload: BootstrapPayload };
146
63
  return data.payload;
147
64
  }
148
65
 
149
- async function buildLocalSecrets(config: RuntimeConfigFile, bootstrapPayload: BootstrapPayload): Promise<{ config: RuntimeConfigFile; secrets: LocalSecretPayload }> {
150
- const nextConfig = structuredClone(config);
151
- const llm = { ...(nextConfig.llm || {}) };
66
+ // ---------------------------------------------------------------------------
67
+ // Step 2: Wallet
68
+ // ---------------------------------------------------------------------------
152
69
 
153
- if (bootstrapPayload.llm?.provider && !llm.provider) {
154
- llm.provider = bootstrapPayload.llm.provider as RuntimeConfigFile['llm']['provider'];
70
+ async function setupWallet(config: RuntimeConfigFile): Promise<string> {
71
+ if (config.wallet?.privateKey) {
72
+ const account = privateKeyToAccount(config.wallet.privateKey as `0x${string}`);
73
+ printDone(`Using existing wallet: ${pc.dim(account.address)}`);
74
+ return config.wallet.privateKey;
155
75
  }
156
- if (bootstrapPayload.llm?.model && !llm.model) {
157
- llm.model = bootstrapPayload.llm.model;
76
+
77
+ const method = await clack.select({
78
+ message: 'How would you like to set up your wallet?',
79
+ options: [
80
+ { value: 'generate', label: 'Generate new wallet locally', hint: 'recommended' },
81
+ { value: 'import', label: 'Import existing private key' },
82
+ ],
83
+ });
84
+ if (clack.isCancel(method)) cancelled();
85
+
86
+ if (method === 'generate') {
87
+ const privateKey = generatePrivateKey();
88
+ const address = privateKeyToAccount(privateKey).address;
89
+ printDone(`Wallet created: ${pc.dim(address)}`);
90
+ return privateKey;
91
+ }
92
+
93
+ // Import flow
94
+ const privateKey = await clack.password({
95
+ message: 'Wallet private key (0x...):',
96
+ validate: (val) => {
97
+ if (!/^0x[a-fA-F0-9]{64}$/.test(val)) {
98
+ return 'Invalid private key. Expected a 32-byte hex string prefixed with 0x.';
99
+ }
100
+ },
101
+ });
102
+ if (clack.isCancel(privateKey)) cancelled();
103
+
104
+ const address = privateKeyToAccount(privateKey as `0x${string}`).address;
105
+ printDone(`Wallet imported: ${pc.dim(address)}`);
106
+ return privateKey;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Step 3: LLM
111
+ // ---------------------------------------------------------------------------
112
+
113
+ const LLM_PROVIDERS = ['openai', 'anthropic', 'google', 'deepseek', 'mistral', 'groq', 'together', 'ollama'] as const;
114
+
115
+ async function setupLlm(
116
+ config: RuntimeConfigFile,
117
+ bootstrapPayload: BootstrapPayload,
118
+ ): Promise<{ provider: string; model: string; apiKey: string }> {
119
+ // Provider
120
+ let provider = config.llm?.provider || bootstrapPayload.llm?.provider;
121
+ if (provider) {
122
+ printInfo(`Provider: ${pc.cyan(provider)} ${pc.dim('(from dashboard)')}`);
123
+ } else {
124
+ const selected = await clack.select({
125
+ message: 'LLM provider:',
126
+ options: LLM_PROVIDERS.map(p => ({ value: p, label: p })),
127
+ });
128
+ if (clack.isCancel(selected)) cancelled();
129
+ provider = selected;
158
130
  }
159
131
 
160
- if (!llm.provider) {
161
- llm.provider = await promptLlmProvider() as RuntimeConfigFile['llm']['provider'];
132
+ // Model
133
+ let model = config.llm?.model || bootstrapPayload.llm?.model;
134
+ if (model) {
135
+ printInfo(`Model: ${pc.cyan(model)} ${pc.dim('(from dashboard)')}`);
136
+ } else {
137
+ const entered = await clack.text({
138
+ message: 'LLM model:',
139
+ placeholder: 'gpt-4o',
140
+ validate: (val) => {
141
+ if (!val.trim()) return 'Model name is required.';
142
+ },
143
+ });
144
+ if (clack.isCancel(entered)) cancelled();
145
+ model = entered;
162
146
  }
163
- if (!llm.model) {
164
- llm.model = await prompt('Agent LLM model: ');
165
- if (!llm.model) {
166
- throw new Error('Agent LLM model is required');
147
+
148
+ // API Key
149
+ let apiKey: string | undefined;
150
+ if (bootstrapPayload.llm?.apiKey) {
151
+ const useBootstrap = await clack.confirm({
152
+ message: 'LLM API key received from dashboard. Use it?',
153
+ initialValue: true,
154
+ });
155
+ if (clack.isCancel(useBootstrap)) cancelled();
156
+ if (useBootstrap) {
157
+ apiKey = bootstrapPayload.llm.apiKey;
167
158
  }
168
159
  }
160
+ if (!apiKey) {
161
+ apiKey = config.llm?.apiKey;
162
+ }
163
+ if (!apiKey) {
164
+ const entered = await clack.password({
165
+ message: 'LLM API key:',
166
+ validate: (val) => {
167
+ if (!val.trim()) return 'API key is required.';
168
+ },
169
+ });
170
+ if (clack.isCancel(entered)) cancelled();
171
+ apiKey = entered;
172
+ }
169
173
 
170
- const secrets: LocalSecretPayload = {
171
- apiToken: bootstrapPayload.apiToken || nextConfig.apiToken || await promptSecret('Agent relay token: '),
172
- walletPrivateKey: bootstrapPayload.walletPrivateKey || nextConfig.wallet?.privateKey || await promptWalletPrivateKey(),
173
- llmApiKey: bootstrapPayload.llm?.apiKey || nextConfig.llm.apiKey || await promptSecret('Agent LLM API key: '),
174
- };
174
+ printDone('LLM configured');
175
+ return { provider, model, apiKey };
176
+ }
175
177
 
176
- if (!secrets.apiToken) {
177
- throw new Error('Agent relay token is required');
178
- }
178
+ // ---------------------------------------------------------------------------
179
+ // Step 4: Encryption
180
+ // ---------------------------------------------------------------------------
179
181
 
180
- nextConfig.llm = llm;
181
- delete nextConfig.apiToken;
182
- delete nextConfig.wallet;
183
- delete nextConfig.llm.apiKey;
182
+ async function setupEncryption(): Promise<string> {
183
+ printInfo(`Secrets encrypted with ${pc.cyan('AES-256-GCM')} (${pc.cyan('scrypt')} KDF)`);
184
+ printInfo('The password never leaves this machine.');
185
+ console.log();
184
186
 
185
- return { config: nextConfig, secrets };
187
+ const password = await clack.password({
188
+ message: 'Choose a device password (12+ characters):',
189
+ validate: (val) => {
190
+ if (val.length < 12) return 'Password must be at least 12 characters.';
191
+ },
192
+ });
193
+ if (clack.isCancel(password)) cancelled();
194
+
195
+ const confirm = await clack.password({
196
+ message: 'Confirm password:',
197
+ validate: (val) => {
198
+ if (val !== password) return 'Passwords do not match.';
199
+ },
200
+ });
201
+ if (clack.isCancel(confirm)) cancelled();
202
+
203
+ return password;
186
204
  }
187
205
 
206
+ // ---------------------------------------------------------------------------
207
+ // Secure store
208
+ // ---------------------------------------------------------------------------
209
+
188
210
  function writeSecureStore(path: string, secrets: LocalSecretPayload, password: string): string {
189
211
  const secureStorePath = expandHomeDir(path);
190
212
  const encrypted = encryptSecretPayload(secrets, password);
@@ -196,11 +218,25 @@ function writeSecureStore(path: string, secrets: LocalSecretPayload, password: s
196
218
  try {
197
219
  chmodSync(secureStorePath, 0o600);
198
220
  } catch {
199
- // Best effort only — some platforms ignore chmod semantics.
221
+ // Best effort — some platforms ignore chmod semantics.
200
222
  }
201
223
  return secureStorePath;
202
224
  }
203
225
 
226
+ // ---------------------------------------------------------------------------
227
+ // Public: password prompt (used by run/status commands)
228
+ // ---------------------------------------------------------------------------
229
+
230
+ export async function promptSecretPassword(question: string = 'Device password:'): Promise<string> {
231
+ const password = await clack.password({ message: question });
232
+ if (clack.isCancel(password)) cancelled();
233
+ return password;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Main setup orchestrator
238
+ // ---------------------------------------------------------------------------
239
+
204
240
  export async function ensureLocalSetup(configPath: string): Promise<void> {
205
241
  const config = readConfigFile(configPath);
206
242
  const existingSecureStorePath = config.secrets?.secureStorePath ? expandHomeDir(config.secrets.secureStorePath) : null;
@@ -215,19 +251,82 @@ export async function ensureLocalSetup(configPath: string): Promise<void> {
215
251
  return;
216
252
  }
217
253
 
254
+ printBanner();
255
+
256
+ clack.intro(pc.bold('Agent Setup'));
257
+
258
+ // Step 1: Bootstrap
259
+ printStep(1, 4, 'Bootstrap package');
218
260
  const bootstrapPayload = await consumeBootstrapPackage(config);
219
- const { config: nextConfig, secrets } = await buildLocalSecrets(config, bootstrapPayload);
220
- const password = await promptPasswordWithConfirmation();
261
+ if (config.secrets?.bootstrapToken) {
262
+ printDone('Bootstrap package consumed');
263
+ if (bootstrapPayload.llm?.provider) {
264
+ printInfo(`LLM config received: ${pc.cyan(bootstrapPayload.llm.provider)}${bootstrapPayload.llm.model ? ` / ${pc.cyan(bootstrapPayload.llm.model)}` : ''}`);
265
+ }
266
+ } else {
267
+ printInfo('No bootstrap token — manual configuration');
268
+ }
269
+
270
+ // Step 2: Wallet
271
+ printStep(2, 4, 'Wallet setup');
272
+ const walletPrivateKey = await setupWallet(config);
273
+
274
+ // Step 3: LLM
275
+ printStep(3, 4, 'LLM configuration');
276
+ const llm = await setupLlm(config, bootstrapPayload);
277
+
278
+ // Step 4: Encryption
279
+ printStep(4, 4, 'Device encryption');
280
+ const password = await setupEncryption();
281
+
282
+ // Build secrets and write
283
+ const secrets: LocalSecretPayload = {
284
+ apiToken: bootstrapPayload.apiToken || config.apiToken || '',
285
+ walletPrivateKey,
286
+ llmApiKey: llm.apiKey,
287
+ };
288
+
289
+ if (!secrets.apiToken) {
290
+ // Prompt for relay token if not available from bootstrap or config
291
+ const token = await clack.password({
292
+ message: 'Agent relay token:',
293
+ validate: (val) => {
294
+ if (!val.trim()) return 'Relay token is required.';
295
+ },
296
+ });
297
+ if (clack.isCancel(token)) cancelled();
298
+ secrets.apiToken = token;
299
+ }
300
+
301
+ const nextConfig = structuredClone(config);
302
+ nextConfig.llm = {
303
+ ...nextConfig.llm,
304
+ provider: llm.provider as RuntimeConfigFile['llm']['provider'],
305
+ model: llm.model,
306
+ };
307
+
308
+ // Strip plaintext secrets from config file
309
+ delete nextConfig.apiToken;
310
+ delete nextConfig.wallet;
311
+ delete nextConfig.llm.apiKey;
312
+
221
313
  const secureStorePath = writeSecureStore(
222
314
  nextConfig.secrets?.secureStorePath || getDefaultSecureStorePath(nextConfig.agentId),
223
315
  secrets,
224
316
  password,
225
317
  );
226
318
 
227
- nextConfig.secrets = {
228
- secureStorePath,
229
- };
230
-
319
+ nextConfig.secrets = { secureStorePath };
231
320
  writeConfigFile(configPath, nextConfig);
232
- output.write(`Encrypted local secret store created at ${secureStorePath}\n`);
321
+
322
+ printDone(`Encrypted store: ${pc.dim(secureStorePath)}`);
323
+
324
+ clack.outro(pc.green('Setup complete'));
325
+
326
+ printSuccess('Ready', [
327
+ `${pc.cyan('npx exagent run')} Start trading`,
328
+ `${pc.cyan('npx exagent status')} Check connection`,
329
+ '',
330
+ `${pc.dim('Dashboard:')} ${pc.cyan('https://exagent.io')}`,
331
+ ]);
233
332
  }
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import type { StrategyFunction, StrategyContext, TradeSignal } from '@exagent/sdk';
5
5
  import { getTemplate } from './templates.js';
6
+ import { scrubSecrets } from '../scrub-secrets.js';
6
7
 
7
8
  const promptSignalSchema = z.object({
8
9
  symbol: z.string().min(1),
@@ -24,6 +25,7 @@ const promptSignalArraySchema = z.array(promptSignalSchema);
24
25
 
25
26
  export async function loadStrategy(config: {
26
27
  file?: string;
28
+ code?: string;
27
29
  template?: string;
28
30
  prompt?: {
29
31
  name?: string;
@@ -35,6 +37,10 @@ export async function loadStrategy(config: {
35
37
  return loadFromFile(config.file);
36
38
  }
37
39
 
40
+ if (config.code) {
41
+ return loadFromCode(config.code);
42
+ }
43
+
38
44
  if (config.prompt) {
39
45
  return loadFromPrompt(config.prompt);
40
46
  }
@@ -62,7 +68,31 @@ async function loadFromFile(filePath: string): Promise<StrategyFunction> {
62
68
  const fn = mod.default || mod.strategy;
63
69
 
64
70
  if (typeof fn !== 'function') {
65
- throw new Error(`Strategy file must export a default function or 'strategy' function`);
71
+ if (typeof mod.code === 'string' && mod.code.trim()) {
72
+ return loadFromCode(mod.code);
73
+ }
74
+
75
+ if (typeof mod.systemPrompt === 'string' && mod.systemPrompt.trim()) {
76
+ const venues = Array.isArray(mod.venues)
77
+ ? mod.venues.filter((venue: unknown): venue is string => typeof venue === 'string')
78
+ : undefined;
79
+ const name = typeof mod.name === 'string' ? mod.name : undefined;
80
+ return loadFromPrompt({
81
+ name,
82
+ systemPrompt: mod.systemPrompt,
83
+ venues,
84
+ });
85
+ }
86
+
87
+ if (typeof mod.template === 'string' && mod.template.trim()) {
88
+ const template = getTemplate(mod.template);
89
+ if (!template) {
90
+ throw new Error(`Unknown strategy template: ${mod.template}. Available: momentum, value, arbitrage, hold`);
91
+ }
92
+ return loadFromCode(template.code);
93
+ }
94
+
95
+ throw new Error(`Strategy file must export a default function, 'strategy' function, 'code' string, 'systemPrompt' string, or 'template' string`);
66
96
  }
67
97
 
68
98
  return fn as StrategyFunction;
@@ -109,7 +139,10 @@ function loadFromPrompt(config: {
109
139
  },
110
140
  ]);
111
141
 
112
- const match = response.content.match(/\[[\s\S]*\]/);
142
+ // Defense-in-depth: scrub any secrets the LLM might echo back before parsing
143
+ const scrubbedContent = scrubSecrets(response.content);
144
+
145
+ const match = scrubbedContent.match(/\[[\s\S]*\]/);
113
146
  if (!match) {
114
147
  context.log('Prompt strategy returned a non-JSON response; no signals emitted.');
115
148
  return [];
package/src/ui.ts ADDED
@@ -0,0 +1,75 @@
1
+ import figlet from 'figlet';
2
+ import gradient from 'gradient-string';
3
+ import boxen from 'boxen';
4
+ import pc from 'picocolors';
5
+ import { createRequire } from 'node:module';
6
+
7
+ // Brand gradient: Blue → Indigo → Violet (from Exagent design system)
8
+ const brandGradient: (text: string) => string = gradient(['#3B82F6', '#6366F1', '#7C3AED']);
9
+
10
+ // Secondary gradient: Cyan → Blue
11
+ const accentGradient: (text: string) => string = gradient(['#22D3EE', '#3B82F6']);
12
+
13
+ function getVersion(): string {
14
+ try {
15
+ const require = createRequire(import.meta.url);
16
+ const pkg = require('../package.json');
17
+ return pkg.version || '0.0.0';
18
+ } catch {
19
+ return '0.0.0';
20
+ }
21
+ }
22
+
23
+ export function printBanner(): void {
24
+ const art = figlet.textSync('EXAGENT', {
25
+ font: 'Small',
26
+ horizontalLayout: 'default',
27
+ });
28
+
29
+ console.log();
30
+ console.log(brandGradient(art));
31
+ console.log(pc.dim(` v${getVersion()}`));
32
+ console.log();
33
+ }
34
+
35
+ export function printSuccess(title: string, lines: string[]): void {
36
+ const body = [
37
+ '',
38
+ pc.bold(pc.white(title)),
39
+ '',
40
+ ...lines.map(l => ` ${l}`),
41
+ '',
42
+ ].join('\n');
43
+
44
+ console.log();
45
+ console.log(boxen(body, {
46
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
47
+ borderColor: '#3B82F6',
48
+ borderStyle: 'round',
49
+ dimBorder: false,
50
+ }));
51
+ console.log();
52
+ }
53
+
54
+ export function printStep(step: number, total: number, label: string): void {
55
+ console.log();
56
+ console.log(accentGradient(` Step ${step} of ${total}`) + pc.dim(` — ${label}`));
57
+ }
58
+
59
+ export function printDone(message: string): void {
60
+ console.log(` ${pc.green('✓')} ${message}`);
61
+ }
62
+
63
+ export function printInfo(message: string): void {
64
+ console.log(` ${pc.dim('│')} ${message}`);
65
+ }
66
+
67
+ export function printWarn(message: string): void {
68
+ console.log(` ${pc.yellow('!')} ${message}`);
69
+ }
70
+
71
+ export function printError(message: string): void {
72
+ console.log(` ${pc.red('✗')} ${message}`);
73
+ }
74
+
75
+ export { pc, brandGradient, accentGradient };