@exagent/agent 0.2.1 → 0.3.1

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/src/setup.ts ADDED
@@ -0,0 +1,332 @@
1
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
5
+ import * as clack from '@clack/prompts';
6
+ import {
7
+ encryptSecretPayload,
8
+ getDefaultSecureStorePath,
9
+ readConfigFile,
10
+ type LocalSecretPayload,
11
+ type RuntimeConfigFile,
12
+ writeConfigFile,
13
+ } from './config.js';
14
+ import { printBanner, printStep, printDone, printInfo, printError, printSuccess, pc } from './ui.js';
15
+
16
+ interface BootstrapPayload {
17
+ apiToken: string;
18
+ walletPrivateKey?: string;
19
+ llm?: {
20
+ provider?: string;
21
+ model?: string;
22
+ apiKey?: string;
23
+ };
24
+ }
25
+
26
+ function expandHomeDir(path: string): string {
27
+ if (!path.startsWith('~/')) return path;
28
+ return resolve(homedir(), path.slice(2));
29
+ }
30
+
31
+ function cancelled(): never {
32
+ clack.cancel('Setup cancelled.');
33
+ process.exit(0);
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Step 1: Bootstrap
38
+ // ---------------------------------------------------------------------------
39
+
40
+ async function consumeBootstrapPackage(config: RuntimeConfigFile): Promise<BootstrapPayload> {
41
+ if (!config.secrets?.bootstrapToken) {
42
+ return { apiToken: '' };
43
+ }
44
+
45
+ const apiHost = new URL(config.apiUrl).host;
46
+ printInfo(`Connecting to ${pc.cyan(apiHost)}...`);
47
+
48
+ const res = await fetch(`${config.apiUrl}/v1/agents/bootstrap/consume`, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({
52
+ agentId: config.agentId,
53
+ token: config.secrets.bootstrapToken,
54
+ }),
55
+ });
56
+
57
+ if (!res.ok) {
58
+ const body = await res.text();
59
+ throw new Error(`Failed to consume bootstrap package: ${body}`);
60
+ }
61
+
62
+ const data = await res.json() as { payload: BootstrapPayload };
63
+ return data.payload;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Step 2: Wallet
68
+ // ---------------------------------------------------------------------------
69
+
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;
75
+ }
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;
130
+ }
131
+
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;
146
+ }
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;
158
+ }
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
+ }
173
+
174
+ printDone('LLM configured');
175
+ return { provider, model, apiKey };
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Step 4: Encryption
180
+ // ---------------------------------------------------------------------------
181
+
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();
186
+
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;
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Secure store
208
+ // ---------------------------------------------------------------------------
209
+
210
+ function writeSecureStore(path: string, secrets: LocalSecretPayload, password: string): string {
211
+ const secureStorePath = expandHomeDir(path);
212
+ const encrypted = encryptSecretPayload(secrets, password);
213
+ const dir = dirname(secureStorePath);
214
+ if (!existsSync(dir)) {
215
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
216
+ }
217
+ writeFileSync(secureStorePath, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
218
+ try {
219
+ chmodSync(secureStorePath, 0o600);
220
+ } catch {
221
+ // Best effort — some platforms ignore chmod semantics.
222
+ }
223
+ return secureStorePath;
224
+ }
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
+
240
+ export async function ensureLocalSetup(configPath: string): Promise<void> {
241
+ const config = readConfigFile(configPath);
242
+ const existingSecureStorePath = config.secrets?.secureStorePath ? expandHomeDir(config.secrets.secureStorePath) : null;
243
+ if (
244
+ existingSecureStorePath &&
245
+ !config.secrets?.bootstrapToken &&
246
+ existsSync(existingSecureStorePath) &&
247
+ !config.apiToken &&
248
+ !config.wallet?.privateKey &&
249
+ !config.llm.apiKey
250
+ ) {
251
+ return;
252
+ }
253
+
254
+ printBanner();
255
+
256
+ clack.intro(pc.bold('Agent Setup'));
257
+
258
+ // Step 1: Bootstrap
259
+ printStep(1, 4, 'Bootstrap package');
260
+ const bootstrapPayload = await consumeBootstrapPackage(config);
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
+
313
+ const secureStorePath = writeSecureStore(
314
+ nextConfig.secrets?.secureStorePath || getDefaultSecureStorePath(nextConfig.agentId),
315
+ secrets,
316
+ password,
317
+ );
318
+
319
+ nextConfig.secrets = { secureStorePath };
320
+ writeConfigFile(configPath, nextConfig);
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
+ ]);
332
+ }
@@ -1,16 +1,50 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
+ import { z } from 'zod';
3
4
  import type { StrategyFunction, StrategyContext, TradeSignal } from '@exagent/sdk';
4
5
  import { getTemplate } from './templates.js';
6
+ import { scrubSecrets } from '../scrub-secrets.js';
7
+
8
+ const promptSignalSchema = z.object({
9
+ symbol: z.string().min(1),
10
+ side: z.enum(['buy', 'sell', 'long', 'short']),
11
+ confidence: z.number().min(0).max(1).optional(),
12
+ reasoning: z.string().optional(),
13
+ venue: z.string().optional(),
14
+ chain: z.string().optional(),
15
+ size: z.number().positive().optional(),
16
+ price: z.number().positive().optional(),
17
+ fee: z.number().min(0).optional(),
18
+ venueFillId: z.string().optional(),
19
+ venueTimestamp: z.string().optional(),
20
+ leverage: z.number().positive().optional(),
21
+ orderType: z.string().optional(),
22
+ });
23
+
24
+ const promptSignalArraySchema = z.array(promptSignalSchema);
5
25
 
6
26
  export async function loadStrategy(config: {
7
27
  file?: string;
28
+ code?: string;
8
29
  template?: string;
30
+ prompt?: {
31
+ name?: string;
32
+ systemPrompt: string;
33
+ venues?: string[];
34
+ };
9
35
  }): Promise<StrategyFunction> {
10
36
  if (config.file) {
11
37
  return loadFromFile(config.file);
12
38
  }
13
39
 
40
+ if (config.code) {
41
+ return loadFromCode(config.code);
42
+ }
43
+
44
+ if (config.prompt) {
45
+ return loadFromPrompt(config.prompt);
46
+ }
47
+
14
48
  if (config.template) {
15
49
  const template = getTemplate(config.template);
16
50
  if (!template) {
@@ -34,7 +68,31 @@ async function loadFromFile(filePath: string): Promise<StrategyFunction> {
34
68
  const fn = mod.default || mod.strategy;
35
69
 
36
70
  if (typeof fn !== 'function') {
37
- 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`);
38
96
  }
39
97
 
40
98
  return fn as StrategyFunction;
@@ -51,6 +109,79 @@ async function loadFromCode(code: string): Promise<StrategyFunction> {
51
109
  return fn;
52
110
  }
53
111
 
112
+ function loadFromPrompt(config: {
113
+ name?: string;
114
+ systemPrompt: string;
115
+ venues?: string[];
116
+ }): StrategyFunction {
117
+ return async (context: StrategyContext): Promise<TradeSignal[]> => {
118
+ const prices = context.market.getPrices();
119
+ const positions = context.position.openPositions.map((position) => ({
120
+ token: position.token,
121
+ quantity: position.quantity,
122
+ costBasisPerUnit: position.costBasisPerUnit,
123
+ venue: position.venue,
124
+ chain: position.chain,
125
+ }));
126
+
127
+ const response = await context.llm.chat([
128
+ { role: 'system', content: config.systemPrompt },
129
+ {
130
+ role: 'user',
131
+ content: [
132
+ `Strategy: ${config.name || 'Prompt Strategy'}`,
133
+ `Allowed venues: ${(config.venues || []).join(', ') || 'any'}`,
134
+ `Current prices: ${JSON.stringify(prices)}`,
135
+ `Open positions: ${JSON.stringify(positions)}`,
136
+ `Risk config: ${JSON.stringify(context.config)}`,
137
+ 'Return ONLY a JSON array of trade signals.',
138
+ ].join('\n'),
139
+ },
140
+ ]);
141
+
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]*\]/);
146
+ if (!match) {
147
+ context.log('Prompt strategy returned a non-JSON response; no signals emitted.');
148
+ return [];
149
+ }
150
+
151
+ try {
152
+ const parsed = promptSignalArraySchema.parse(JSON.parse(match[0]));
153
+ const signals: TradeSignal[] = [];
154
+ for (const signal of parsed) {
155
+ const price = signal.price ?? prices[signal.symbol.toUpperCase()];
156
+ if (!price || price <= 0) {
157
+ context.log(`Prompt strategy skipped ${signal.symbol}: no usable price in response or market cache.`);
158
+ continue;
159
+ }
160
+
161
+ signals.push({
162
+ symbol: signal.symbol,
163
+ side: signal.side,
164
+ confidence: signal.confidence ?? 0.5,
165
+ reasoning: signal.reasoning,
166
+ venue: signal.venue || config.venues?.[0] || 'manual',
167
+ chain: signal.chain,
168
+ size: signal.size ?? 1,
169
+ price,
170
+ fee: signal.fee ?? 0,
171
+ venueFillId: signal.venueFillId ?? '',
172
+ venueTimestamp: signal.venueTimestamp ?? new Date().toISOString(),
173
+ leverage: signal.leverage,
174
+ orderType: signal.orderType,
175
+ });
176
+ }
177
+ return signals;
178
+ } catch (err) {
179
+ context.log(`Prompt strategy parse failed: ${(err as Error).message}`);
180
+ return [];
181
+ }
182
+ };
183
+ }
184
+
54
185
  const holdStrategy: StrategyFunction = async (_context: StrategyContext): Promise<TradeSignal[]> => {
55
186
  return []; // No trades — hold position
56
187
  };
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 };