morpho-vault-manager 0.1.0

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,900 @@
1
+ import path from "node:path";
2
+ import * as p from "@clack/prompts";
3
+ import { getAddress, isAddress } from "viem";
4
+ import { BASE_USDC_ADDRESS, RISK_PRESETS } from "../lib/constants.js";
5
+ import { ensureDir, readTextFile, writeTextFile } from "../lib/fs.js";
6
+ import { getMorphoTokenBalance } from "../lib/morpho.js";
7
+ import { describeTokenSource, resolveApiToken } from "../lib/secrets.js";
8
+ import { disableCronJob, enableCronJob, ensureAgent, installSkill, listConfiguredTelegramAccounts, listCronJobs, listTelegramGroups, runCronJobNow, setEnvVar, upsertCronJob } from "../lib/openclaw.js";
9
+ import { runPreflightChecks } from "../lib/preflight.js";
10
+ import { commandExists } from "../lib/shell.js";
11
+ import { runPlan } from "../lib/rebalance.js";
12
+ import { buildApiKeyCreateCommand, buildWalletCreateCommand } from "../lib/ows.js";
13
+ import { loadProfile, saveProfile } from "../lib/profile.js";
14
+ import { renderAgentInstructions } from "../lib/template.js";
15
+ export const CRON_SCHEDULE_PRESETS = {
16
+ hourly: {
17
+ id: "hourly",
18
+ label: "Hourly",
19
+ cronExpression: "0 * * * *",
20
+ description: "Run once per hour at minute 0."
21
+ },
22
+ every6Hours: {
23
+ id: "every6Hours",
24
+ label: "Every 6 hours",
25
+ cronExpression: "0 */6 * * *",
26
+ description: "Run four times per day on a 6 hour cadence."
27
+ },
28
+ daily: {
29
+ id: "daily",
30
+ label: "Daily",
31
+ cronExpression: "0 0 * * *",
32
+ description: "Run once per day at midnight."
33
+ },
34
+ weekdays: {
35
+ id: "weekdays",
36
+ label: "Weekdays",
37
+ cronExpression: "0 0 * * 1-5",
38
+ description: "Run Monday through Friday at midnight."
39
+ }
40
+ };
41
+ export function formatRiskPresetConfig(riskPreset) {
42
+ return JSON.stringify(riskPreset, null, 2);
43
+ }
44
+ export function describeCronSchedule(cronExpression) {
45
+ const preset = Object.values(CRON_SCHEDULE_PRESETS).find((candidate) => candidate.cronExpression === cronExpression);
46
+ return preset ? `${preset.label} (${preset.cronExpression})` : `Custom (${cronExpression})`;
47
+ }
48
+ function cronScheduleSelectionForExpression(expression) {
49
+ const preset = Object.values(CRON_SCHEDULE_PRESETS).find((candidate) => candidate.cronExpression === expression);
50
+ return preset ? preset.id : "custom";
51
+ }
52
+ function fail(message) {
53
+ p.cancel(message);
54
+ throw new Error(message);
55
+ }
56
+ function requiredString(value, label) {
57
+ if (value === undefined || value === null || typeof value === "symbol" || value.trim().length === 0) {
58
+ fail(`${label} is required.`);
59
+ }
60
+ return value.trim();
61
+ }
62
+ function optionalString(value) {
63
+ if (value === undefined || value === null || typeof value === "symbol") {
64
+ return "";
65
+ }
66
+ return value.trim();
67
+ }
68
+ function requiredBoolean(value, label) {
69
+ if (typeof value === "symbol" || value === undefined) {
70
+ fail(`${label} was cancelled.`);
71
+ }
72
+ return value;
73
+ }
74
+ function tokenEnvVarForProfile(settings, profileId) {
75
+ if (profileId === "default") {
76
+ return settings.defaultTokenEnvVar;
77
+ }
78
+ const suffix = profileId.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
79
+ return `${settings.defaultTokenEnvVar}_${suffix}`;
80
+ }
81
+ export function agentIdForProfile(settings, profileId) {
82
+ return profileId === "default" ? settings.baseAgentId : `${settings.baseAgentId}-${profileId}`;
83
+ }
84
+ export function workspaceDirForAgent(settings, agentId) {
85
+ return path.join(settings.workspaceRoot, `workspace-${agentId}`);
86
+ }
87
+ function describeDeliveryTarget(target) {
88
+ if (target.notifications !== "announce") {
89
+ return "Internal only (no delivery)";
90
+ }
91
+ if ((target.deliveryChannel ?? "last") === "last") {
92
+ return "Announce to OpenClaw last route";
93
+ }
94
+ const pieces = [target.deliveryChannel];
95
+ if (target.deliveryTo) {
96
+ pieces.push(target.deliveryTo);
97
+ }
98
+ if (target.deliveryAccountId) {
99
+ pieces.push(`account ${target.deliveryAccountId}`);
100
+ }
101
+ return pieces.join(" / ");
102
+ }
103
+ async function promptTelegramDeliveryTarget(settings, existing) {
104
+ const accounts = await listConfiguredTelegramAccounts(settings);
105
+ if (accounts.length === 0) {
106
+ await p.note([
107
+ "No configured Telegram accounts were found in OpenClaw.",
108
+ "Falling back to the OpenClaw last route instead of asking for a raw chat id."
109
+ ].join("\n"), "Telegram Delivery");
110
+ return { deliveryChannel: "last" };
111
+ }
112
+ const defaultAccountId = existing?.deliveryAccountId ?? settings.defaultDeliveryAccountId;
113
+ let accountId = accounts.length === 1 ? accounts[0] : accounts.includes(defaultAccountId ?? "") ? defaultAccountId : undefined;
114
+ if (accounts.length > 1) {
115
+ accountId = requiredString(await p.select({
116
+ message: "Telegram account",
117
+ initialValue: accountId,
118
+ options: accounts.map((candidate) => ({
119
+ value: candidate,
120
+ label: candidate,
121
+ hint: "Configured Telegram account"
122
+ }))
123
+ }), "telegram account");
124
+ }
125
+ const discoveredTargets = await listTelegramGroups(settings, accountId);
126
+ if (discoveredTargets.length === 0) {
127
+ await p.note([
128
+ "No Telegram groups/topics were discovered from the OpenClaw directory.",
129
+ "Falling back to the OpenClaw last route instead of asking for a raw chat id."
130
+ ].join("\n"), "Telegram Delivery");
131
+ return { deliveryChannel: "last" };
132
+ }
133
+ const deliveryTo = requiredString(await p.select({
134
+ message: "Telegram delivery target",
135
+ initialValue: discoveredTargets.some((target) => target.id === existing?.deliveryTo)
136
+ ? existing?.deliveryTo
137
+ : undefined,
138
+ options: discoveredTargets.map((target) => ({
139
+ value: target.id,
140
+ label: target.label,
141
+ hint: target.id
142
+ }))
143
+ }), "telegram delivery target");
144
+ return {
145
+ deliveryChannel: "telegram",
146
+ deliveryTo,
147
+ deliveryAccountId: accountId
148
+ };
149
+ }
150
+ async function promptManualDeliveryTarget(existing) {
151
+ const deliveryChannel = requiredString(await p.text({
152
+ message: "Delivery channel",
153
+ placeholder: "telegram",
154
+ defaultValue: existing?.deliveryChannel && existing.deliveryChannel !== "last"
155
+ ? existing.deliveryChannel
156
+ : undefined
157
+ }), "delivery channel");
158
+ if (deliveryChannel === "last") {
159
+ return { deliveryChannel: "last" };
160
+ }
161
+ const deliveryTo = requiredString(await p.text({
162
+ message: "Delivery target",
163
+ placeholder: "-1001234567890:topic:42",
164
+ defaultValue: existing?.deliveryTo
165
+ }), "delivery target");
166
+ const deliveryAccountId = optionalString(await p.text({
167
+ message: "Delivery account id (optional)",
168
+ placeholder: "default",
169
+ defaultValue: existing?.deliveryAccountId
170
+ }));
171
+ return {
172
+ deliveryChannel,
173
+ deliveryTo,
174
+ deliveryAccountId: deliveryAccountId || undefined
175
+ };
176
+ }
177
+ async function promptCronDelivery(settings, existing) {
178
+ const notifications = requiredString(await p.select({
179
+ message: "Cron delivery mode",
180
+ initialValue: existing?.notifications ?? settings.defaultDeliveryMode,
181
+ options: [
182
+ { value: "announce", label: "Announce run summaries", hint: "Post run summaries back through OpenClaw." },
183
+ { value: "none", label: "No delivery", hint: "Keep cron runs internal only." }
184
+ ]
185
+ }), "cron delivery mode");
186
+ if (notifications === "none") {
187
+ return { notifications };
188
+ }
189
+ const existingSelection = existing?.deliveryChannel === "telegram"
190
+ ? "telegram"
191
+ : existing?.deliveryChannel && existing.deliveryChannel !== "last"
192
+ ? "manual"
193
+ : "last";
194
+ const defaultSelection = settings.defaultDeliveryChannel && settings.defaultDeliveryChannel !== "last" ? "manual" : "last";
195
+ const selection = requiredString(await p.select({
196
+ message: "Cron delivery target",
197
+ initialValue: existing ? existingSelection : defaultSelection,
198
+ options: [
199
+ {
200
+ value: "last",
201
+ label: "Use OpenClaw last route",
202
+ hint: "Seamless default. Reuse the last chat destination OpenClaw delivered to."
203
+ },
204
+ {
205
+ value: "telegram",
206
+ label: "Select Telegram target",
207
+ hint: "Discover Telegram groups/topics from OpenClaw and store one on this profile."
208
+ },
209
+ {
210
+ value: "manual",
211
+ label: "Manual target",
212
+ hint: "Enter a delivery channel and destination yourself."
213
+ }
214
+ ]
215
+ }), "cron delivery target");
216
+ if (selection === "last") {
217
+ return {
218
+ notifications,
219
+ deliveryChannel: "last"
220
+ };
221
+ }
222
+ if (selection === "telegram") {
223
+ return {
224
+ notifications,
225
+ ...(await promptTelegramDeliveryTarget(settings, existing))
226
+ };
227
+ }
228
+ return {
229
+ notifications,
230
+ ...(await promptManualDeliveryTarget(existing))
231
+ };
232
+ }
233
+ async function preflight(settings) {
234
+ const result = await runPreflightChecks(settings);
235
+ const owsIssue = result.issues.find((issue) => issue.code === "missing_ows");
236
+ const gatewayIssue = result.issues.find((issue) => issue.code === "openclaw_gateway_unreachable");
237
+ const hardIssues = result.issues.filter((issue) => issue.code !== "openclaw_gateway_unreachable" && issue.code !== "missing_ows");
238
+ if (hardIssues.length > 0) {
239
+ fail(hardIssues.map((issue) => issue.message).join("\n"));
240
+ }
241
+ if (owsIssue) {
242
+ await p.note([
243
+ owsIssue.message,
244
+ "",
245
+ "OWS (Open Wallet SDK) is required for wallet creation, transaction signing, and policy management.",
246
+ "",
247
+ "Install with:",
248
+ " curl -fsSL https://docs.openwallet.sh/install.sh | bash",
249
+ "",
250
+ "After installing, verify with:",
251
+ ` ${settings.owsCommand} --version`,
252
+ "",
253
+ "Full docs: https://docs.openwallet.sh/"
254
+ ].join("\n"), "OWS Not Found");
255
+ while (true) {
256
+ const retry = requiredBoolean(await p.confirm({
257
+ message: "Have you installed OWS? Retry the check?",
258
+ initialValue: false
259
+ }), "OWS install confirmation");
260
+ if (!retry) {
261
+ fail("Install OWS before running configure. See https://docs.openwallet.sh/");
262
+ }
263
+ if (await commandExists(settings.owsCommand)) {
264
+ break;
265
+ }
266
+ await p.note(`${settings.owsCommand} is still not found in PATH.`, "OWS Still Missing");
267
+ }
268
+ }
269
+ if (gatewayIssue) {
270
+ await p.note([
271
+ gatewayIssue.message,
272
+ "",
273
+ "Remediation:",
274
+ ...(gatewayIssue.remediation ?? [
275
+ "Start or daemonize the OpenClaw gateway before enabling cron.",
276
+ "Verify the daemon with: openclaw gateway status",
277
+ "Rerun configure after the gateway stays reachable."
278
+ ]).map((line) => `- ${line}`),
279
+ "",
280
+ "Configure can continue for onboarding and profile creation, but cron setup will fail until the gateway is reachable."
281
+ ].join("\n"), "Gateway Warning");
282
+ const continueAnyway = requiredBoolean(await p.confirm({
283
+ message: "Continue configure without a reachable OpenClaw gateway?",
284
+ initialValue: false
285
+ }), "gateway continue confirmation");
286
+ if (!continueAnyway) {
287
+ fail("Start the OpenClaw gateway daemon, then rerun configure.");
288
+ }
289
+ }
290
+ }
291
+ async function promptWallet(settings, existing) {
292
+ const walletMode = await p.select({
293
+ message: "Wallet setup",
294
+ initialValue: existing?.walletMode ?? "created",
295
+ options: [
296
+ {
297
+ value: "created",
298
+ label: "Create a fresh OWS wallet",
299
+ hint: "Guided, but the sensitive OWS command still runs under your control."
300
+ },
301
+ {
302
+ value: "existing",
303
+ label: "Use an existing OWS wallet",
304
+ hint: "Use a wallet that already exists inside OWS."
305
+ }
306
+ ]
307
+ });
308
+ const resolvedMode = requiredString(walletMode, "wallet mode");
309
+ if (resolvedMode === "created") {
310
+ const walletName = requiredString(await p.text({
311
+ message: "Wallet name",
312
+ placeholder: existing?.walletRef ?? `morpho-vault-manager`,
313
+ defaultValue: existing?.walletRef ?? "morpho-vault-manager"
314
+ }), "wallet name");
315
+ await p.note([
316
+ "Manual step 1/2: create the OWS wallet in your own shell so the plugin never handles owner credentials.",
317
+ "",
318
+ buildWalletCreateCommand(settings, walletName),
319
+ "",
320
+ "Return here after the wallet exists and paste the public address below."
321
+ ].join("\n"), "Wallet Setup");
322
+ const created = requiredBoolean(await p.confirm({
323
+ message: "Did you create the wallet successfully?",
324
+ initialValue: true
325
+ }), "wallet creation confirmation");
326
+ if (!created) {
327
+ fail("Wallet creation was not completed.");
328
+ }
329
+ const walletRef = walletName;
330
+ const walletAddress = requiredString(await p.text({
331
+ message: "Wallet public address",
332
+ placeholder: existing?.walletAddress ?? "0x...",
333
+ defaultValue: existing?.walletAddress ?? "",
334
+ validate(value) {
335
+ return isAddress(value) ? undefined : "Enter a valid EVM address.";
336
+ }
337
+ }), "wallet public address");
338
+ return {
339
+ walletMode: resolvedMode,
340
+ walletRef,
341
+ walletAddress: getAddress(walletAddress)
342
+ };
343
+ }
344
+ await p.note([
345
+ "Manual step 1/2: point the wizard at an existing OWS wallet reference and confirm the public address.",
346
+ "No wallet import happens inside the plugin, and no recovery material is collected here."
347
+ ].join("\n"), "Wallet Setup");
348
+ const walletRef = requiredString(await p.text({
349
+ message: "Existing OWS wallet reference",
350
+ placeholder: existing?.walletRef ?? "wallet-name-or-id",
351
+ defaultValue: existing?.walletRef ?? ""
352
+ }), "wallet reference");
353
+ const walletAddress = requiredString(await p.text({
354
+ message: "Existing wallet public address",
355
+ placeholder: existing?.walletAddress ?? "0x...",
356
+ defaultValue: existing?.walletAddress ?? "",
357
+ validate(value) {
358
+ return isAddress(value) ? undefined : "Enter a valid EVM address.";
359
+ }
360
+ }), "wallet public address");
361
+ return {
362
+ walletMode: resolvedMode,
363
+ walletRef,
364
+ walletAddress: getAddress(walletAddress)
365
+ };
366
+ }
367
+ async function promptTokenSource(defaultSource, existing) {
368
+ const baseline = existing ?? defaultSource;
369
+ const kind = requiredString(await p.select({
370
+ message: "OWS API token source",
371
+ initialValue: baseline.kind,
372
+ options: [
373
+ {
374
+ value: "env",
375
+ label: "Environment variable",
376
+ hint: "Gateway process reads the token from an env var. Good for ad-hoc setups."
377
+ },
378
+ {
379
+ value: "file",
380
+ label: "File on disk",
381
+ hint: "Read the token from a file path (mounted secrets, systemd EnvironmentFile, etc)."
382
+ }
383
+ ]
384
+ }), "token source");
385
+ if (kind === "env") {
386
+ const envVar = requiredString(await p.text({
387
+ message: "Environment variable name",
388
+ placeholder: baseline.kind === "env" ? baseline.envVar : defaultSource.kind === "env" ? defaultSource.envVar : "OWS_MORPHO_VAULT_MANAGER_TOKEN",
389
+ defaultValue: baseline.kind === "env" ? baseline.envVar : ""
390
+ }), "token environment variable");
391
+ return { kind: "env", envVar };
392
+ }
393
+ const filePath = requiredString(await p.text({
394
+ message: "Secret file path",
395
+ placeholder: baseline.kind === "file" ? baseline.path : "/run/secrets/morpho-vault-manager-token",
396
+ defaultValue: baseline.kind === "file" ? baseline.path : ""
397
+ }), "secret file path");
398
+ const mode = requiredString(await p.select({
399
+ message: "Secret file format",
400
+ initialValue: baseline.kind === "file" ? baseline.mode ?? "singleValue" : "singleValue",
401
+ options: [
402
+ { value: "singleValue", label: "Plain text containing only the token" },
403
+ { value: "json", label: "JSON file with a token field" }
404
+ ]
405
+ }), "secret file format");
406
+ const jsonField = mode === "json"
407
+ ? optionalString(await p.text({
408
+ message: "JSON field name",
409
+ placeholder: baseline.kind === "file" ? baseline.jsonField ?? "apiKey" : "apiKey",
410
+ defaultValue: baseline.kind === "file" ? baseline.jsonField ?? "" : ""
411
+ }))
412
+ : "";
413
+ return {
414
+ kind: "file",
415
+ path: filePath,
416
+ mode,
417
+ jsonField: jsonField.length > 0 ? jsonField : undefined
418
+ };
419
+ }
420
+ async function promptFundingGuidance(settings, walletAddress) {
421
+ await p.note([
422
+ "Deposit USDC on Base to the wallet address below.",
423
+ "",
424
+ `Wallet: ${walletAddress}`,
425
+ `Asset: USDC (${BASE_USDC_ADDRESS})`,
426
+ `Chain: Base (eip155:8453)`,
427
+ "",
428
+ "Funding is optional right now; the rebalancer will no-op cleanly until USDC arrives."
429
+ ].join("\n"), "Fund Wallet");
430
+ let lastProbe = null;
431
+ while (true) {
432
+ const choice = requiredString(await p.select({
433
+ message: "Funding check",
434
+ initialValue: "check",
435
+ options: [
436
+ { value: "check", label: "Check current USDC balance now" },
437
+ { value: "skip", label: "Skip funding check and continue" }
438
+ ]
439
+ }), "funding choice");
440
+ if (choice === "skip") {
441
+ return lastProbe;
442
+ }
443
+ try {
444
+ const balance = await getMorphoTokenBalance(settings, "base", BASE_USDC_ADDRESS, walletAddress);
445
+ const amount = balance.balance.value;
446
+ const checkedAt = new Date().toISOString();
447
+ lastProbe = { balance: amount, checkedAt };
448
+ await p.note([
449
+ `USDC balance: ${amount} ${balance.balance.symbol}`,
450
+ `Checked at: ${checkedAt}`
451
+ ].join("\n"), "Funding Status");
452
+ if (Number(amount) > 0) {
453
+ const proceed = requiredBoolean(await p.confirm({
454
+ message: "Continue with the current balance?",
455
+ initialValue: true
456
+ }), "funding continue confirmation");
457
+ if (proceed)
458
+ return lastProbe;
459
+ }
460
+ else {
461
+ const waitMore = requiredBoolean(await p.confirm({
462
+ message: "Balance is still zero. Check again after depositing?",
463
+ initialValue: true
464
+ }), "funding wait confirmation");
465
+ if (!waitMore)
466
+ return lastProbe;
467
+ }
468
+ }
469
+ catch (error) {
470
+ await p.note(`Failed to read USDC balance: ${error.message}`, "Funding Error");
471
+ const retry = requiredBoolean(await p.confirm({
472
+ message: "Try the balance check again?",
473
+ initialValue: false
474
+ }), "funding retry confirmation");
475
+ if (!retry)
476
+ return lastProbe;
477
+ }
478
+ }
479
+ }
480
+ async function promptModelSelection(existing) {
481
+ const choice = requiredString(await p.select({
482
+ message: "Model selection for the vault-manager agent",
483
+ initialValue: existing ? "override" : "inherit",
484
+ options: [
485
+ {
486
+ value: "inherit",
487
+ label: "Use the default OpenClaw model routing",
488
+ hint: "Recommended unless you already know which model to use."
489
+ },
490
+ {
491
+ value: "override",
492
+ label: "Pin a specific model for this agent",
493
+ hint: "Examples: anthropic/claude-sonnet-4-6, codex/gpt-5, codex/o4-mini."
494
+ }
495
+ ]
496
+ }), "model selection");
497
+ if (choice === "inherit")
498
+ return undefined;
499
+ const value = optionalString(await p.text({
500
+ message: "Model identifier",
501
+ placeholder: existing ?? "anthropic/claude-sonnet-4-6",
502
+ defaultValue: existing ?? ""
503
+ }));
504
+ return value.length > 0 ? value : undefined;
505
+ }
506
+ async function promptCronSchedule(settings, existingExpression, defaultCron) {
507
+ const initialValue = cronScheduleSelectionForExpression(existingExpression ?? defaultCron ?? settings.defaultCron);
508
+ const selection = requiredString(await p.select({
509
+ message: "Cron schedule",
510
+ initialValue,
511
+ options: [
512
+ ...Object.values(CRON_SCHEDULE_PRESETS).map((preset) => ({
513
+ value: preset.id,
514
+ label: preset.label,
515
+ hint: `${preset.description} ${preset.cronExpression}`
516
+ })),
517
+ {
518
+ value: "custom",
519
+ label: "Custom cron expression",
520
+ hint: "Enter an advanced cron expression manually."
521
+ }
522
+ ]
523
+ }), "cron schedule");
524
+ if (selection !== "custom") {
525
+ return CRON_SCHEDULE_PRESETS[selection].cronExpression;
526
+ }
527
+ return requiredString(await p.text({
528
+ message: "Custom cron expression",
529
+ placeholder: defaultCron ?? settings.defaultCron,
530
+ defaultValue: existingExpression ?? defaultCron ?? ""
531
+ }), "cron expression");
532
+ }
533
+ export async function runConfigureFlow(context) {
534
+ const { settings, profileId } = context;
535
+ const existing = await loadProfile(settings, profileId);
536
+ p.intro(`Morpho Vault Manager configure (${profileId})`);
537
+ await preflight(settings);
538
+ const wallet = await promptWallet(settings, existing.profile ?? undefined);
539
+ const backedUp = requiredBoolean(await p.confirm({
540
+ message: "Have you backed up the wallet recovery material and confirmed you understand the owner credential must stay out of the agent?",
541
+ initialValue: false
542
+ }), "backup confirmation");
543
+ if (!backedUp) {
544
+ fail("Backup confirmation is required.");
545
+ }
546
+ const riskProfile = requiredString(await p.select({
547
+ message: "Risk profile",
548
+ initialValue: existing.profile?.riskProfile ?? "balanced",
549
+ options: Object.values(RISK_PRESETS).map((preset) => ({
550
+ value: preset.id,
551
+ label: preset.label,
552
+ hint: preset.description
553
+ }))
554
+ }), "risk profile");
555
+ const riskPreset = RISK_PRESETS[riskProfile];
556
+ await p.note([
557
+ `Selected risk profile: ${riskPreset.label}`,
558
+ "Machine-readable risk config:",
559
+ formatRiskPresetConfig(riskPreset)
560
+ ].join("\n"), "Risk Config");
561
+ const modelPreference = await promptModelSelection(existing.profile?.modelPreference);
562
+ const delivery = await promptCronDelivery(settings, existing.profile ?? undefined);
563
+ const cronExpression = await promptCronSchedule(settings, existing.profile?.cronExpression, settings.defaultCron);
564
+ const timezone = requiredString(await p.text({
565
+ message: "Cron timezone",
566
+ placeholder: settings.defaultTimezone,
567
+ defaultValue: existing.profile?.timezone ?? settings.defaultTimezone
568
+ }), "cron timezone");
569
+ await p.note([
570
+ `Schedule: ${describeCronSchedule(cronExpression)}`,
571
+ `Timezone: ${timezone}`,
572
+ `Delivery: ${describeDeliveryTarget(delivery)}`,
573
+ `Machine-readable cron expression: ${cronExpression}`
574
+ ].join("\n"), "Cron Schedule");
575
+ const defaultTokenEnvVar = tokenEnvVarForProfile(settings, profileId);
576
+ const defaultTokenSourceForProfile = settings.defaultTokenSource.kind === "env"
577
+ ? { kind: "env", envVar: defaultTokenEnvVar }
578
+ : settings.defaultTokenSource;
579
+ const tokenSource = await promptTokenSource(defaultTokenSourceForProfile, existing.profile?.tokenSource);
580
+ const tokenEnvVar = tokenSource.kind === "env" ? tokenSource.envVar : defaultTokenEnvVar;
581
+ const agentId = agentIdForProfile(settings, profileId);
582
+ const workspaceDir = workspaceDirForAgent(settings, agentId);
583
+ const cronEnabled = requiredBoolean(await p.confirm({
584
+ message: "Enable the cron job immediately?",
585
+ initialValue: existing.profile?.cronEnabled ?? false
586
+ }), "cron enable confirmation");
587
+ const tokenSourceDescription = describeTokenSource(tokenSource);
588
+ const tokenProvisioningHint = (() => {
589
+ if (tokenSource.kind === "env") {
590
+ return `Inject the returned token into the OpenClaw gateway environment as ${tokenSource.envVar}.`;
591
+ }
592
+ if (tokenSource.kind === "file") {
593
+ const jsonSuffix = tokenSource.mode === "json"
594
+ ? ` (JSON field "${tokenSource.jsonField ?? "apiKey"}")`
595
+ : "";
596
+ return `Write the returned token to ${tokenSource.path}${jsonSuffix} and make sure the OpenClaw gateway process can read it.`;
597
+ }
598
+ return `Token is pre-resolved by the OpenClaw host (${tokenSource.origin}). No manual provisioning required.`;
599
+ })();
600
+ await p.note([
601
+ "Manual step 2/2: create the OWS API key yourself so the token never passes through the plugin process.",
602
+ "",
603
+ buildApiKeyCreateCommand({
604
+ settings,
605
+ keyName: `${agentId}-agent`,
606
+ walletRef: wallet.walletRef
607
+ }),
608
+ "",
609
+ tokenProvisioningHint,
610
+ "",
611
+ `Token source: ${tokenSourceDescription}`
612
+ ].join("\n"), "OWS API Key");
613
+ while (true) {
614
+ const probe = await resolveApiToken(tokenSource);
615
+ if (probe.ok) {
616
+ const envResult = await setEnvVar(settings, tokenEnvVar, probe.value);
617
+ if (envResult.ok) {
618
+ await p.note(`Token source ${probe.description} resolved and written to openclaw.json (env.vars.${tokenEnvVar}).`, "Token Verified");
619
+ }
620
+ else {
621
+ await p.note([
622
+ `Token source ${probe.description} resolved successfully.`,
623
+ "",
624
+ `Warning: failed to write env var to openclaw.json: ${envResult.stderr || "unknown error"}`,
625
+ `The cron agent may not be able to access the token. Set it manually:`,
626
+ ` openclaw config set env.vars.${tokenEnvVar} <token-value>`
627
+ ].join("\n"), "Token Verified (env injection failed)");
628
+ }
629
+ break;
630
+ }
631
+ const retry = requiredBoolean(await p.confirm({
632
+ message: `Token not yet available (${probe.description}). ${probe.error}\nRetry after provisioning?`,
633
+ initialValue: true
634
+ }), "token retry confirmation");
635
+ if (!retry) {
636
+ fail(`OWS API token could not be resolved via ${tokenSourceDescription}. Provision it and rerun configure.`);
637
+ }
638
+ }
639
+ const fundingProbe = await promptFundingGuidance(settings, wallet.walletAddress);
640
+ await ensureDir(workspaceDir);
641
+ const profile = {
642
+ profileId,
643
+ chain: "base",
644
+ walletRef: wallet.walletRef,
645
+ walletAddress: wallet.walletAddress,
646
+ walletMode: wallet.walletMode,
647
+ riskProfile,
648
+ tokenEnvVar,
649
+ tokenSource,
650
+ usdcAddress: BASE_USDC_ADDRESS,
651
+ agentId,
652
+ workspaceDir,
653
+ cronJobId: existing.profile?.cronJobId,
654
+ cronJobName: existing.profile?.cronJobName ?? `${settings.baseCronName} (${profileId})`,
655
+ cronExpression,
656
+ timezone,
657
+ notifications: delivery.notifications,
658
+ deliveryChannel: delivery.deliveryChannel,
659
+ deliveryTo: delivery.deliveryTo,
660
+ deliveryAccountId: delivery.deliveryAccountId,
661
+ cronEnabled,
662
+ createdAt: existing.profile?.createdAt ?? new Date().toISOString(),
663
+ updatedAt: new Date().toISOString(),
664
+ riskPreset,
665
+ modelPreference,
666
+ lastFundedCheckAt: fundingProbe?.checkedAt ?? existing.profile?.lastFundedCheckAt,
667
+ lastFundedUsdc: fundingProbe?.balance ?? existing.profile?.lastFundedUsdc,
668
+ lastValidationRun: existing.profile?.lastValidationRun
669
+ };
670
+ const agentResult = await ensureAgent({
671
+ settings,
672
+ agentId,
673
+ workspaceDir,
674
+ modelPreference
675
+ });
676
+ if (!agentResult.ok) {
677
+ fail(`Failed to create or resolve OpenClaw agent ${agentId}.\n${agentResult.stderr || agentResult.stdout}`);
678
+ }
679
+ const agentsMdPath = path.join(workspaceDir, "AGENTS.md");
680
+ const agentsMd = await readTextFile(agentsMdPath);
681
+ const vaultManagerInstructions = renderAgentInstructions(profile);
682
+ if (agentsMd === null) {
683
+ await writeTextFile(agentsMdPath, vaultManagerInstructions);
684
+ }
685
+ else {
686
+ await writeTextFile(agentsMdPath, agentsMd.trimEnd() + "\n\n" + vaultManagerInstructions);
687
+ }
688
+ const skillResult = await installSkill({
689
+ workspaceDir,
690
+ slug: "morpho-cli",
691
+ force: true
692
+ });
693
+ if (!skillResult.ok) {
694
+ await p.note([
695
+ "Failed to install the morpho-cli skill into the agent workspace.",
696
+ "",
697
+ "Install it manually:",
698
+ ` clawhub --workdir "${workspaceDir}" install morpho-cli`,
699
+ "",
700
+ "stderr:",
701
+ skillResult.stderr || "(empty)"
702
+ ].join("\n"), "Morpho Skill");
703
+ }
704
+ const cronResult = await upsertCronJob({
705
+ settings,
706
+ profile
707
+ });
708
+ if (!cronResult.ok || !cronResult.jobId) {
709
+ fail(`Failed to create or update the cron job.\n${cronResult.stderr || cronResult.stdout}`);
710
+ }
711
+ profile.cronJobId = cronResult.jobId;
712
+ let profilePath = await saveProfile(settings, profile);
713
+ await p.note([
714
+ `Profile: ${profilePath}`,
715
+ `Workspace: ${workspaceDir}`,
716
+ `Agent: ${agentId}`,
717
+ `Cron job: ${profile.cronJobId}`,
718
+ `Wallet mode: ${profile.walletMode}`,
719
+ `Schedule: ${describeCronSchedule(profile.cronExpression)}`,
720
+ `Delivery: ${describeDeliveryTarget(profile)}`,
721
+ `Risk config: ${formatRiskPresetConfig(profile.riskPreset)}`,
722
+ `Token source: ${tokenSourceDescription}`,
723
+ `Model: ${modelPreference ?? "(default OpenClaw routing)"}`
724
+ ].join("\n"), "Configured");
725
+ await p.note([
726
+ "Your vault manager profile is ready.",
727
+ "",
728
+ "To perform the initial allocation of your funds into Morpho vaults, run:",
729
+ "",
730
+ ` openclaw vault-manager allocate --profile ${profileId}`,
731
+ "",
732
+ "This will invoke the agent to compute a plan and execute the allocation",
733
+ "using morpho-cli and OWS."
734
+ ].join("\n"), "Next Step");
735
+ p.outro("Vault manager configuration complete.");
736
+ return {
737
+ profile,
738
+ profilePath,
739
+ createdAgent: agentResult.created,
740
+ createdCron: cronResult.created
741
+ };
742
+ }
743
+ export async function showStatus(settings, profileId, json) {
744
+ const { profile } = await loadProfile(settings, profileId);
745
+ if (!profile) {
746
+ fail(`Profile ${profileId} does not exist.`);
747
+ }
748
+ const cronJobs = await listCronJobs(settings);
749
+ const cronJob = cronJobs?.find((job) => {
750
+ const id = typeof job.id === "string" ? job.id : undefined;
751
+ return id === profile.cronJobId;
752
+ });
753
+ const effectiveSource = profile.tokenSource ?? settings.defaultTokenSource ?? { kind: "env", envVar: profile.tokenEnvVar };
754
+ const tokenProbe = await resolveApiToken(effectiveSource);
755
+ const summary = {
756
+ profileId: profile.profileId,
757
+ walletRef: profile.walletRef,
758
+ walletAddress: profile.walletAddress,
759
+ walletMode: profile.walletMode,
760
+ riskProfile: profile.riskProfile,
761
+ riskPreset: profile.riskPreset,
762
+ tokenEnvVar: profile.tokenEnvVar,
763
+ tokenSource: describeTokenSource(effectiveSource),
764
+ tokenReady: tokenProbe.ok,
765
+ tokenReadyError: tokenProbe.ok ? null : tokenProbe.error,
766
+ agentId: profile.agentId,
767
+ modelPreference: profile.modelPreference ?? null,
768
+ workspaceDir: profile.workspaceDir,
769
+ cronJobId: profile.cronJobId,
770
+ cronExpression: profile.cronExpression,
771
+ timezone: profile.timezone,
772
+ cronKnownToGateway: Boolean(cronJob),
773
+ cronEnabled: profile.cronEnabled,
774
+ notifications: profile.notifications,
775
+ deliveryChannel: profile.notifications === "announce"
776
+ ? profile.deliveryChannel ?? settings.defaultDeliveryChannel ?? "last"
777
+ : null,
778
+ deliveryTo: profile.notifications === "announce"
779
+ ? profile.deliveryTo ?? settings.defaultDeliveryTo ?? null
780
+ : null,
781
+ deliveryAccountId: profile.notifications === "announce"
782
+ ? profile.deliveryAccountId ?? settings.defaultDeliveryAccountId ?? null
783
+ : null,
784
+ lastFundedCheckAt: profile.lastFundedCheckAt ?? null,
785
+ lastFundedUsdc: profile.lastFundedUsdc ?? null,
786
+ lastValidationRun: profile.lastValidationRun ?? null,
787
+ updatedAt: profile.updatedAt
788
+ };
789
+ if (json) {
790
+ console.log(JSON.stringify(summary, null, 2));
791
+ return;
792
+ }
793
+ await p.note([
794
+ `Wallet: ${summary.walletRef} (${summary.walletAddress})`,
795
+ `Wallet mode: ${summary.walletMode}`,
796
+ `Risk profile: ${summary.riskProfile}`,
797
+ `Schedule: ${describeCronSchedule(summary.cronExpression)} (${summary.timezone})`,
798
+ `Risk config: ${formatRiskPresetConfig(summary.riskPreset)}`,
799
+ `Token source: ${summary.tokenSource} (${summary.tokenReady ? "ready" : `unavailable: ${summary.tokenReadyError}`})`,
800
+ `Agent: ${summary.agentId}`,
801
+ `Model: ${summary.modelPreference ?? "(default routing)"}`,
802
+ `Workspace: ${summary.workspaceDir}`,
803
+ `Cron job: ${summary.cronJobId ?? "missing"} (${summary.cronKnownToGateway ? "known" : "not found"})`,
804
+ `Cron enabled: ${summary.cronEnabled ? "yes" : "no"}`,
805
+ `Delivery: ${describeDeliveryTarget({
806
+ notifications: profile.notifications,
807
+ deliveryChannel: summary.deliveryChannel ?? undefined,
808
+ deliveryTo: summary.deliveryTo ?? undefined,
809
+ deliveryAccountId: summary.deliveryAccountId ?? undefined
810
+ })}`,
811
+ `Last funded check: ${summary.lastFundedCheckAt ?? "never"}${summary.lastFundedUsdc ? ` (${summary.lastFundedUsdc} USDC)` : ""}`,
812
+ `Last validation run: ${summary.lastValidationRun
813
+ ? `${summary.lastValidationRun.status} @ ${summary.lastValidationRun.createdAt}`
814
+ : "never"}`
815
+ ].join("\n"), `Status: ${profile.profileId}`);
816
+ }
817
+ export async function pauseProfile(settings, profileId) {
818
+ const loaded = await loadProfile(settings, profileId);
819
+ const profile = loaded.profile;
820
+ if (!profile || !profile.cronJobId) {
821
+ fail(`Profile ${profileId} does not have a cron job.`);
822
+ }
823
+ const ok = await disableCronJob(settings, profile.cronJobId);
824
+ if (!ok) {
825
+ fail(`Failed to disable cron job ${profile.cronJobId}.`);
826
+ }
827
+ profile.cronEnabled = false;
828
+ profile.updatedAt = new Date().toISOString();
829
+ await saveProfile(settings, profile);
830
+ p.outro(`Paused ${profileId}.`);
831
+ }
832
+ export async function resumeProfile(settings, profileId) {
833
+ const loaded = await loadProfile(settings, profileId);
834
+ const profile = loaded.profile;
835
+ if (!profile || !profile.cronJobId) {
836
+ fail(`Profile ${profileId} does not have a cron job.`);
837
+ }
838
+ const ok = await enableCronJob(settings, profile.cronJobId);
839
+ if (!ok) {
840
+ fail(`Failed to enable cron job ${profile.cronJobId}.`);
841
+ }
842
+ profile.cronEnabled = true;
843
+ profile.updatedAt = new Date().toISOString();
844
+ await saveProfile(settings, profile);
845
+ p.outro(`Resumed ${profileId}.`);
846
+ }
847
+ export async function allocateProfile(settings, profileId) {
848
+ const loaded = await loadProfile(settings, profileId);
849
+ const profile = loaded.profile;
850
+ if (!profile || !profile.cronJobId) {
851
+ fail(`Profile ${profileId} does not have a cron job. Run "openclaw vault-manager configure" first.`);
852
+ }
853
+ const output = await runCronJobNow(settings, profile.cronJobId);
854
+ await p.note(output || "Agent run enqueued. The agent will compute a plan and execute the allocation using morpho-cli and OWS.", `Allocate: ${profileId}`);
855
+ }
856
+ export async function runProfileNow(settings, profileId) {
857
+ const loaded = await loadProfile(settings, profileId);
858
+ const profile = loaded.profile;
859
+ if (!profile || !profile.cronJobId) {
860
+ fail(`Profile ${profileId} does not have a cron job.`);
861
+ }
862
+ const output = await runCronJobNow(settings, profile.cronJobId);
863
+ await p.note(output || "Run enqueued.", `Run Now: ${profileId}`);
864
+ }
865
+ function renderPlanSummary(result) {
866
+ const lines = [
867
+ `Status: ${result.status}`,
868
+ `Wallet: ${result.walletAddress}`,
869
+ `Managed USDC: ${result.metrics.totalManagedUsdc}`,
870
+ `Idle USDC: ${result.metrics.idleUsdc}`,
871
+ `Planned turnover: ${result.metrics.totalPlannedTurnoverUsdc}`,
872
+ `Receipt: ${result.receiptPath}`
873
+ ];
874
+ if (result.actions.length > 0) {
875
+ lines.push("");
876
+ lines.push("Actions:");
877
+ for (const action of result.actions) {
878
+ lines.push(`- ${action.kind} ${action.amountUsdc} USDC via ${action.vaultName} (${action.vaultAddress})`);
879
+ }
880
+ }
881
+ if (result.reasons.length > 0) {
882
+ lines.push("");
883
+ lines.push("Reasons:");
884
+ for (const reason of result.reasons) {
885
+ lines.push(`- ${reason}`);
886
+ }
887
+ }
888
+ return lines.join("\n");
889
+ }
890
+ async function presentPlanResult(result, json, title) {
891
+ if (json) {
892
+ console.log(JSON.stringify(result, null, 2));
893
+ return;
894
+ }
895
+ await p.note(renderPlanSummary(result), title);
896
+ }
897
+ export async function runProfilePlan(settings, profileId, json) {
898
+ const result = await runPlan(settings, profileId);
899
+ await presentPlanResult(result, json, `Plan: ${profileId}`);
900
+ }