@thirdfy/agent-cli 0.2.86 → 0.4.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +61 -25
  3. package/package.json +2 -2
  4. package/scripts/validate-cli-contract-schemas.mjs +5 -0
  5. package/src/cli/errors.mjs +58 -8
  6. package/src/cli/help.mjs +0 -3
  7. package/src/cli/manifest.mjs +54 -27
  8. package/src/cli/options/bootstrap.mjs +15 -3
  9. package/src/cli/options/chains.mjs +5 -0
  10. package/src/cli/options/credits.mjs +9 -0
  11. package/src/cli/options/data.mjs +13 -1
  12. package/src/cli/options/index.mjs +10 -4
  13. package/src/cli/options/walletControl.mjs +8 -0
  14. package/src/commands/bootstrap.mjs +44 -5
  15. package/src/commands/chat.mjs +3 -0
  16. package/src/commands/credits.mjs +39 -4
  17. package/src/commands/delegation.mjs +3 -1
  18. package/src/commands/discovery.mjs +156 -13
  19. package/src/commands/doctor.mjs +5 -2
  20. package/src/commands/execute.mjs +96 -3
  21. package/src/commands/prompt.mjs +5 -4
  22. package/src/commands/wallet.mjs +119 -3
  23. package/src/commands/walletControl.mjs +66 -0
  24. package/src/contracts/cli/schemas/actions.response.schema.json +45 -0
  25. package/src/contracts/cli/schemas/chains-non-evm.response.schema.json +76 -0
  26. package/src/contracts/cli/schemas/chains.response.schema.json +151 -0
  27. package/src/contracts/cli/schemas/error.response.schema.json +61 -0
  28. package/src/contracts/cli/schemas/preview.response.schema.json +179 -0
  29. package/src/contracts/cli/schemas/readiness.response.schema.json +94 -0
  30. package/src/core/args.mjs +10 -2
  31. package/src/core/chainRegistry.mjs +223 -1
  32. package/src/core/context.mjs +37 -14
  33. package/src/core/errorEnvelope.mjs +209 -0
  34. package/src/core/fundingTokenIn.mjs +24 -1
  35. package/src/core/http.mjs +6 -1
  36. package/src/core/readOnlyActions.mjs +10 -0
  37. package/src/core/walletExecuteChainId.mjs +25 -0
  38. package/src/lib/creditsUsdSummary.mjs +48 -4
  39. package/src/runtime/actions/actionList.mjs +87 -0
  40. package/src/runtime/actions/selection.mjs +17 -22
  41. package/src/runtime/execution/actionPreflight.mjs +50 -14
  42. package/src/runtime/execution/payloads.mjs +36 -28
  43. package/src/runtime/execution/runners.mjs +75 -21
  44. package/src/runtime/execution/svmLane.mjs +106 -0
  45. package/src/runtime/execution/walletControl.mjs +104 -0
  46. package/src/runtime/handlers.mjs +16 -2
  47. package/src/runtime/index.mjs +1 -1
  48. package/src/runtime/main.mjs +21 -5
  49. package/src/runtime/providerHints/apps.mjs +36 -0
  50. package/src/runtime/providerHints/catalogHint.mjs +55 -0
  51. package/src/runtime/providerHints/cex.mjs +130 -0
  52. package/src/runtime/providerHints/data.mjs +144 -0
  53. package/src/runtime/providerHints/dispatch.mjs +177 -0
  54. package/src/runtime/providerHints/dogeos.mjs +38 -0
  55. package/src/runtime/providerHints/earn.mjs +201 -0
  56. package/src/runtime/providerHints/perps.mjs +346 -0
  57. package/src/runtime/providerHints/prediction.mjs +128 -0
  58. package/src/runtime/providerHints/solana.mjs +85 -0
  59. package/src/runtime/providerHints/trading.mjs +78 -0
  60. package/src/runtime/providerHints/walletControl.mjs +39 -0
  61. package/src/runtime/risk/notional.mjs +59 -0
  62. package/src/runtime/risk/orderPreview.mjs +154 -0
  63. package/src/runtime/risk/riskRefusal.mjs +74 -0
  64. package/src/runtime/validation/actionParamsGate.mjs +2 -0
  65. package/src/runtime/providerHints.mjs +0 -1017
@@ -1,8 +1,52 @@
1
- export function formatCreditsBalanceSummary(response) {
2
- if (typeof response?.availableUsd === 'number') {
3
- return `$${response.availableUsd.toFixed(2)} available (${response.balance ?? 0} credits)`;
1
+ /**
2
+ * Credits balances and action costs are shown in credits, never as a dollar price per credit or per
3
+ * request (owner decision 2026-10-14; thirdfy-mcp #252 `stripUsdFields`). Buying credits keeps its USD
4
+ * amount (`credits buy --usd`): that is a purchase, not a request price.
5
+ */
6
+ const USD_KEY = /usd/i;
7
+
8
+ /** The value with every key that names USD (and displayCurrency / pricingCurrency) removed, at any depth. */
9
+ export function stripUsdFields(value) {
10
+ if (Array.isArray(value)) return value.map(stripUsdFields);
11
+ if (!value || typeof value !== 'object') return value;
12
+ const out = {};
13
+ for (const [key, inner] of Object.entries(value)) {
14
+ if (USD_KEY.test(key) || key === 'displayCurrency' || key === 'pricingCurrency') continue;
15
+ out[key] = stripUsdFields(inner);
4
16
  }
5
- return `${response?.balance ?? 0} credits`;
17
+ return out;
18
+ }
19
+
20
+ const isCount = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
21
+
22
+ /**
23
+ * Who pays next for the agent's actions, from the agent credits route's fields (thirdfy-api-v2#884):
24
+ * `freeRequestsRemainingToday` of `freeDailyLimit` (free requests per UTC day) and
25
+ * `ownerCreditsFallback` (the owner's credits pay once the agent's credits and free requests are
26
+ * used). Billing order: agent's credits, free requests, owner's credits, refusal. Same wording as
27
+ * thirdfy-mcp `creditsFreeQuotaNote` (#253). Undefined when an older API omits the fields.
28
+ */
29
+ export function creditsFreeQuotaNote(payload) {
30
+ const fields = payload?.data && typeof payload.data === 'object' && 'freeDailyLimit' in payload.data ? payload.data : payload;
31
+ if (!fields || typeof fields !== 'object') return undefined;
32
+ const { freeDailyLimit, freeRequestsRemainingToday, ownerCreditsFallback, balanceCredits } = fields;
33
+ if (!isCount(freeDailyLimit) || !isCount(freeRequestsRemainingToday)) return undefined;
34
+ const parts = [];
35
+ if (isCount(balanceCredits) && balanceCredits > 0) parts.push(`The agent's credits (${balanceCredits}) pay first`);
36
+ parts.push(`${freeRequestsRemainingToday} of ${freeDailyLimit} free requests left today`);
37
+ if (ownerCreditsFallback && typeof ownerCreditsFallback === 'object' && ownerCreditsFallback.enabled === true) {
38
+ const owner = ownerCreditsFallback.balanceCredits;
39
+ parts.push(isCount(owner) ? `then the owner's credits (${owner}) pay` : "then the owner's credits pay");
40
+ }
41
+ parts.push('then buy credits');
42
+ return `${parts.join('; ')}.`;
43
+ }
44
+
45
+ /** The balance in credits only, with who pays next when the API says. */
46
+ export function formatCreditsBalanceSummary(response) {
47
+ const credits = `${response?.balance ?? response?.balanceCredits ?? 0} credits`;
48
+ const note = creditsFreeQuotaNote(response);
49
+ return note ? `${credits}. ${note}` : credits;
6
50
  }
7
51
 
8
52
  export function formatCreditsModelsSummary(response) {
@@ -0,0 +1,87 @@
1
+ import { usageError } from '../../core/args.mjs';
2
+
3
+ /**
4
+ * Search, paging and views for `actions`, as thirdfy-mcp `getActionsCatalog` / `getAgentActions` page
5
+ * the list (`src/catalog/actionListFilter.ts`, `src/catalog/actionListView.ts`). The catalog is
6
+ * hundreds of actions with schemas; `--search`, `--limit`, `--offset` and `--view` return one page.
7
+ * Without any of them, `actions` prints the whole list as before.
8
+ * `--provider` and `--chain-id` stay the CLI's own filters (src/runtime/actions/selection.mjs).
9
+ */
10
+
11
+ export const DEFAULT_PAGE_SIZE = Object.freeze({ concise: 50, detailed: 10 });
12
+ /** The largest page each view returns, whatever `--limit` asks (thirdfy-mcp MAX_PAGE_SIZE). */
13
+ export const MAX_PAGE_SIZE = Object.freeze({ concise: 100, detailed: 25 });
14
+
15
+ const CONCISE_FIELDS = ['action', 'providerId', 'category', 'description', 'supportedChains', 'requiredParams', 'riskLevel', 'maxAmountUsd', 'enabled', 'aliases'];
16
+
17
+ const isRecord = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
18
+ const isSet = (value) => value !== undefined && value !== null && String(value).trim() !== '';
19
+
20
+ /**
21
+ * The paging flags, checked: `{ search, limit, offset, view }`, or null when none is set (the
22
+ * list stays whole). A bad value is INVALID_ARGUMENTS.
23
+ */
24
+ export function readActionListPaging(flags = {}) {
25
+ if (![flags.search, flags.limit, flags.offset, flags.view].some(isSet)) return null;
26
+ const view = isSet(flags.view) ? String(flags.view).trim().toLowerCase() : 'concise';
27
+ if (view !== 'concise' && view !== 'detailed') throw usageError('--view must be concise or detailed', 'view');
28
+ const count = (key, min, max) => {
29
+ if (!isSet(flags[key])) return undefined;
30
+ const text = String(flags[key]).trim();
31
+ const value = Number(text);
32
+ if (!/^\d+$/.test(text) || value < min || value > max) throw usageError(`--${key} must be an integer from ${min} to ${max}`, key);
33
+ return value;
34
+ };
35
+ return {
36
+ search: isSet(flags.search) ? String(flags.search).trim() : undefined,
37
+ limit: count('limit', 1, 500),
38
+ offset: count('offset', 0, Number.MAX_SAFE_INTEGER),
39
+ view,
40
+ };
41
+ }
42
+
43
+ const lower = (value) => (typeof value === 'string' ? value.toLowerCase() : '');
44
+
45
+ /** True when the action's name, title, description or aliases contain `search` (case-insensitive). */
46
+ function matchesSearch(entry, search) {
47
+ if (!search) return true;
48
+ const record = isRecord(entry) ? entry : {};
49
+ const aliases = Array.isArray(record.aliases) ? record.aliases : [];
50
+ return [record.action, record.name, record.title, record.description, ...aliases].map(lower).join(' ').includes(search.toLowerCase());
51
+ }
52
+
53
+ function conciseEntry(entry) {
54
+ if (!isRecord(entry)) return entry;
55
+ const out = {};
56
+ for (const field of CONCISE_FIELDS) {
57
+ const value = entry[field];
58
+ if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) continue;
59
+ out[field] = value;
60
+ }
61
+ if (typeof entry.behavior?.readOnly === 'boolean') out.readOnly = entry.behavior.readOnly;
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * One page of `actions` (already filtered by provider and chain): `{ actions, pagination, view, hint }`.
67
+ * `pagination` is `{ total, matched, offset, limit, returned, hasMore }`, as thirdfy-mcp returns it.
68
+ */
69
+ export function pageActionList(actions, paging) {
70
+ const rows = Array.isArray(actions) ? actions : [];
71
+ const view = paging.view;
72
+ const limit = Math.min(paging.limit ?? DEFAULT_PAGE_SIZE[view], MAX_PAGE_SIZE[view]);
73
+ const offset = paging.offset ?? 0;
74
+ const matched = rows.filter((entry) => matchesSearch(entry, paging.search));
75
+ const page = matched.slice(offset, offset + limit);
76
+ const hasMore = offset + page.length < matched.length;
77
+ return {
78
+ actions: view === 'concise' ? page.map(conciseEntry) : page,
79
+ pagination: { total: rows.length, matched: matched.length, offset, limit, returned: page.length, hasMore },
80
+ view,
81
+ hint: hasMore
82
+ ? `More actions match: run again with --offset ${offset + page.length}, or narrow with --provider or --search.`
83
+ : view === 'concise'
84
+ ? '--view detailed (with --provider or --search) returns paramsSchema for each action.'
85
+ : undefined,
86
+ };
87
+ }
@@ -7,6 +7,15 @@ import {
7
7
  toAvantisSpelling,
8
8
  toLegacyVenueActionKey,
9
9
  } from '../../core/venueBrand.mjs';
10
+ /**
11
+ * The action name to send to the API: the one the caller asked for, as thirdfy-mcp sends it
12
+ * (`gateManagedWriteActionParams` `apiAction`). The catalog key is sent instead only when the request
13
+ * matched through its Veranta/Avantis twin, the name the API in hand does not know.
14
+ */
15
+ export function actionNameForApi(resolved) {
16
+ return String(resolved?.apiAction || resolved?.resolvedAction || '').trim();
17
+ }
18
+
10
19
  export function createActionSelection({ getTradingProviderHint, formatProviderAmbiguousActionHint, formatProviderUnknownActionHint }) {
11
20
  function getActionKey(action) {
12
21
  return String(action?.action || action?.id || action?.key || action?.name || '')
@@ -368,6 +377,9 @@ function getActionProvider(action) {
368
377
 
369
378
  async function resolveActionSelection(ctx, flags) {
370
379
  const requestedAction = requireFlag(flags, 'action', 'Missing --action');
380
+ // The agent's own policy list whenever execution will run with an agent key (flag, env or config),
381
+ // as thirdfy-mcp resolves: an action the agent may not run stops here as UNKNOWN_ACTION with
382
+ // suggestions, not at the API as ACTION_NOT_ALLOWED.
371
383
  const actions = applyActionFilters(await getCachedActionsCatalog(ctx, flags), flags);
372
384
  return resolveActionFromRows(requestedAction, actions, flags);
373
385
  }
@@ -463,7 +475,6 @@ function matchCatalogEntry(requestedAction, keyed, flags = {}) {
463
475
  }
464
476
 
465
477
  function resolveActionFromRows(requestedAction, actions, flags = {}) {
466
- const requestedLower = String(requestedAction || '').toLowerCase();
467
478
  const keyed = actions
468
479
  .map((action) => ({
469
480
  key: getActionKey(action),
@@ -484,34 +495,18 @@ function resolveActionFromRows(requestedAction, actions, flags = {}) {
484
495
  }
485
496
 
486
497
  const direct = matchCatalogEntry(requestedAction, keyed, flags);
487
- if (direct) return direct;
498
+ if (direct) return { ...direct, apiAction: String(requestedAction).trim() };
488
499
 
489
500
  // Veranta (formerly Avantis): retry with the other spelling so `place_veranta_perps_order` finds the
490
501
  // `place_avantis_perps_order` row on an API that has not shipped Veranta names, and the reverse.
491
502
  const alternate = alternateVenueActionSpelling(requestedAction);
492
503
  if (alternate) {
493
504
  const viaAlternate = matchCatalogEntry(alternate, keyed, flags);
494
- if (viaAlternate) return { ...viaAlternate, requestedAction };
495
- }
496
- const alternateLower = alternate ? alternate.toLowerCase() : null;
497
-
498
- const fuzzyNeedles = alternateLower ? [requestedLower, alternateLower] : [requestedLower];
499
- const fuzzy = keyed.filter(
500
- (entry) =>
501
- fuzzyNeedles.some((needle) => entry.key.toLowerCase().includes(needle)) ||
502
- entry.aliases.some((a) => fuzzyNeedles.some((needle) => a.toLowerCase().includes(needle)))
503
- );
504
- if (fuzzy.length === 1) {
505
- return { requestedAction, resolvedAction: fuzzy[0].key, resolvedActionMeta: fuzzy[0].action || null };
506
- }
507
- if (fuzzy.length > 1) {
508
- const providerHint = flags.provider ? getTradingProviderHint(flags.provider) : null;
509
- throw new Error(
510
- `Ambiguous --action "${requestedAction}". Did you mean one of: ${formatActionList(
511
- fuzzy.slice(0, 10).map((v) => v.key)
512
- )}?${formatProviderAmbiguousActionHint(providerHint)}`
513
- );
505
+ if (viaAlternate) return { ...viaAlternate, requestedAction, apiAction: viaAlternate.resolvedAction };
514
506
  }
507
+ // No substring match: a name that is not an action, alias or Veranta/Avantis twin is unknown, as in
508
+ // thirdfy-mcp. Picking the one row that contains it ran a different action than the one asked for
509
+ // (`swap` → `execute_optimal_swap` on a list without `swap`); the near names come back as suggestions.
515
510
  return {
516
511
  requestedAction,
517
512
  resolvedAction: requestedAction,
@@ -1,5 +1,7 @@
1
1
  import { toLegacyVenueActionKey } from '../../core/venueBrand.mjs';
2
2
  import { resolveExecuteEstimatedAmountUsd } from './payloads.mjs';
3
+ import { describeCliError } from '../../core/errorEnvelope.mjs';
4
+ import { findRiskRefusal } from '../risk/riskRefusal.mjs';
3
5
 
4
6
  /**
5
7
  * Action-aware `agent_wallet` preflight (Thirdfy API: `GET /agent/execution-wallet?action=…`).
@@ -31,8 +33,12 @@ export function resolveActionVenue(action) {
31
33
 
32
34
  /**
33
35
  * The venue an action executes on, next to the chain the managed wallet was looked up on. The
34
- * managed wallet is one EVM address on every chain, so a Hyperliquid order still resolves it on the
35
- * default chain; this says the order itself does not run there.
36
+ * managed EVM wallet is one address on every EVM chain, so a Hyperliquid order still resolves it on
37
+ * the default chain; this says the order itself does not run there. Only the four EVM-settled venues
38
+ * above match. Most Solana actions (Jupiter, Kamino, Meteora, ...) resolve no venue here; the Solana
39
+ * funding actions `deposit_polymarket_from_solana` and `deposit_hyperliquid_from_solana` name a venue
40
+ * and so get `executionVenue` in the output. Either way they sign with the separate base58 Solana
41
+ * agent wallet on chain 101/103, and the API decides venue readiness.
36
42
  */
37
43
  export function describeExecutionVenue(action, walletLookupChainId) {
38
44
  const venue = resolveActionVenue(action);
@@ -41,10 +47,11 @@ export function describeExecutionVenue(action, walletLookupChainId) {
41
47
  }
42
48
 
43
49
  /** Adds `action`, `params` and `estimatedAmountUsd` to an `/execution-wallet` query. */
44
- export function appendActionPreflightQuery(query, { action, params, flags }) {
50
+ export function appendActionPreflightQuery(query, { action, apiAction, params, flags }) {
45
51
  const actionId = String(action || '').trim();
46
52
  if (!actionId) return query;
47
- query.set('action', actionId);
53
+ // The API gets the requested name (as thirdfy-mcp sends it); the catalog key sizes the estimate.
54
+ query.set('action', String(apiAction || actionId).trim());
48
55
  if (params && typeof params === 'object' && !Array.isArray(params) && Object.keys(params).length) {
49
56
  query.set('params', JSON.stringify(params));
50
57
  }
@@ -53,11 +60,22 @@ export function appendActionPreflightQuery(query, { action, params, flags }) {
53
60
  return query;
54
61
  }
55
62
 
56
- /** Venue fields an action preflight carries onto the CLI envelope. */
57
- export const ACTION_PREFLIGHT_VENUE_FIELDS = Object.freeze(['venue', 'venueReadiness', 'blockingReasons']);
63
+ /**
64
+ * Venue and order fields an action preflight carries onto the CLI envelope: venue readiness, the
65
+ * order preview (`orderPreview`), a risk-policy refusal (`riskRefusal`) and a venue setup that runs
66
+ * with the first order (`venueSetup`), as thirdfy-mcp `previewOrder` reads them.
67
+ */
68
+ export const ACTION_PREFLIGHT_VENUE_FIELDS = Object.freeze([
69
+ 'venue',
70
+ 'venueReadiness',
71
+ 'blockingReasons',
72
+ 'orderPreview',
73
+ 'riskRefusal',
74
+ 'venueSetup',
75
+ ]);
58
76
 
59
77
  /** Stages at which the API refused the action itself (as opposed to a CLI-local check). */
60
- const API_BLOCK_STAGES = new Set(['catalog', 'policy', 'venue_readiness']);
78
+ const API_BLOCK_STAGES = new Set(['catalog', 'policy', 'venue_readiness', 'venue_rules', 'risk_policy']);
61
79
 
62
80
  /** True when the API blocked the action (policy, catalog, venue readiness). */
63
81
  export function isApiActionBlock(preflight) {
@@ -70,24 +88,42 @@ export function pickActionPreflightFields(response) {
70
88
  for (const key of [...ACTION_PREFLIGHT_VENUE_FIELDS, 'actionPreflight']) {
71
89
  if (response?.[key] != null) out[key] = response[key];
72
90
  }
91
+ // A risk-policy refusal may sit under `data`; carry it at the top level.
92
+ const riskRefusal = out.riskRefusal || findRiskRefusal(response);
93
+ if (riskRefusal) out.riskRefusal = riskRefusal;
73
94
  return out;
74
95
  }
75
96
 
76
97
  /**
77
- * A blocked action preflight arrives as a non-2xx answer (409 `VENUE_NOT_READY`, 403 policy, 429
78
- * limits) with `preflightBlocked: true`. Turn it into a blocked preflight result; null otherwise.
98
+ * The API answered the action preflight but refused the action (`success: false`, from a non-2xx
99
+ * `preflightBlocked` answer or a 2xx one). `preflightBlocked: true` tells it from a CLI-local check.
79
100
  */
80
- export function blockedActionPreflightFromError(error) {
81
- const payload = error?.payload;
82
- if (!payload || typeof payload !== 'object' || payload.preflightBlocked !== true) return null;
101
+ export function blockedActionPreflight(payload, { statusCode = null, fallbackError = 'Preflight blocked', errorFields = {} } = {}) {
83
102
  return {
84
103
  success: false,
104
+ preflightBlocked: true,
85
105
  blockedReason: String(payload.blockedReason || 'PREFLIGHT_BLOCKED'),
86
106
  blockedStage: String(payload.blockedStage || 'policy'),
87
- error: String(payload.error || payload.message || error?.message || 'Preflight blocked'),
88
- statusCode: Number(error?.statusCode) || null,
107
+ error: String(payload.error || payload.message || fallbackError),
108
+ statusCode,
89
109
  signerMethod: payload.signerMethod || 'managed_wallet_server',
90
110
  ...pickActionPreflightFields(payload),
91
111
  ...(payload.data ? { data: payload.data } : {}),
112
+ ...errorFields,
92
113
  };
93
114
  }
115
+
116
+ /**
117
+ * A blocked action preflight arrives as a non-2xx answer (409 `VENUE_NOT_READY`, 403 policy, 429
118
+ * limits) with `preflightBlocked: true`. Turn it into a blocked preflight result; null otherwise.
119
+ */
120
+ export function blockedActionPreflightFromError(error) {
121
+ const payload = error?.payload;
122
+ if (!payload || typeof payload !== 'object' || payload.preflightBlocked !== true) return null;
123
+ return blockedActionPreflight(payload, {
124
+ statusCode: Number(error?.statusCode) || null,
125
+ fallbackError: error?.message || 'Preflight blocked',
126
+ // errorCode, retryable, retryAfterSeconds, nextAction, hint: as thirdfy-mcp's error envelope.
127
+ errorFields: describeCliError(error),
128
+ });
129
+ }
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
2
2
  import { wrapWriteIdempotencyKeyForHostedRuntime } from '../../core/hostedRuntimeIdempotency.mjs';
3
3
  import { resolveExecutionContextFromEnv } from '../../core/executionContext.mjs';
4
4
  import { requireFlag, parseJsonFlag } from '../../core/args.mjs';
5
- import { loadProfileConfig } from '../../core/context.mjs';
5
+ import { findAgentApiKey, loadProfileConfig } from '../../core/context.mjs';
6
6
  import { createCliError } from '../../core/envelope.mjs';
7
7
  import { toLegacyVenueActionKey } from '../../core/venueBrand.mjs';
8
8
  import { normalizeRunMode, normalizeManagedRunMode, normalizeHybridWalletMode } from '../../core/runMode.mjs';
@@ -14,15 +14,25 @@ function isTruthyFlag(value) {
14
14
  return value === true || String(value || '').trim().toLowerCase() === 'true';
15
15
  }
16
16
 
17
- export function mergeConfirmWritesIntoParams(params, flags) {
17
+ const SCHEMA_CONFIRMATION_KEYS = ['confirmWrites', 'confirm'];
18
+
19
+ /**
20
+ * `--confirm-writes` (or `--confirm`) copied into each confirmation key the action's catalog
21
+ * `paramsSchema` lists, and only those, as thirdfy-mcp `injectSchemaConfirmationIntoParams`
22
+ * (`src/catalog/actionParamsGate.ts`): `params.confirmWrites` on Veranta writes, `params.confirm` on
23
+ * the Hyperliquid and Polymarket bridge and capital hops. A schema with `additionalProperties: false`
24
+ * refuses any other key, so a key it does not list is never added. A value already in params wins.
25
+ * The schema defaults to the one the catalog params gate kept on `flags`.
26
+ */
27
+ export function mergeConfirmWritesIntoParams(params, flags, schema = flags?.__actionParamsSchema) {
18
28
  const next = { ...(params && typeof params === 'object' ? params : {}) };
19
- if (
20
- isTruthyFlag(flags?.confirmWrites) ||
21
- isTruthyFlag(flags?.confirm) ||
22
- next.confirmWrites === true ||
23
- next.confirm === true
24
- ) {
25
- next.confirmWrites = true;
29
+ if (!isTruthyFlag(flags?.confirmWrites) && !isTruthyFlag(flags?.confirm)) return next;
30
+ const properties = schema && typeof schema === 'object' ? schema.properties : null;
31
+ if (!properties || typeof properties !== 'object') return next;
32
+ for (const key of SCHEMA_CONFIRMATION_KEYS) {
33
+ if (!Object.prototype.hasOwnProperty.call(properties, key)) continue;
34
+ if (next[key] !== undefined) continue;
35
+ next[key] = true;
26
36
  }
27
37
  return next;
28
38
  }
@@ -37,11 +47,11 @@ export function resolveExecuteEstimatedAmountUsd(action, flags) {
37
47
  }
38
48
 
39
49
  export function createExecutionPayloads({ getPreparedParams, rawUnitsToDecimalString }) {
40
- function buildBuildTxPayload(flags, resolvedAction) {
50
+ function buildBuildTxPayload(flags, resolvedAction, apiAction) {
41
51
  const runMode = String(flags.runMode || '').trim().toLowerCase() || 'self';
42
52
  const payload = {
43
53
  agentApiKey: resolveAgentApiKey(flags, runMode),
44
- action: String(resolvedAction || requireFlag(flags, 'action', 'Missing --action')).trim(),
54
+ action: String(apiAction || resolvedAction || requireFlag(flags, 'action', 'Missing --action')).trim(),
45
55
  params: getPreparedParams(flags),
46
56
  };
47
57
  if (flags.chainId) payload.chainId = Number(flags.chainId);
@@ -129,10 +139,11 @@ function buildManagedExecutePayload(flags, options) {
129
139
  delete managedParams.tokenInDecimals;
130
140
  managedParams.amountIn = flags.__swapInput.amountInHuman;
131
141
  }
142
+ // `action` (the catalog key) drives the CLI's own rules; the API gets the requested name.
132
143
  const action = String(options.resolvedAction || requireFlag(flags, 'action', 'Missing --action')).trim();
133
144
  const payload = {
134
145
  agentApiKey: resolveAgentApiKey(flags, runMode),
135
- action,
146
+ action: String(options.apiAction || action).trim(),
136
147
  userDid: resolveEffectiveUserDid(flags, {
137
148
  runMode,
138
149
  hybridWalletMode: options.hybridWalletMode,
@@ -155,11 +166,11 @@ function buildManagedExecutePayload(flags, options) {
155
166
  'Managed wallet execution requires --user-did, --agent-key (solo did:wallet identity), THIRDFY_USER_DID, or a saved userDid from `thirdfy-agent login email`.'
156
167
  );
157
168
  }
158
- const estimatedAmountUsd = resolveExecuteEstimatedAmountUsd(payload.action, flags);
169
+ const estimatedAmountUsd = resolveExecuteEstimatedAmountUsd(action, flags);
159
170
  if (estimatedAmountUsd != null) payload.estimatedAmountUsd = estimatedAmountUsd;
160
171
  if (options.forceIdempotency) {
161
172
  payload.executionIdempotencyKey = String(
162
- flags.idempotencyKey || `cli:walletExecute:${payload.action}:${Date.now()}:${randomUUID()}`
173
+ flags.idempotencyKey || `cli:walletExecute:${action}:${Date.now()}:${randomUUID()}`
163
174
  );
164
175
  } else if (flags.idempotencyKey) {
165
176
  payload.executionIdempotencyKey = String(flags.idempotencyKey);
@@ -168,11 +179,11 @@ function buildManagedExecutePayload(flags, options) {
168
179
  payload.executionIdempotencyKey = wrapWriteIdempotencyKeyForHostedRuntime(
169
180
  payload.executionIdempotencyKey,
170
181
  resolveExecutionContextFromEnv().runtimeId,
171
- payload.action,
182
+ action,
172
183
  );
173
184
  }
174
185
  assertHostedRuntimePolymarketWriteIdempotency({
175
- action: payload.action,
186
+ action,
176
187
  idempotencyKey: payload.executionIdempotencyKey,
177
188
  paramsIdempotencyKey: managedParams?.idempotencyKey,
178
189
  });
@@ -181,14 +192,16 @@ function buildManagedExecutePayload(flags, options) {
181
192
 
182
193
  function buildIntentPayload(flags, options) {
183
194
  const runMode = String(options.runMode || flags.runMode || '').trim().toLowerCase() || 'thirdfy';
195
+ // `action` (the catalog key) drives the CLI's own rules; the API gets the requested name.
196
+ const action = String(options.resolvedAction || requireFlag(flags, 'action', 'Missing --action')).trim();
184
197
  const payload = {
185
198
  agentApiKey: resolveAgentApiKey(flags, runMode),
186
- action: String(options.resolvedAction || requireFlag(flags, 'action', 'Missing --action')).trim(),
199
+ action: String(options.apiAction || action).trim(),
187
200
  params: mergeConfirmWritesIntoParams(getPreparedParams(flags), flags),
188
201
  };
189
202
  if (flags.catalog) payload.catalog = String(flags.catalog).trim();
190
203
  if (flags.chainId) payload.chainId = Number(flags.chainId);
191
- const estimatedAmountUsd = resolveExecuteEstimatedAmountUsd(payload.action, flags);
204
+ const estimatedAmountUsd = resolveExecuteEstimatedAmountUsd(action, flags);
192
205
  if (estimatedAmountUsd != null) payload.estimatedAmountUsd = estimatedAmountUsd;
193
206
  const intentRunMode = String(options.runMode || flags.runMode || '').trim().toLowerCase();
194
207
  const userDid = resolveEffectiveUserDid(flags, {
@@ -213,7 +226,7 @@ function buildIntentPayload(flags, options) {
213
226
  };
214
227
  }
215
228
  if (options.forceIdempotency) {
216
- payload.idempotencyKey = String(flags.idempotencyKey || `cli:${payload.action}:${Date.now()}:${randomUUID()}`);
229
+ payload.idempotencyKey = String(flags.idempotencyKey || `cli:${action}:${Date.now()}:${randomUUID()}`);
217
230
  } else if (flags.idempotencyKey) {
218
231
  payload.idempotencyKey = String(flags.idempotencyKey);
219
232
  }
@@ -221,12 +234,12 @@ function buildIntentPayload(flags, options) {
221
234
  payload.idempotencyKey = wrapWriteIdempotencyKeyForHostedRuntime(
222
235
  payload.idempotencyKey,
223
236
  resolveExecutionContextFromEnv().runtimeId,
224
- payload.action,
237
+ action,
225
238
  );
226
239
  }
227
240
  if (!options.validationOnly) {
228
241
  assertHostedRuntimePolymarketWriteIdempotency({
229
- action: payload.action,
242
+ action,
230
243
  idempotencyKey: payload.idempotencyKey,
231
244
  paramsIdempotencyKey: payload.params?.idempotencyKey,
232
245
  });
@@ -235,13 +248,8 @@ function buildIntentPayload(flags, options) {
235
248
  }
236
249
 
237
250
  function resolveAgentApiKey(flags, runMode) {
238
- const explicit = String(flags.agentApiKey || '').trim();
239
- if (explicit) return explicit;
240
- const envKey = String(process.env.THIRDFY_AGENT_API_KEY || '').trim();
241
- if (envKey) return envKey;
242
- const config = loadProfileConfig();
243
- const configKey = String(config?.agent?.apiKey || '').trim();
244
- if (configKey) return configKey;
251
+ const key = findAgentApiKey(flags);
252
+ if (key) return key;
245
253
  if (runMode === 'self') {
246
254
  throw new Error(
247
255
  'SELF_OPEN_DISABLED: self mode without agent identity is not enabled on this API yet. Set --agent-api-key, THIRDFY_AGENT_API_KEY, or login/config default.'