@thirdfy/agent-cli 0.3.0 → 0.4.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +59 -19
  3. package/package.json +2 -2
  4. package/scripts/validate-cli-contract-schemas.mjs +6 -0
  5. package/src/cli/errors.mjs +58 -8
  6. package/src/cli/help.mjs +1 -1
  7. package/src/cli/manifest.mjs +62 -6
  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 +109 -16
  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 -3
  22. package/src/commands/wallet.mjs +238 -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/contracts/cli/schemas/wallet-profile.response.schema.json +127 -0
  31. package/src/core/args.mjs +10 -2
  32. package/src/core/chainRegistry.mjs +223 -1
  33. package/src/core/context.mjs +57 -14
  34. package/src/core/errorEnvelope.mjs +209 -0
  35. package/src/core/fundingTokenIn.mjs +24 -1
  36. package/src/core/http.mjs +6 -1
  37. package/src/core/readOnlyActions.mjs +10 -0
  38. package/src/core/walletExecuteChainId.mjs +25 -0
  39. package/src/lib/creditsUsdSummary.mjs +48 -4
  40. package/src/runtime/actions/actionList.mjs +87 -0
  41. package/src/runtime/actions/selection.mjs +17 -22
  42. package/src/runtime/execution/actionPreflight.mjs +50 -14
  43. package/src/runtime/execution/payloads.mjs +36 -28
  44. package/src/runtime/execution/runners.mjs +75 -21
  45. package/src/runtime/execution/svmLane.mjs +106 -0
  46. package/src/runtime/execution/walletControl.mjs +104 -0
  47. package/src/runtime/handlers.mjs +18 -2
  48. package/src/runtime/index.mjs +1 -1
  49. package/src/runtime/main.mjs +21 -5
  50. package/src/runtime/providerHints/apps.mjs +36 -0
  51. package/src/runtime/providerHints/catalogHint.mjs +55 -0
  52. package/src/runtime/providerHints/cex.mjs +130 -0
  53. package/src/runtime/providerHints/data.mjs +144 -0
  54. package/src/runtime/providerHints/dispatch.mjs +177 -0
  55. package/src/runtime/providerHints/dogeos.mjs +38 -0
  56. package/src/runtime/providerHints/earn.mjs +201 -0
  57. package/src/runtime/providerHints/perps.mjs +346 -0
  58. package/src/runtime/providerHints/prediction.mjs +128 -0
  59. package/src/runtime/providerHints/solana.mjs +85 -0
  60. package/src/runtime/providerHints/trading.mjs +78 -0
  61. package/src/runtime/providerHints/walletControl.mjs +39 -0
  62. package/src/runtime/risk/notional.mjs +59 -0
  63. package/src/runtime/risk/orderPreview.mjs +154 -0
  64. package/src/runtime/risk/riskRefusal.mjs +74 -0
  65. package/src/runtime/validation/actionParamsGate.mjs +2 -0
  66. package/src/runtime/providerHints.mjs +0 -1017
@@ -124,11 +124,14 @@ function buildOnboardingHelp(ctx, flags = {}) {
124
124
  },
125
125
  {
126
126
  id: 'wallet_sign',
127
- description: 'Headless wallet proof for operators with an EVM signer.',
127
+ description: 'An agent with its own EVM key and no email: prove the key, then create the agent. The agent wallet owns the agent.',
128
128
  commands: [
129
129
  'thirdfy-agent agent auth challenge --agent-key 0xYOUR_AGENT_KEY --json',
130
- 'thirdfy-agent login --agent-key 0xYOUR_AGENT_KEY --challenge-id <id> --signature <hex> --json',
130
+ '# sign data.message exactly as given with the private key of 0xYOUR_AGENT_KEY (EIP-191 personal_sign)',
131
+ 'thirdfy-agent agent auth verify --agent-key 0xYOUR_AGENT_KEY --challenge-id <id> --signature <hex> --json',
132
+ 'thirdfy-agent agent register --agent-key 0xYOUR_AGENT_KEY --owner-session-token <ownerSessionToken> --name "My Agent" --json',
131
133
  ],
134
+ note: 'verify returns an owner session; register with --name creates the agent and its API key. An existing agent logs in with `thirdfy-agent login --agent-key 0xYOUR_AGENT_KEY --challenge-id <id> --signature <hex>`. No owner session is stored until the challenge completes.',
132
135
  },
133
136
  {
134
137
  id: 'existing_credentials',
@@ -1,4 +1,4 @@
1
- import { requireFlag } from '../core/args.mjs';
1
+ import { parseJsonFlag, requireFlag } from '../core/args.mjs';
2
2
  import { createCliError, exitAfterOutput } from '../core/envelope.mjs';
3
3
  import {
4
4
  getEffectiveRunMode,
@@ -7,6 +7,13 @@ import {
7
7
  parseBooleanFlag,
8
8
  } from '../core/runMode.mjs';
9
9
  import { apiGet } from '../core/http.mjs';
10
+ import { applySvmLane } from '../runtime/execution/svmLane.mjs';
11
+ import { isReadOnlyResolvedAction } from '../core/readOnlyActions.mjs';
12
+ import { resolveWalletExecuteChainId } from '../core/walletExecuteChainId.mjs';
13
+ import { resolveExecuteEstimatedAmountUsd } from '../runtime/execution/payloads.mjs';
14
+ import { assertPreviewSized, buildPreviewOrder, shapeOrderPreview } from '../runtime/risk/orderPreview.mjs';
15
+ import { actionNameForApi } from '../runtime/actions/selection.mjs';
16
+ import { runNotionalWarnings } from '../runtime/risk/notional.mjs';
10
17
  import { findTypedReading, formatTypedReadingLead } from './signals/typedReading.mjs';
11
18
  import { createRequire } from 'module';
12
19
 
@@ -25,6 +32,7 @@ export function createExecuteCommands(deps) {
25
32
  applyAvantisAgentWalletAddressDefault,
26
33
  runPreflightByMode,
27
34
  runExecutionByMode,
35
+ resolveManagedExecutionPreflight,
28
36
  applyExecutionFallbackHints,
29
37
  performSelfBroadcast,
30
38
  executeManagedWalletRun,
@@ -39,7 +47,8 @@ async function commandPreflight(ctx, flags, capabilities) {
39
47
  const resolved = await resolveActionSelection(ctx, flags);
40
48
  prepareActionParamsForFlags(flags, resolved.resolvedAction);
41
49
  enforceCatalogActionParams(resolved, flags);
42
- const runMode = getEffectiveRunMode(ctx, flags);
50
+ // Solana-only rows default to chain 101; Solana runs only on agent_wallet.
51
+ const runMode = applySvmLane(flags, resolved, getEffectiveRunMode(ctx, flags));
43
52
  enforceOffchainVenueLaneCompatibility(resolved.resolvedAction, runMode);
44
53
  const hybridWalletMode = runMode === 'hybrid' ? normalizeHybridWalletMode(flags.hybridWalletMode) : null;
45
54
  enforceChainCompatibility(capabilities, flags, runMode);
@@ -89,11 +98,88 @@ async function commandPreflight(ctx, flags, capabilities) {
89
98
  }
90
99
 
91
100
 
101
+ /**
102
+ * `preview`: whether Thirdfy would sign a write, with its order and risk preview, before it is sent.
103
+ * Same verdict as thirdfy-mcp `previewOrder`. Read only: nothing is signed, billed or reserved. A
104
+ * blocked order is the answer (success, exit 0); `data.wouldSign` says whether `run` would sign it.
105
+ */
106
+ async function commandPreview(ctx, flags, capabilities) {
107
+ const previewFlags = { ...flags, runMode: 'agent_wallet' };
108
+ const resolved = await resolveActionSelection(ctx, previewFlags);
109
+ prepareActionParamsForFlags(previewFlags, resolved.resolvedAction);
110
+ enforceCatalogActionParams(resolved, previewFlags);
111
+ if (isReadOnlyResolvedAction(resolved)) {
112
+ throw createCliError(
113
+ 'PREVIEW_ORDER_WRITE_ONLY',
114
+ `${resolved.resolvedAction} is a read; preview previews writes. Run the read with \`thirdfy-agent run\`.`,
115
+ { resolvedAction: resolved.resolvedAction, requestedAction: resolved.requestedAction },
116
+ );
117
+ }
118
+ applySvmLane(previewFlags, resolved, 'agent_wallet', { locked: true });
119
+ enforceChainCompatibility(capabilities, previewFlags, 'agent_wallet');
120
+ const managedPreflight =
121
+ (await applyHyperliquidAgentWalletAddressDefault(ctx, previewFlags, resolved, 'agent_wallet'))
122
+ || (await applyLighterAgentWalletAddressDefault(ctx, previewFlags, resolved, 'agent_wallet'))
123
+ || (await applyAvantisAgentWalletAddressDefault(ctx, previewFlags, resolved, 'agent_wallet'));
124
+ // The action-aware preflight, with no CLI funding or gas gate: the verdict reads the API's answer.
125
+ const preflight = await resolveManagedExecutionPreflight(ctx, previewFlags, resolved, {
126
+ runMode: 'agent_wallet',
127
+ requireFundingCheck: false,
128
+ preflightSeed: managedPreflight,
129
+ evaluateAction: true,
130
+ });
131
+ // Anything but an answer about the order (no identity, a wallet mismatch) is a failed command.
132
+ if (!preflight.success && preflight.preflightBlocked !== true) {
133
+ throw createCliError(preflight.blockedReason || 'PREVIEW_FAILED', preflight.error || 'Preview failed', {
134
+ blockedStage: preflight.blockedStage || 'routing',
135
+ });
136
+ }
137
+ // The catalog key drives the CLI's own rules; the verdict and the order name the action the API
138
+ // was asked about (the requested name), as thirdfy-mcp previewOrder does.
139
+ const action = resolved.resolvedAction;
140
+ const apiAction = actionNameForApi(resolved);
141
+ const chainId = Number(preflight.chainId) || resolveWalletExecuteChainId({
142
+ action,
143
+ chainId: previewFlags.chainId,
144
+ params: parseJsonFlag(previewFlags, 'params') || {},
145
+ });
146
+ const estimatedAmountUsd = resolveExecuteEstimatedAmountUsd(action, previewFlags);
147
+ const behavior = resolved.resolvedActionMeta?.behavior;
148
+ const shaped = shapeOrderPreview({ action: apiAction, chainId, preflight });
149
+ assertPreviewSized({ shaped, estimatedAmountUsd, behavior });
150
+ const order = buildPreviewOrder({
151
+ shaped,
152
+ params: parseJsonFlag(flags, 'params') || {},
153
+ estimatedAmountUsd,
154
+ paramsSchema: previewFlags.__actionParamsSchema,
155
+ });
156
+ printEnvelope({
157
+ success: true,
158
+ code: shaped.wouldSign ? 'PREVIEW_WOULD_SIGN' : 'PREVIEW_BLOCKED',
159
+ message: shaped.wouldSign
160
+ ? 'Thirdfy would sign this order'
161
+ : shaped.message || `Thirdfy would not sign this order (${shaped.blockedReason || 'blocked'})`,
162
+ data: { ...shaped, ...(order ? { order } : {}) },
163
+ meta: {
164
+ apiBase: ctx.apiBase,
165
+ requestedAction: resolved.requestedAction,
166
+ resolvedAction: action,
167
+ runMode: 'agent_wallet',
168
+ executionWalletAddress: preflight.executionWalletAddress || null,
169
+ profile: ctx.profile || null,
170
+ swapInput: previewFlags.__swapInput || null,
171
+ capabilitiesVersion: capabilities?.contractVersion || null,
172
+ },
173
+ });
174
+ }
175
+
176
+
92
177
  async function commandRun(ctx, flags, capabilities) {
93
178
  const resolved = await resolveActionSelection(ctx, flags);
94
179
  prepareActionParamsForFlags(flags, resolved.resolvedAction);
95
180
  enforceCatalogActionParams(resolved, flags);
96
- const runMode = getEffectiveRunMode(ctx, flags);
181
+ // Solana-only rows default to chain 101; Solana runs only on agent_wallet.
182
+ const runMode = applySvmLane(flags, resolved, getEffectiveRunMode(ctx, flags));
97
183
  enforceOffchainVenueLaneCompatibility(resolved.resolvedAction, runMode);
98
184
  const hybridWalletMode = runMode === 'hybrid' ? normalizeHybridWalletMode(flags.hybridWalletMode) : null;
99
185
  enforceChainCompatibility(capabilities, flags, runMode);
@@ -102,6 +188,8 @@ async function commandRun(ctx, flags, capabilities) {
102
188
  || (await applyLighterAgentWalletAddressDefault(ctx, flags, resolved, runMode))
103
189
  || (await applyAvantisAgentWalletAddressDefault(ctx, flags, resolved, runMode));
104
190
  const skipPreflight = Boolean(flags.skipPreflight);
191
+ // A write with no --estimated-amount-usd: a warning, or a refusal before anything is sent.
192
+ const warnings = runNotionalWarnings(resolved, flags, runMode);
105
193
  const backendResult = await runExecutionByMode(runMode, ctx, flags, resolved, {
106
194
  skipPreflight,
107
195
  hybridWalletMode,
@@ -164,6 +252,9 @@ async function commandRun(ctx, flags, capabilities) {
164
252
  skippedPreflight: skipPreflight,
165
253
  swapInput: flags.__swapInput || null,
166
254
  capabilitiesVersion: capabilities?.contractVersion || null,
255
+ ...(flags.__runModeDefaultedForSvm ? { runModeDefaultedForSvm: true } : {}),
256
+ ...(flags.__chainIdInferred ? { chainIdInferred: flags.__chainIdInferred } : {}),
257
+ ...(warnings.length ? { warnings } : {}),
167
258
  },
168
259
  },
169
260
  typedReading ? { humanLead: formatTypedReadingLead(typedReading) } : {},
@@ -238,6 +329,7 @@ async function commandSelfExec(ctx, flags, capabilities) {
238
329
  const resolved = await resolveActionSelection(ctx, flags);
239
330
  prepareActionParamsForFlags(flags, resolved.resolvedAction);
240
331
  enforceCatalogActionParams(resolved, flags);
332
+ applySvmLane(flags, resolved, 'self', { locked: true });
241
333
  enforceOffchainVenueLaneCompatibility(resolved.resolvedAction, 'self');
242
334
  enforceChainCompatibility(capabilities, flags, 'self');
243
335
  const selfResult = await executeSelfRun(ctx, flags, resolved, {
@@ -278,6 +370,7 @@ async function commandSelfExec(ctx, flags, capabilities) {
278
370
 
279
371
  return {
280
372
  commandPreflight,
373
+ commandPreview,
281
374
  commandRun,
282
375
  commandAgentRun,
283
376
  commandIntentStatus,
@@ -1,5 +1,6 @@
1
1
  import { requireFlag } from '../core/args.mjs';
2
2
  import { apiPost } from '../core/http.mjs';
3
+ import { getEffectiveRunMode } from '../core/runMode.mjs';
3
4
  export function createPromptCommands({ printEnvelope }) {
4
5
  async function commandPrompt(ctx, flags, capabilities, promptTokens) {
5
6
  const prompt = String(flags.prompt || promptTokens.join(' ') || '').trim();
@@ -9,12 +10,13 @@ async function commandPrompt(ctx, flags, capabilities, promptTokens) {
9
10
  printEnvelope({
10
11
  success: true,
11
12
  code: 'PROMPT_RECEIVED',
12
- message: 'Prompt captured; execute with `run` using --run-mode.',
13
+ message: 'Prompt captured; find the action with `actions`, check it with `preview`, then send it with `run`.',
13
14
  data: {
14
15
  prompt,
15
16
  suggestedCommands: [
16
- 'thirdfy-agent run --run-mode self --agent-api-key ... --action ... --params ...',
17
- 'thirdfy-agent run --run-mode hybrid --agent-api-key ... --action ... --params ...',
17
+ 'thirdfy-agent actions --provider <provider> --json',
18
+ 'thirdfy-agent preview --action <action> --params <json> --estimated-amount-usd <usd> --json',
19
+ 'thirdfy-agent run --run-mode agent_wallet --action <action> --params <json> --estimated-amount-usd <usd> --json',
18
20
  ],
19
21
  },
20
22
  meta: {
@@ -1,8 +1,15 @@
1
- import { requireFlag } from '../core/args.mjs';
1
+ import { requireFlag, usageError } from '../core/args.mjs';
2
2
  import { createCliError, exitAfterOutput } from '../core/envelope.mjs';
3
- import { loadProfileConfig } from '../core/context.mjs';
4
- import { DEFAULT_CHAIN_ID, parseChainIdFlag, resolveChainRpcUrl } from '../core/chainRegistry.mjs';
3
+ import { findAgentApiKey, loadProfileConfig } from '../core/context.mjs';
4
+ import { DEFAULT_CHAIN_ID, chainGasNote, isSvmChainId, parseChainIdFlag, resolveChainRpcUrl } from '../core/chainRegistry.mjs';
5
+ import { apiGet } from '../core/http.mjs';
6
+ import { getEffectiveRunMode } from '../core/runMode.mjs';
7
+ import { readWalletControl } from '../runtime/execution/walletControl.mjs';
8
+ import { blockedResultFields } from '../core/errorEnvelope.mjs';
9
+ import { assertSvmChainRunByApi } from '../runtime/execution/svmLane.mjs';
10
+ import { applySvmLane } from '../runtime/execution/svmLane.mjs';
5
11
  import { readSignedTxChainId } from '../core/signedTx.mjs';
12
+ import { runNotionalWarnings } from '../runtime/risk/notional.mjs';
6
13
 
7
14
  export function createWalletCommands(deps) {
8
15
  const {
@@ -18,6 +25,9 @@ export function createWalletCommands(deps) {
18
25
  executeSelfRun,
19
26
  submitSignedTx,
20
27
  printEnvelope,
28
+ resolveEffectiveUserDid,
29
+ resolveAgentKey,
30
+ buildOwnerAuthHeaders,
21
31
  } = deps;
22
32
 
23
33
 
@@ -26,12 +36,15 @@ async function commandWalletExecute(ctx, flags, capabilities) {
26
36
  const resolved = await resolveActionSelection(ctx, normalizedFlags);
27
37
  prepareActionParamsForFlags(normalizedFlags, resolved.resolvedAction);
28
38
  enforceCatalogActionParams(resolved, normalizedFlags);
39
+ applySvmLane(normalizedFlags, resolved, 'agent_wallet');
29
40
  enforceChainCompatibility(capabilities, normalizedFlags, 'agent_wallet');
30
41
  const managedPreflight =
31
42
  (await applyHyperliquidAgentWalletAddressDefault(ctx, normalizedFlags, resolved, 'agent_wallet'))
32
43
  || (await applyLighterAgentWalletAddressDefault(ctx, normalizedFlags, resolved, 'agent_wallet'))
33
44
  || (await applyAvantisAgentWalletAddressDefault(ctx, normalizedFlags, resolved, 'agent_wallet'));
34
45
  const skipPreflight = Boolean(normalizedFlags.skipPreflight);
46
+ // A write with no --estimated-amount-usd: a warning, or a refusal before anything is sent.
47
+ const warnings = runNotionalWarnings(resolved, normalizedFlags, 'agent_wallet');
35
48
  const result = await executeManagedWalletRun(ctx, normalizedFlags, resolved, { skipPreflight, managedPreflight });
36
49
  printEnvelope({
37
50
  success: result.success,
@@ -47,6 +60,7 @@ async function commandWalletExecute(ctx, flags, capabilities) {
47
60
  executionWalletAddress: result.executionWalletAddress || null,
48
61
  profile: ctx.profile || null,
49
62
  capabilitiesVersion: capabilities?.contractVersion || null,
63
+ ...(warnings.length ? { warnings } : {}),
50
64
  },
51
65
  });
52
66
  if (!result.success) await exitAfterOutput(1);
@@ -58,6 +72,7 @@ async function commandWalletSign(ctx, flags, capabilities) {
58
72
  const resolved = await resolveActionSelection(ctx, normalizedFlags);
59
73
  prepareActionParamsForFlags(normalizedFlags, resolved.resolvedAction);
60
74
  enforceCatalogActionParams(resolved, normalizedFlags);
75
+ applySvmLane(normalizedFlags, resolved, 'self', { locked: true });
61
76
  enforceOffchainVenueLaneCompatibility(resolved.resolvedAction, 'self');
62
77
  enforceChainCompatibility(capabilities, normalizedFlags, 'self');
63
78
  const selfResult = await executeSelfRun(ctx, normalizedFlags, resolved, {
@@ -91,6 +106,13 @@ async function commandWalletSubmit(ctx, flags, capabilities) {
91
106
  if (Number.isNaN(flagChainId)) {
92
107
  throw createCliError('INVALID_CHAIN_ID', `wallet submit --chain-id must be a positive integer (got ${flags.chainId})`);
93
108
  }
109
+ if (isSvmChainId(flagChainId)) {
110
+ throw createCliError(
111
+ 'SVM_REQUIRES_AGENT_WALLET',
112
+ `wallet submit broadcasts signed EVM transactions only. Solana (chain ${flagChainId}) actions run with --run-mode agent_wallet on the managed Solana agent wallet.`,
113
+ { chainId: flagChainId, chainFamily: 'svm', recommendedRunModes: ['agent_wallet'] },
114
+ );
115
+ }
94
116
  // The signed tx carries its chain id (EIP-155 / typed tx). Use it when --chain-id is absent,
95
117
  // and refuse a mismatch so a DogeOS-signed tx is never sent to a Base RPC (or vice versa).
96
118
  const txChainId = readSignedTxChainId(signedTxHex);
@@ -147,7 +169,220 @@ async function commandWalletList(ctx, _flags) {
147
169
  });
148
170
  }
149
171
 
172
+ /** The chain a readiness check uses, saying so when it assumed Base (thirdfy-mcp `resolveReadinessChain`). */
173
+ function resolveReadinessChain(flags) {
174
+ const chainId = parseChainIdFlag(flags.chainId);
175
+ // A bad --chain-id is an error, not a silent check of Base.
176
+ if (Number.isNaN(chainId)) throw usageError('--chain-id must be a positive integer chain id', 'chainId');
177
+ if (chainId) return { chainId };
178
+ return {
179
+ chainId: DEFAULT_CHAIN_ID,
180
+ chainIdAssumed: true,
181
+ chainIdNote: `--chain-id was not provided; assumed Base (${DEFAULT_CHAIN_ID}). Pass --chain-id for any other chain (for example DogeOS testnet 6281971, Solana 101).`,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * The agent's execution wallet on a chain, read with its own key (`GET /agent/execution-wallet`, as
187
+ * thirdfy-mcp agentWalletBootstrap): the API answer, the chain's gas note, wallet control (API 4.0),
188
+ * whether it holds the funding token, and whether it can sign (`ready`). Used by `readiness` and
189
+ * `wallet profile`.
190
+ */
191
+ /**
192
+ * The agent an agent API key acts for. With an explicit --agent-api-key, only --agent-key or
193
+ * --user-did name it: an agent from env or config may belong to another key. Otherwise the env or
194
+ * config agent. Throws AGENT_IDENTITY_UNAVAILABLE when no agent can be named, as thirdfy-mcp does
195
+ * for a key it cannot tie to an agent; it never guesses.
196
+ */
197
+ function resolveAgentIdentity(flags) {
198
+ const explicitKey = Boolean(String(flags.agentApiKey || '').trim());
199
+ const agentKey = explicitKey ? String(flags.agentKey || '').trim().toLowerCase() : String(resolveAgentKey(flags) || '').trim();
200
+ const explicitDid = String(flags.userDid || '').trim();
201
+ const userDid = explicitKey
202
+ ? explicitDid || (agentKey ? `did:wallet:${agentKey}` : '')
203
+ : resolveEffectiveUserDid(flags, { runMode: 'agent_wallet' });
204
+ if (!userDid) {
205
+ throw createCliError(
206
+ 'AGENT_IDENTITY_UNAVAILABLE',
207
+ explicitKey
208
+ ? 'With --agent-api-key, pass --agent-key (the agent that key belongs to) so its wallet can be named.'
209
+ : 'No agent to name the wallet: pass --agent-key, or set THIRDFY_AGENT_KEY or config agent.agentKey.',
210
+ { blockedStage: 'auth' },
211
+ );
212
+ }
213
+ return { agentKey: agentKey || null, userDid };
214
+ }
215
+
216
+ async function readAgentExecutionWallet(ctx, flags, agentApiKey, chainId) {
217
+ const { agentKey, userDid } = resolveAgentIdentity(flags);
218
+ const query = new URLSearchParams({ chainId: String(chainId), runMode: 'agent_wallet' });
219
+ if (userDid) query.set('userDid', userDid);
220
+ const data = await apiGet(ctx, `/api/v1/agent/execution-wallet?${query.toString()}`, { 'x-agent-api-key': agentApiKey });
221
+ // The API says per chain whether it sponsors the fee (Solana `gasSponsored`); that answer wins.
222
+ const gas = chainGasNote(chainId, { gasSponsored: data?.gasSponsored });
223
+ const control = readWalletControl(data, { svm: isSvmChainId(chainId) });
224
+ const raw = String(data?.fundingTokenBalance?.raw ?? '').trim();
225
+ const fundingReady = /^(0x[0-9a-fA-F]+|[0-9]+)$/.test(raw) ? BigInt(raw) > 0n : undefined;
226
+ const ready = Boolean(data?.executionWalletAddress) && data?.success !== false && !control.walletControlBlocked;
227
+ return { agentKey, userDid, data, gas, control, fundingReady, ready };
228
+ }
229
+
230
+ /**
231
+ * `wallet profile` (thirdfy-mcp `getWalletProfile`). With owner auth: the owner's live onboarding
232
+ * profile (`GET /agent/onboarding/cli/status`: linked wallets, EVM/Solana readiness). With
233
+ * --agent-api-key, or with no owner auth but an agent key in env or config: the agent's own
234
+ * execution wallet on --chain-id (Base by default), with `ready`, `walletControl` and `nextAction`.
235
+ * `wallet list` reads only the local profile config; this asks the API.
236
+ */
237
+ async function commandWalletProfile(ctx, flags, capabilities) {
238
+ const meta = { apiBase: ctx.apiBase, capabilitiesVersion: capabilities?.contractVersion || null };
239
+ const ownerHeaders = (() => {
240
+ try {
241
+ return buildOwnerAuthHeaders(flags, 'no owner auth');
242
+ } catch {
243
+ return null;
244
+ }
245
+ })();
246
+ const agentApiKey = findAgentApiKey(flags);
247
+ // An owner flag the caller passed wins (as thirdfy-mcp), then an explicit --agent-api-key, then
248
+ // owner auth from env or config, then an agent key from env or config.
249
+ const explicitOwner = Boolean(String(flags.ownerSessionToken || flags.authToken || '').trim());
250
+ const explicitAgent = Boolean(String(flags.agentApiKey || '').trim());
251
+ const agentPath = !explicitOwner && (explicitAgent || (!ownerHeaders && Boolean(agentApiKey)));
252
+ if (!agentPath) {
253
+ if (!ownerHeaders) {
254
+ throw createCliError(
255
+ 'OWNER_AUTH_REQUIRED',
256
+ 'wallet profile needs owner auth (--owner-session-token or --auth-token) for the owner profile, or an agent key for the agent\'s execution wallet.',
257
+ { blockedStage: 'auth' },
258
+ );
259
+ }
260
+ const profile = await apiGet(ctx, '/api/v1/agent/onboarding/cli/status', ownerHeaders);
261
+ printEnvelope({
262
+ success: true,
263
+ code: 'WALLET_PROFILE_OK',
264
+ message: 'Owner wallet profile loaded',
265
+ data: { auth: 'owner', source: '/api/v1/agent/onboarding/cli/status', profile },
266
+ meta,
267
+ });
268
+ return;
269
+ }
270
+ const chain = resolveReadinessChain(flags);
271
+ assertSvmChainRunByApi(chain.chainId);
272
+ const { agentKey, userDid, data, gas, control, fundingReady, ready } = await readAgentExecutionWallet(ctx, flags, agentApiKey, chain.chainId);
273
+ // An API answer of success:false is a failed call, as thirdfy-mcp lifts it to the top level.
274
+ const apiFailed = data?.success === false;
275
+ const failure = apiFailed
276
+ ? {
277
+ ...blockedResultFields(typeof data?.blockedReason === 'string' && data.blockedReason ? data.blockedReason : 'WALLET_PROFILE_FAILED'),
278
+ ...(data?.error ? { error: String(data.error) } : {}),
279
+ }
280
+ : {};
281
+ printEnvelope({
282
+ success: !apiFailed,
283
+ code: apiFailed ? 'WALLET_PROFILE_FAILED' : 'WALLET_PROFILE_OK',
284
+ message: apiFailed
285
+ ? `Agent execution wallet read failed${data?.error ? `: ${data.error}` : ''}`
286
+ : ready ? 'Agent execution wallet loaded (ready)' : control.userMessage || 'Agent execution wallet loaded (not ready)',
287
+ data: {
288
+ ...failure,
289
+ auth: 'agent_api_key',
290
+ source: '/api/v1/agent/execution-wallet',
291
+ agentKey,
292
+ chainId: chain.chainId,
293
+ ...(chain.chainIdAssumed ? { chainIdAssumed: true, chainIdNote: chain.chainIdNote } : {}),
294
+ ready,
295
+ ...(fundingReady !== undefined ? { fundingReady } : {}),
296
+ ...(gas ? { gas } : {}),
297
+ ...(control.walletControl ? { walletControl: control.walletControl } : {}),
298
+ ...(control.nextAction ? { nextAction: control.nextAction, userMessage: control.userMessage } : {}),
299
+ executionWallet: {
300
+ executionWalletAddress: data?.executionWalletAddress || null,
301
+ userDid: userDid || data?.userDid || null,
302
+ data,
303
+ },
304
+ note: "This is the agent's execution wallet. The owner's onboarding profile needs --owner-session-token or --auth-token.",
305
+ },
306
+ meta,
307
+ });
308
+ if (apiFailed) await exitAfterOutput(1);
309
+ }
310
+
311
+ /**
312
+ * `readiness`: one verdict on whether the agent can act, as thirdfy-mcp `getSelfReadiness`.
313
+ * - agent_wallet: the agent's execution wallet on the chain (`GET /agent/execution-wallet` with the
314
+ * agent's own key): its address, funding, gas, and wallet control (API 4.0), with `ready`,
315
+ * `walletControl` and `nextAction` at the top. No owner auth.
316
+ * - self / hybrid / thirdfy: the delegation check (`GET /agent/delegation/check`, owner auth).
317
+ */
318
+ async function commandReadiness(ctx, flags, capabilities) {
319
+ const runMode = getEffectiveRunMode(ctx, flags);
320
+ const chain = resolveReadinessChain(flags);
321
+ const meta = { apiBase: ctx.apiBase, runMode, capabilitiesVersion: capabilities?.contractVersion || null };
322
+ const echo = {
323
+ runMode,
324
+ custodyMode: ctx.custodyMode || null,
325
+ chainId: chain.chainId,
326
+ ...(chain.chainIdAssumed ? { chainIdAssumed: true, chainIdNote: chain.chainIdNote } : {}),
327
+ walletAddress: flags.walletAddress ? String(flags.walletAddress) : null,
328
+ };
329
+
330
+ if (runMode !== 'agent_wallet') {
331
+ const headers = buildOwnerAuthHeaders(flags, `readiness with --run-mode ${runMode} needs owner auth (--auth-token or --owner-session-token).`);
332
+ const agentKey = requireFlag({ agentKey: resolveAgentKey(flags) }, 'agentKey', 'Missing --agent-key (or THIRDFY_AGENT_KEY / config.agent.agentKey)');
333
+ const query = new URLSearchParams({ agentKey, chainId: String(chain.chainId) });
334
+ const readiness = await apiGet(ctx, `/api/v1/agent/delegation/check?${query.toString()}`, headers);
335
+ printEnvelope({
336
+ success: true,
337
+ code: 'READINESS_OK',
338
+ message: `Delegation readiness for ${runMode}`,
339
+ data: { ...echo, ...(chainGasNote(chain.chainId) ? { gas: chainGasNote(chain.chainId) } : {}), source: '/api/v1/agent/delegation/check', readiness },
340
+ meta,
341
+ });
342
+ return;
343
+ }
344
+
345
+ // A Solana chain the API does not run is refused as `run` refuses it (thirdfy-mcp unsupportedChainFields).
346
+ assertSvmChainRunByApi(chain.chainId);
347
+ // Only the agent's own key: readiness acts for the caller's agent.
348
+ const agentApiKey = findAgentApiKey(flags);
349
+ if (!agentApiKey) {
350
+ throw createCliError(
351
+ 'AGENT_API_KEY_REQUIRED',
352
+ "readiness with --run-mode agent_wallet needs the agent's own API key (--agent-api-key, THIRDFY_AGENT_API_KEY or config).",
353
+ { blockedStage: 'auth', runMode, chainId: chain.chainId },
354
+ );
355
+ }
356
+ const { userDid, data, gas, control, fundingReady, ready } = await readAgentExecutionWallet(ctx, flags, agentApiKey, chain.chainId);
357
+ printEnvelope({
358
+ success: true,
359
+ code: ready ? 'READY' : 'NOT_READY',
360
+ message: ready ? 'The agent can act on this chain' : control.userMessage || 'The agent is not ready on this chain',
361
+ data: {
362
+ ...echo,
363
+ ...(gas ? { gas } : {}),
364
+ source: '/api/v1/agent/execution-wallet',
365
+ ready,
366
+ // `ready` says the wallet can sign; whether it holds the funding token is this separate field.
367
+ ...(fundingReady !== undefined ? { fundingReady } : {}),
368
+ ...(control.walletControl ? { walletControl: control.walletControl } : {}),
369
+ ...(control.nextAction ? { nextAction: control.nextAction, userMessage: control.userMessage } : {}),
370
+ readiness: {
371
+ executionWalletAddress: data?.executionWalletAddress || null,
372
+ userDid: userDid || data?.userDid || null,
373
+ ready,
374
+ ...(fundingReady !== undefined ? { fundingReady } : {}),
375
+ ...(control.walletControlBlocked ? { blockedBy: 'wallet_control', blocker: control.walletControlBlocker, signerActive: false } : {}),
376
+ data,
377
+ },
378
+ },
379
+ meta,
380
+ });
381
+ }
382
+
150
383
  return {
384
+ commandReadiness,
385
+ commandWalletProfile,
151
386
  commandWalletExecute,
152
387
  commandWalletSign,
153
388
  commandWalletSubmit,
@@ -0,0 +1,66 @@
1
+ import { loadProfileConfig } from '../core/context.mjs';
2
+ import { parseJsonFlag, requireFlag } from '../core/args.mjs';
3
+ import { apiGet, apiPost } from '../core/http.mjs';
4
+
5
+ /**
6
+ * The owner's side of API 4.0 wallet control (`/api/v1/agent/:agentKey/wallet-control/*`), for a
7
+ * user-owned agent wallet. Owner auth only (`--owner-session-token` or a Privy `--auth-token`); the
8
+ * API refuses agent API keys. Thirdfy never signs these: `request` returns the unsigned Privy
9
+ * request(s), the owner signs `signatureInput` with their own key, and `submit` relays it. This is
10
+ * how an owner answers AGENT_POLICY_UPDATE_REQUIRED (`--action update_policy`) from the CLI.
11
+ * Same routes as Console and thirdfy-mcp `walletControlRequest` / `walletControlSubmit`.
12
+ */
13
+ export function createWalletControlCommands({ printEnvelope, buildOwnerAuthHeaders }) {
14
+ const ownerHeaders = (flags, command) =>
15
+ buildOwnerAuthHeaders(flags, `Missing owner auth for wallet-control ${command}: provide --auth-token (THIRDFY_AUTH_TOKEN) or --owner-session-token`);
16
+
17
+ function agentKeyOf(flags) {
18
+ const key = String(flags.agentKey || loadProfileConfig()?.agent?.agentKey || '').trim();
19
+ if (!key) throw new Error('Missing --agent-key (or a configured agent)');
20
+ return key;
21
+ }
22
+
23
+ const base = (agentKey) => `/api/v1/agent/${encodeURIComponent(agentKey)}/wallet-control`;
24
+ const meta = (ctx, capabilities) => ({ apiBase: ctx.apiBase, capabilitiesVersion: capabilities?.contractVersion || null });
25
+
26
+ async function commandWalletControlState(ctx, flags, capabilities) {
27
+ const headers = ownerHeaders(flags, 'state');
28
+ const agentKey = agentKeyOf(flags);
29
+ const query = flags.walletAddress ? `?walletAddress=${encodeURIComponent(String(flags.walletAddress).trim())}` : '';
30
+ const response = await apiGet(ctx, `${base(agentKey)}/state${query}`, headers);
31
+ printEnvelope({ success: true, code: 'WALLET_CONTROL_STATE', message: 'Owner wallet state', data: response?.data ?? response, meta: meta(ctx, capabilities) });
32
+ }
33
+
34
+ async function commandWalletControlRequest(ctx, flags, capabilities) {
35
+ const headers = ownerHeaders(flags, 'request');
36
+ const action = requireFlag(flags, 'action', 'Missing --action (update_policy, stop_agent, resume_agent, change_payout, export)');
37
+ const agentKey = agentKeyOf(flags);
38
+ const payload = { action: String(action).trim() };
39
+ if (flags.walletAddress) payload.walletAddress = String(flags.walletAddress).trim();
40
+ if (flags.payoutAddress) payload.payoutAddress = String(flags.payoutAddress).trim();
41
+ if (flags.recipientPublicKey) payload.recipientPublicKey = String(flags.recipientPublicKey).trim();
42
+ const response = await apiPost(ctx, `${base(agentKey)}/requests`, payload, headers);
43
+ printEnvelope({
44
+ success: true,
45
+ code: 'WALLET_CONTROL_REQUESTS',
46
+ message: 'Unsigned owner request(s): sign each signatureInput with the owner key, then run wallet-control submit',
47
+ data: response?.data ?? response,
48
+ meta: meta(ctx, capabilities),
49
+ });
50
+ }
51
+
52
+ async function commandWalletControlSubmit(ctx, flags, capabilities) {
53
+ const headers = ownerHeaders(flags, 'submit');
54
+ const action = requireFlag(flags, 'action', 'Missing --action');
55
+ const signatureInput = parseJsonFlag(flags, 'signatureInput');
56
+ if (!signatureInput || typeof signatureInput !== 'object') throw new Error('Missing --signature-input (the signatureInput JSON of the request the owner signed)');
57
+ const signature = requireFlag(flags, 'signature', 'Missing --signature (the owner signature of signatureInput)');
58
+ const agentKey = agentKeyOf(flags);
59
+ const payload = { action: String(action).trim(), signatureInput, signature: String(signature).trim() };
60
+ if (flags.payoutAddress) payload.payoutAddress = String(flags.payoutAddress).trim();
61
+ const response = await apiPost(ctx, `${base(agentKey)}/submit`, payload, headers);
62
+ printEnvelope({ success: true, code: 'WALLET_CONTROL_SUBMITTED', message: 'Owner action relayed', data: response?.data ?? response, meta: meta(ctx, capabilities) });
63
+ }
64
+
65
+ return { commandWalletControlState, commandWalletControlRequest, commandWalletControlSubmit };
66
+ }
@@ -44,6 +44,51 @@
44
44
  },
45
45
  "additionalProperties": true
46
46
  }
47
+ },
48
+ "pagination": {
49
+ "type": "object",
50
+ "description": "Present with --search, --limit, --offset or --view (thirdfy-mcp getAgentActions paging).",
51
+ "required": [
52
+ "total",
53
+ "matched",
54
+ "offset",
55
+ "limit",
56
+ "returned",
57
+ "hasMore"
58
+ ],
59
+ "properties": {
60
+ "total": {
61
+ "type": "integer"
62
+ },
63
+ "matched": {
64
+ "type": "integer"
65
+ },
66
+ "offset": {
67
+ "type": "integer"
68
+ },
69
+ "limit": {
70
+ "type": "integer"
71
+ },
72
+ "returned": {
73
+ "type": "integer"
74
+ },
75
+ "hasMore": {
76
+ "type": "boolean"
77
+ }
78
+ },
79
+ "additionalProperties": true
80
+ },
81
+ "view": {
82
+ "enum": [
83
+ "concise",
84
+ "detailed"
85
+ ]
86
+ },
87
+ "hint": {
88
+ "type": "string"
89
+ },
90
+ "providerHint": {
91
+ "type": "object"
47
92
  }
48
93
  },
49
94
  "additionalProperties": true