@palmyr/cli 1.8.2 → 1.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -209,26 +209,42 @@ function subcommandHelp(command, subcommand, options) {
209
209
  }
210
210
  console.log();
211
211
  }
212
+ /**
213
+ * Loud, single-shot warning printed when `--session-only` was chosen. Goes to
214
+ * stderr so JSON on stdout stays clean. Caller supplies the write fn so we
215
+ * route through the right stream in agent vs TTY mode.
216
+ *
217
+ * Why this is here: 1.8.2 and earlier defaulted to session-only without
218
+ * warning. A user lost three wallets to a routine keyring change on a
219
+ * headless box because the JSON file alone is mathematically useless without
220
+ * the keychain secret. 1.8.3 makes the choice explicit; this warning is the
221
+ * reminder for anyone who picks the foot-gun anyway.
222
+ */
223
+ function emitSessionOnlyWarning(write) {
224
+ write(`\n ${t.warn}⚠ session-only wallet — NOT recoverable from the JSON file alone.${t.reset}\n`);
225
+ write(` Reboot, OS-keychain password change, or host copy permanently breaks decryption.\n`);
226
+ write(` Back up the mnemonic externally, or run \`palmyr wallet rekey <id> --passphrase <p>\` later.\n\n`);
227
+ }
212
228
  // ─── Subcommand help definitions ───
213
229
  const WALLET_HELP = {
214
230
  create: [
215
231
  { flag: '--name <name>', desc: 'Wallet name', hint: 'default: "My Wallet"' },
216
- { flag: '--managed', desc: 'Create managed wallet with human oversight via passkey (single-create only)' },
217
232
  { flag: '--solana', desc: 'Materialize the Solana account only', hint: 'default: both chains' },
218
233
  { flag: '--base', desc: 'Materialize the Base/EVM account only', hint: 'pair with --solana for both (default)' },
219
234
  { flag: '--tag <name>', desc: 'Folder-like grouping tag', hint: 'e.g. palmyr-demo — required with --count' },
220
- { flag: '--count <N>', desc: 'Bulk-create N wallets in one call (1-500)', hint: 'unmanaged only; requires --tag' },
235
+ { flag: '--count <N>', desc: 'Bulk-create N wallets in one call (1-500)', hint: 'requires --tag' },
221
236
  { flag: '--name-prefix <p>', desc: 'Bulk name prefix; suffixed `-001..-N`', hint: 'default: same as --tag' },
222
- { flag: '--passphrase <p>', desc: 'Also seal the mnemonic with this passphrase (≥8 chars) for durable recovery', hint: 'or PALMYR_WALLET_PASSPHRASE env (env preferred — keeps phrase out of shell history)' },
237
+ { flag: '--passphrase <p>', desc: 'Seal the mnemonic with this passphrase (≥8 chars) for durable recovery across reboot / OS-keychain loss / host migration', hint: 'or PALMYR_WALLET_PASSPHRASE env (env preferred — keeps phrase out of shell history). Interactive prompt on TTY when neither set.' },
238
+ { flag: '--session-only', desc: 'OPT OUT of the passphrase fallback. Wallet is bound to this machine\'s OS keychain — dies on reboot/keyring loss/migration.', hint: 'use only for ephemeral / throwaway wallets where loss is acceptable' },
223
239
  ],
224
240
  import: [
225
241
  { flag: '--mnemonic <words>', desc: 'BIP-39 mnemonic phrase (required)' },
226
242
  { flag: '--name <name>', desc: 'Wallet name', hint: 'default: "Imported Wallet"' },
227
- { flag: '--managed', desc: 'Import as managed wallet' },
228
243
  { flag: '--solana', desc: 'Materialize the Solana account only' },
229
244
  { flag: '--base', desc: 'Materialize the Base/EVM account only' },
230
245
  { flag: '--tag <name>', desc: 'Assign a tag at import time' },
231
- { flag: '--passphrase <p>', desc: 'Also seal the mnemonic with this passphrase (≥8 chars) for durable recovery', hint: 'or PALMYR_WALLET_PASSPHRASE env (env preferred)' },
246
+ { flag: '--passphrase <p>', desc: 'Seal the mnemonic with this passphrase (≥8 chars) for durable recovery', hint: 'or PALMYR_WALLET_PASSPHRASE env (env preferred). Interactive prompt on TTY when neither set.' },
247
+ { flag: '--session-only', desc: 'OPT OUT of the passphrase fallback. Wallet is bound to this machine\'s OS keychain.', hint: 'use only for ephemeral / throwaway wallets' },
232
248
  ],
233
249
  rekey: [
234
250
  { flag: '<WALLET_ID>', desc: 'Wallet ID or name (positional or --id)' },
@@ -431,6 +447,15 @@ const PHONE_HELP = {
431
447
  { flag: '(price)', desc: '$0.02 per call' },
432
448
  { flag: '(example)', desc: 'palmyr phone messages --id PN_abc' },
433
449
  ],
450
+ message: [
451
+ { flag: '--id <MESSAGE_ID>', desc: 'SMS message id (Telnyx-supplied; positional also accepted)' },
452
+ { flag: '(price)', desc: '$0.005 per readback — cheap so agents can poll until delivery_status is terminal' },
453
+ { flag: '(example)', desc: 'palmyr phone message <message-id-from-sms-response>' },
454
+ ],
455
+ 'sms-status': [
456
+ { flag: '<message-id>', desc: 'Alias for `palmyr phone message <id>` — readback by id' },
457
+ { flag: '(price)', desc: '$0.005 per readback' },
458
+ ],
434
459
  calls: [
435
460
  { flag: '--id <PHONE_ID>', desc: 'Phone number id to list calls for (required; positional also accepted)' },
436
461
  { flag: '(price)', desc: '$0.02 per call' },
@@ -509,7 +534,7 @@ const EMAIL_HELP = {
509
534
  create: [
510
535
  { flag: '--name <name>', desc: 'Inbox name (required)' },
511
536
  { flag: '--domain <domain>', desc: 'Wallet-owned domain to host the inbox on (optional)', hint: 'default: Palmyr-hosted domain' },
512
- { flag: '--wallet <id|name>', desc: 'Wallet to own the inbox (optional)' },
537
+ { flag: '--wallet <id|name|sol_pubkey>', desc: 'Inbox owner — vault id/name (resolves to its Solana address) or a raw Solana pubkey. Omit to use the paying wallet.', hint: 'E2E encryption is Ed25519, so the owner must always be a Solana address — Base addresses cannot own an inbox' },
513
538
  { flag: '(price)', desc: '$2.00 per inbox provisioned' },
514
539
  { flag: '(example)', desc: 'palmyr email create --name agent --domain example.com' },
515
540
  ],
@@ -715,6 +740,283 @@ const CHAT_HELP = {
715
740
  { flag: '(example)', desc: 'palmyr chat providers --capability web_search' },
716
741
  ],
717
742
  };
743
+ // Help tables for the two social subsystems. Their presence is what gates
744
+ // `--help` from dispatching paid actions — see the `case 'twitter'` and
745
+ // `case 'tiktok'` blocks below. 1.8.3 had no entries here and the
746
+ // `case 'buy'` arm immediately called the $5 paid endpoint when a user
747
+ // (reasonably) ran `palmyr twitter buy --help`. Every entry below MUST
748
+ // flag the price for paid subcommands so future readers can scan it.
749
+ const TWITTER_HELP = {
750
+ import: [
751
+ { flag: '<username>', desc: 'Twitter handle to import' },
752
+ { flag: '--credentials-line "..."', desc: 'login:password:email:email_pw:totp_seed:ct0:auth_token format' },
753
+ { flag: '--username --password ...', desc: 'Alternative: individual flags for each field' },
754
+ { flag: '(price)', desc: 'Free — local vault only' },
755
+ ],
756
+ list: [
757
+ { flag: '--local', desc: 'Skip server check; show only locally-vaulted accounts' },
758
+ { flag: '(price)', desc: 'Free local listing + paid lookups when --local is omitted (~$0.001 to enumerate server-side access)' },
759
+ ],
760
+ info: [
761
+ { flag: '<username>', desc: 'Account to inspect' },
762
+ { flag: '(price)', desc: 'Free — local vault read' },
763
+ ],
764
+ rename: [
765
+ { flag: '<old>', desc: 'Current local handle' },
766
+ { flag: '--to <new>', desc: 'New handle (after a real-server rename)' },
767
+ { flag: '(price)', desc: 'Free — local-only metadata update' },
768
+ ],
769
+ remove: [
770
+ { flag: '<username>', desc: 'Account to remove' },
771
+ { flag: '--confirm', desc: 'Required — local delete is irreversible' },
772
+ { flag: '(price)', desc: 'Free — local vault only' },
773
+ ],
774
+ totp: [
775
+ { flag: '<username>', desc: 'Account whose current TOTP code to print' },
776
+ { flag: '(price)', desc: 'Free — local TOTP generation' },
777
+ ],
778
+ buy: [
779
+ { flag: '(no args)', desc: 'Purchase the oldest ready X account from the supplier pool' },
780
+ { flag: '(price)', desc: '$5 USDC — paid via x402. Account auto-imported into the local vault and session primed.' },
781
+ { flag: '(example)', desc: 'palmyr twitter buy' },
782
+ ],
783
+ login: [
784
+ { flag: '<username>', desc: 'Force a fresh server-side session (browser runtime)' },
785
+ { flag: '(price)', desc: '$0.005 USDC' },
786
+ ],
787
+ 'manual-login': [
788
+ { flag: '<username>', desc: 'Open a remote browser session you sign in to manually' },
789
+ { flag: '(price)', desc: 'Variable — server-side browser session cost' },
790
+ ],
791
+ session: [
792
+ { flag: '<username>', desc: 'Inspect cached server-side session status' },
793
+ { flag: '(price)', desc: 'Free' },
794
+ ],
795
+ post: [
796
+ { flag: '<username>', desc: 'Account to post from' },
797
+ { flag: '--body "..."', desc: 'Tweet body (required)' },
798
+ { flag: '(price)', desc: '$0.001 USDC per post' },
799
+ ],
800
+ reply: [
801
+ { flag: '<username>', desc: 'Account to reply from' },
802
+ { flag: '--to <url>', desc: 'Tweet URL to reply to' },
803
+ { flag: '--body "..."', desc: 'Reply body' },
804
+ { flag: '(price)', desc: '$0.001 USDC' },
805
+ ],
806
+ like: [
807
+ { flag: '<username>', desc: 'Account doing the like' },
808
+ { flag: '--tweet <url>', desc: 'Tweet to like' },
809
+ { flag: '(price)', desc: '$0.001 USDC' },
810
+ ],
811
+ retweet: [
812
+ { flag: '<username>', desc: 'Account doing the retweet' },
813
+ { flag: '--tweet <url>', desc: 'Tweet to retweet' },
814
+ { flag: '(price)', desc: '$0.001 USDC' },
815
+ ],
816
+ follow: [
817
+ { flag: '<username>', desc: 'Account doing the follow' },
818
+ { flag: '--user @handle', desc: 'User to follow' },
819
+ { flag: '(price)', desc: '$0.001 USDC' },
820
+ ],
821
+ unfollow: [
822
+ { flag: '<username>', desc: 'Account doing the unfollow' },
823
+ { flag: '--user @handle', desc: 'User to unfollow' },
824
+ { flag: '(price)', desc: '$0.001 USDC' },
825
+ ],
826
+ delete: [
827
+ { flag: '<username>', desc: 'Account that posted the tweet' },
828
+ { flag: '--tweet <url>', desc: 'Tweet to delete' },
829
+ { flag: '(price)', desc: '$0.001 USDC' },
830
+ ],
831
+ 'list-tweets': [
832
+ { flag: '<username>', desc: 'Account whose timeline to fetch' },
833
+ { flag: '(price)', desc: '$0.001 USDC' },
834
+ ],
835
+ bio: [
836
+ { flag: '<username>', desc: 'Account whose bio to update' },
837
+ { flag: '--text "..."', desc: 'New bio (<=160 chars)' },
838
+ { flag: '(price)', desc: '$0.001 USDC' },
839
+ ],
840
+ name: [
841
+ { flag: '<username>', desc: 'Account whose display name to update' },
842
+ { flag: '--display "..."', desc: 'New display name' },
843
+ { flag: '(price)', desc: '$0.001 USDC' },
844
+ ],
845
+ location: [
846
+ { flag: '<username>', desc: 'Account whose location to update' },
847
+ { flag: '--location "..."', desc: 'New location string' },
848
+ { flag: '(price)', desc: '$0.001 USDC' },
849
+ ],
850
+ website: [
851
+ { flag: '<username>', desc: 'Account whose profile website to update' },
852
+ { flag: '--url https://...', desc: 'New website URL' },
853
+ { flag: '(price)', desc: '$0.001 USDC' },
854
+ ],
855
+ pfp: [
856
+ { flag: '<username>', desc: 'Account whose avatar to update' },
857
+ { flag: '--file pic.png', desc: 'Image file (jpeg / png)' },
858
+ { flag: '(price)', desc: '$0.005 USDC' },
859
+ ],
860
+ banner: [
861
+ { flag: '<username>', desc: 'Account whose banner to update' },
862
+ { flag: '--file banner.png', desc: 'Image file' },
863
+ { flag: '(price)', desc: '$0.005 USDC' },
864
+ ],
865
+ username: [
866
+ { flag: '<username>', desc: 'Current account handle' },
867
+ { flag: '--to <new>', desc: 'New handle' },
868
+ { flag: '(price)', desc: '$0.005 USDC' },
869
+ ],
870
+ transfer: [
871
+ { flag: '<username>', desc: 'Account to transfer' },
872
+ { flag: '--to <wallet>', desc: 'Destination wallet address' },
873
+ { flag: '--confirm', desc: 'Required — rotates password, revokes other sessions' },
874
+ { flag: '(price)', desc: 'Free for vaulted accounts; ~$0.01 USDC to auto-register an imported-only account first' },
875
+ ],
876
+ share: [
877
+ { flag: '<username>', desc: 'Account to share' },
878
+ { flag: '--with <wallet>', desc: 'Wallet to grant access to' },
879
+ { flag: '(price)', desc: 'Free for shared access (no password rotation)' },
880
+ ],
881
+ unshare: [
882
+ { flag: '<username>', desc: 'Account to revoke share on' },
883
+ { flag: '--from <wallet>', desc: 'Wallet to revoke' },
884
+ { flag: '--rotate', desc: 'Also rotate password so cached cookies stop working' },
885
+ { flag: '(price)', desc: 'Free; --rotate runs async like transfer (~30-90s)' },
886
+ ],
887
+ claim: [
888
+ { flag: '(no args)', desc: 'Pull every server-side X account the wallet can access into the local vault' },
889
+ { flag: '(price)', desc: '~$0.001 USDC per account claimed (creds-decryption fee)' },
890
+ ],
891
+ // Server-backed account registration. Without these entries, `palmyr twitter
892
+ // register --help` (and friends) fell through to the top-level menu instead
893
+ // of explaining their flags. Subcommands listed in the parent switch must
894
+ // always have an entry here so the help guard fires before the case body.
895
+ thread: [
896
+ { flag: '<username>', desc: 'Account to post the thread from' },
897
+ { flag: '--texts \'["...","..."]\'', desc: 'JSON array of tweets (in order)' },
898
+ { flag: '--file path.json', desc: 'Alternative: read the JSON array from a file' },
899
+ { flag: '(price)', desc: '$0.005 USDC' },
900
+ ],
901
+ register: [
902
+ { flag: '<username>', desc: 'Account handle' },
903
+ { flag: '--password <pw>', desc: 'Required if the account is not already in the local vault' },
904
+ { flag: '--login --email --totp-seed --auth-token --ct0', desc: 'Optional; auto-pulled from local vault when not passed' },
905
+ { flag: '--country <CC>', desc: 'Optional residency hint stored alongside the encrypted credentials' },
906
+ { flag: '(price)', desc: 'Free — server tests login + encrypts creds at rest. Enables scheduling.' },
907
+ ],
908
+ unregister: [
909
+ { flag: '<username-or-id>', desc: 'Handle or 32-char hex account id' },
910
+ { flag: '(price)', desc: 'Free — wipes server-side credentials, account no longer schedulable' },
911
+ ],
912
+ registered: [
913
+ { flag: '(no args)', desc: 'List every server-registered X account this wallet owns' },
914
+ { flag: '(price)', desc: 'Free' },
915
+ ],
916
+ schedule: [
917
+ { flag: '<username>', desc: 'Account to post from (must be `register`-ed)' },
918
+ { flag: '--at "ISO8601"', desc: 'When to fire (e.g. --at "2026-05-15T14:00:00Z")' },
919
+ { flag: '--body "..."', desc: 'Text-only post' },
920
+ { flag: '--texts \'["..."]\' / --file path.json', desc: 'Thread' },
921
+ { flag: '--image / --video / --media-json', desc: 'Media attachments' },
922
+ { flag: '--community <id>', desc: 'Post into an X Community' },
923
+ { flag: '(price)', desc: '$0.001 USDC (text) or $0.005 USDC (thread / media) — paid up front' },
924
+ ],
925
+ queue: [
926
+ { flag: '--status pending|in_progress|completed|failed|cancelled', desc: 'Filter by status' },
927
+ { flag: '--from / --to "ISO8601"', desc: 'Filter by post-at window' },
928
+ { flag: '--account-id <id>', desc: 'Filter to one account' },
929
+ { flag: '--limit <n>', desc: 'Cap result count' },
930
+ { flag: '(price)', desc: 'Free' },
931
+ ],
932
+ cancel: [
933
+ { flag: '<schedule-id>', desc: 'Id from `palmyr twitter queue`' },
934
+ { flag: '(price)', desc: 'Free — only cancels pending posts; in-flight ones are already settled' },
935
+ ],
936
+ status: [
937
+ { flag: '<username>', desc: 'Account to inspect' },
938
+ { flag: '(price)', desc: 'Server-side liveness/shadow-ban check (not yet wired — see `palmyr twitter session` for cached login state)' },
939
+ ],
940
+ 'pool-add': [
941
+ { flag: '--credentials-line "..."', desc: 'Single account creds (login:pw:email:email_pw[:2fa[:ct0:auth_token]])' },
942
+ { flag: '--file path.txt', desc: 'Bulk: one credentials-line per row (# = comment)' },
943
+ { flag: '--price <USDC>', desc: 'Required — what `twitter buy` will charge per account' },
944
+ { flag: '--country <CC>', desc: 'Optional metadata' },
945
+ { flag: '--age 1y|2y|3y|...', desc: 'Optional age category metadata' },
946
+ { flag: '(auth)', desc: 'Admin-signed call — requires PALMYR_ADMIN_KEY' },
947
+ { flag: '(price)', desc: 'Free — server-side seeding by pool operator' },
948
+ ],
949
+ 'pool-status': [
950
+ { flag: '(no args)', desc: 'Available / sold / reserved counts in the X account pool' },
951
+ { flag: '(auth)', desc: 'Admin-signed call — requires PALMYR_ADMIN_KEY' },
952
+ { flag: '(price)', desc: 'Free' },
953
+ ],
954
+ };
955
+ const TIKTOK_HELP = {
956
+ import: [
957
+ { flag: '<username>', desc: 'TikTok handle to import' },
958
+ { flag: '--sessionid <s> --csrf <c> --webid <w>', desc: 'Cookies from a logged-in TikTok browser' },
959
+ { flag: '--credentials-line "..."', desc: 'Marketplace login:pw:email:email_pw format' },
960
+ { flag: '(price)', desc: 'Free — local vault only' },
961
+ ],
962
+ list: [
963
+ { flag: '(no args)', desc: 'List all local TikTok accounts' },
964
+ { flag: '(price)', desc: 'Free' },
965
+ ],
966
+ info: [{ flag: '<username>', desc: 'Show one account' }, { flag: '(price)', desc: 'Free' }],
967
+ rename: [
968
+ { flag: '<old>', desc: 'Current local handle' },
969
+ { flag: '--to <new>', desc: 'New handle' },
970
+ { flag: '(price)', desc: 'Free — local-only metadata update' },
971
+ ],
972
+ remove: [
973
+ { flag: '<username>', desc: 'Account to delete from local vault' },
974
+ { flag: '--confirm', desc: 'Required' },
975
+ { flag: '(price)', desc: 'Free' },
976
+ ],
977
+ totp: [{ flag: '<username>', desc: 'Print current TOTP code' }, { flag: '(price)', desc: 'Free' }],
978
+ login: [
979
+ { flag: '<username>', desc: 'Validate cookies and cache the session' },
980
+ { flag: '(price)', desc: '$0.005 USDC' },
981
+ ],
982
+ session: [{ flag: '<username>', desc: 'Check cached session' }, { flag: '(price)', desc: 'Free' }],
983
+ post: [
984
+ { flag: '<username>', desc: 'Account to post from' },
985
+ { flag: '--file video.mp4', desc: 'Video file' },
986
+ { flag: '--caption "..."', desc: 'Caption' },
987
+ { flag: '(price)', desc: '$0.001 USDC' },
988
+ ],
989
+ follow: [
990
+ { flag: '<username>', desc: 'Account doing the follow' },
991
+ { flag: '--user @handle', desc: 'User to follow' },
992
+ { flag: '(price)', desc: '$0.001 USDC' },
993
+ ],
994
+ like: [
995
+ { flag: '<username>', desc: 'Account doing the like' },
996
+ { flag: '--video <url>', desc: 'Video URL to like' },
997
+ { flag: '(price)', desc: '$0.001 USDC' },
998
+ ],
999
+ delete: [
1000
+ { flag: '<username>', desc: 'Account that posted the video' },
1001
+ { flag: '--video <url>', desc: 'Video to delete' },
1002
+ { flag: '(price)', desc: '$0.001 USDC' },
1003
+ ],
1004
+ bio: [
1005
+ { flag: '<username>', desc: 'Account whose bio to update' },
1006
+ { flag: '--text "..."', desc: 'New bio (<=80 chars)' },
1007
+ { flag: '(price)', desc: '$0.001 USDC' },
1008
+ ],
1009
+ name: [
1010
+ { flag: '<username>', desc: 'Account whose display name to update' },
1011
+ { flag: '--display "..."', desc: 'New display name (<=30 chars)' },
1012
+ { flag: '(price)', desc: '$0.001 USDC' },
1013
+ ],
1014
+ pfp: [
1015
+ { flag: '<username>', desc: 'Account whose avatar to update' },
1016
+ { flag: '--file pic.png', desc: 'Image file' },
1017
+ { flag: '(price)', desc: '$0.005 USDC' },
1018
+ ],
1019
+ };
718
1020
  /**
719
1021
  * Render a per-command menu (no subcommand given). On a TTY → Ink MenuScreen
720
1022
  * with the Palmyr aesthetic. In agent mode → flat JSON listing the available
@@ -1024,6 +1326,7 @@ async function main() {
1024
1326
  { name: 'release', description: 'Release a phone number', hint: '--id PHONE_ID' },
1025
1327
  { name: 'sms', description: 'Send an SMS', hint: '--id ID --to +1... --body "hi"' },
1026
1328
  { name: 'messages', description: 'Read SMS messages received on a number', hint: '--id PHONE_ID' },
1329
+ { name: 'message', description: 'Get one SMS message by id (incl. delivery status)', hint: '--id MESSAGE_ID' },
1027
1330
  { name: 'call', description: 'Place a voice call', hint: '--id ID --to +1... --tts "hello"' },
1028
1331
  { name: 'calls', description: 'List calls placed/received on a number', hint: '--id PHONE_ID' },
1029
1332
  { name: 'call-info', description: 'Get details on a single call', hint: '--call CALL_CONTROL_ID' },
@@ -1131,6 +1434,18 @@ async function main() {
1131
1434
  const data = await ao.phoneMessages(id);
1132
1435
  return print(data);
1133
1436
  }
1437
+ case 'message':
1438
+ case 'sms-status': {
1439
+ // Per-message readback. Mirrors `phone call-info` for SMS:
1440
+ // the Telnyx webhook updates delivery_status on the row, and this
1441
+ // endpoint serves it back. Useful when the immediate sms response
1442
+ // is 'queued' and the caller wants to confirm delivery.
1443
+ const messageId = flags.id || flags.message || positional[0];
1444
+ if (!messageId)
1445
+ err('Usage: palmyr phone message <message-id>');
1446
+ const data = await ao.phoneMessage(messageId);
1447
+ return print(data);
1448
+ }
1134
1449
  case 'calls': {
1135
1450
  const id = flags.id || positional[0];
1136
1451
  if (!id)
@@ -1269,13 +1584,58 @@ async function main() {
1269
1584
  switch (subcommand) {
1270
1585
  case 'create': {
1271
1586
  const name = flags.name || positional[0];
1272
- const wallet = flags.wallet;
1587
+ const walletInput = flags.wallet;
1273
1588
  const domain = flags.domain;
1274
1589
  if (!name)
1275
1590
  err('--name required (e.g. palmyr email create --name hello [--domain example.com])');
1591
+ // --wallet accepts three forms: vault id, vault name, or a raw
1592
+ // Solana base58 pubkey. The server only accepts a Solana pubkey
1593
+ // (E2E encryption is Ed25519→X25519), so resolve id/name to a
1594
+ // pubkey here before the request. Raw pubkeys pass through.
1595
+ // Doing this resolution client-side also means a Base-paying user
1596
+ // doesn't need a Solana wallet — their vault already has one
1597
+ // (single mnemonic, both chains), and we can find it without
1598
+ // making the server reach back for client-side keys.
1599
+ let walletAddress;
1600
+ if (walletInput) {
1601
+ const looksLikeSolPubkey = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(walletInput);
1602
+ if (looksLikeSolPubkey) {
1603
+ walletAddress = walletInput;
1604
+ }
1605
+ else {
1606
+ const { listVaultWallets } = await import('./vault.js');
1607
+ const wallets = listVaultWallets();
1608
+ const match = wallets.find(w => w.id === walletInput || w.name === walletInput);
1609
+ if (!match)
1610
+ err(`--wallet "${walletInput}" did not match any vault id, name, or look like a Solana pubkey`);
1611
+ if (!match.solanaAddress)
1612
+ err(`Wallet "${walletInput}" has no Solana address — email inboxes require one (E2E uses Ed25519). Re-create with: palmyr wallet create`);
1613
+ walletAddress = match.solanaAddress;
1614
+ }
1615
+ }
1616
+ else {
1617
+ // No --wallet: the server would normally default the inbox owner
1618
+ // to the x402 payer. That works for Solana-paid calls but
1619
+ // 400s on Base because the payer is an EVM address. Auto-fill
1620
+ // the *paying wallet's* Solana address here so a single
1621
+ // mnemonic-derived vault wallet works on either pay chain.
1622
+ const cfg = loadConfig();
1623
+ const payChain = (cfg.defaultPayChain || 'solana');
1624
+ if (payChain === 'base') {
1625
+ const { listVaultWallets } = await import('./vault.js');
1626
+ const wallets = listVaultWallets();
1627
+ const targetId = cfg.defaultPayWalletId || process.env.PALMYR_PAY_WALLET;
1628
+ const paying = (targetId && wallets.find(w => w.id === targetId)) || wallets.find(w => w.evmAddress && w.solanaAddress);
1629
+ if (paying?.solanaAddress)
1630
+ walletAddress = paying.solanaAddress;
1631
+ // If no Solana address is reachable, fall through and let the
1632
+ // server return its actionable 400 — silent failure would be
1633
+ // worse than a clear error.
1634
+ }
1635
+ }
1276
1636
  const spin = new Spinner();
1277
1637
  spin.start('Creating inbox...');
1278
- const data = await ao.emailCreate(name, wallet, domain);
1638
+ const data = await ao.emailCreate(name, walletAddress, domain);
1279
1639
  spin.stop('Inbox created', true);
1280
1640
  return print(data);
1281
1641
  }
@@ -1571,6 +1931,26 @@ async function main() {
1571
1931
  const localKeyPath = generatedKeyMeta?.privateKeyPath
1572
1932
  || (pubkeyFile ? pubkeyFile.replace(/\.pub$/, '').replace('~', homedir()) : undefined)
1573
1933
  || (explicitKeyPath ? explicitKeyPath.replace('~', homedir()) : undefined);
1934
+ // --ssh-key <id> uploads a server-side key but doesn't tell us where
1935
+ // the matching private key lives on this machine. Without
1936
+ // --key-path, the SSH readiness gate later silently skips and the
1937
+ // deploy reports `ssh: skipped` while still marking the server as
1938
+ // "ready" — a real foot-gun (dogfood report 2026-05-25 hit this
1939
+ // exact path). Be loud about it now, before anyone pays.
1940
+ if (sshKeyIds && !localKeyPath) {
1941
+ const msg = 'Warning: --ssh-key <id> uploaded the public key server-side, ' +
1942
+ 'but no matching private key was passed to this CLI. ' +
1943
+ 'SSH readiness cannot be verified locally — `compute wait` and the ' +
1944
+ 'inline --wait check will skip the SSH gate. Pass ' +
1945
+ '`--key-path /path/to/private_key` (or `--private-key`) to ' +
1946
+ 'enable the verification.';
1947
+ if (AGENT_MODE) {
1948
+ process.stderr.write(JSON.stringify({ event: 'warning', code: 'ssh_key_no_local_key', message: msg }) + '\n');
1949
+ }
1950
+ else {
1951
+ process.stderr.write(`\n${msg}\n\n`);
1952
+ }
1953
+ }
1574
1954
  // Progress events to stderr — default ON in agent mode so a
1575
1955
  // long deploy isn't a 10-minute silence. Pass --no-progress to
1576
1956
  // opt out. Stdout still gets one final JSON object, so jq
@@ -2151,7 +2531,7 @@ async function main() {
2151
2531
  subtitle: 'Non-custodial HD wallet',
2152
2532
  footerLeft: 'Solana + Base wallet operations',
2153
2533
  commands: [
2154
- { name: 'create', description: 'Create one or many wallets', hint: '[--tag X --count 100] [--solana|--base] [--managed]' },
2534
+ { name: 'create', description: 'Create one or many wallets', hint: '[--tag X --count 100] [--solana|--base]' },
2155
2535
  { name: 'import', description: 'Import from mnemonic', hint: '--mnemonic "..." [--tag X]' },
2156
2536
  { name: 'list', description: 'List all wallets', hint: '[--tag <name>]' },
2157
2537
  { name: 'info', description: 'Wallet details', hint: 'WALLET_ID' },
@@ -2165,7 +2545,6 @@ async function main() {
2165
2545
  { name: 'api-key', description: 'Create agent API key', hint: 'WALLET_ID --name my-agent' },
2166
2546
  { name: 'config', description: 'Get agent config', hint: 'WALLET_ID' },
2167
2547
  { name: 'use', description: 'Set default pay wallet', hint: 'WALLET_ID' },
2168
- { name: 'request-approval', description: 'Request human approval (managed)', hint: 'WALLET_ID --action limits --daily 100' },
2169
2548
  { name: 'buy', description: 'Open a trading position', hint: 'solana <CA> --amount 0.5sol --thesis "..."' },
2170
2549
  { name: 'cohort', description: 'Split a buy across N derived wallets with jitter (Phase 4c)', hint: 'buy <CHAIN> <CA> --total ... --split N' },
2171
2550
  { name: 'template', description: 'Manage YAML strategy templates', hint: 'list | show <name> | path <name> | delete <name>' },
@@ -2210,10 +2589,37 @@ async function main() {
2210
2589
  const chains = (wantSol && !wantBase) ? ['solana']
2211
2590
  : (wantBase && !wantSol) ? ['base']
2212
2591
  : ['solana', 'base'];
2213
- // Optional passphrase fallback seals the mnemonic with scrypt so
2214
- // PALMYR_WALLET_PASSPHRASE can decrypt on a different machine / user /
2215
- // headless box where the OS credential-store secret isn't reachable.
2216
- const passphrase = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE || undefined;
2592
+ // Passphrase resolutionrecoverable-by-default.
2593
+ // Three paths:
2594
+ // 1. `--passphrase <p>` or `PALMYR_WALLET_PASSPHRASE` env seal with scrypt
2595
+ // 2. `--session-only` explicit opt-out, OS-keychain-only (warned)
2596
+ // 3. nothing + TTY → interactive prompt
2597
+ // 4. nothing + non-TTY → error with the three options
2598
+ // Session-only wallets are recoverable ONLY from this machine's OS
2599
+ // keychain; reboot / keyring change / host migration breaks them
2600
+ // permanently. We pushed agents into that foot-gun in 1.8.2 and
2601
+ // earlier; 1.8.3 forces the choice up front.
2602
+ const sessionOnly = !!flags['session-only'];
2603
+ let passphrase = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE || undefined;
2604
+ if (passphrase && sessionOnly) {
2605
+ err('Pass either --passphrase / PALMYR_WALLET_PASSPHRASE OR --session-only, not both.', EXIT.BAD_INPUT);
2606
+ }
2607
+ if (!passphrase && !sessionOnly) {
2608
+ if (process.stdin.isTTY) {
2609
+ const { promptNewPassphrase } = await import('./passphrase-prompt.js');
2610
+ if (!AGENT_MODE)
2611
+ process.stderr.write('\n Wallet creation needs a passphrase fallback so the wallet survives a reboot / OS-keychain change / host migration.\n' +
2612
+ ' (Re-run with --session-only to opt out — ephemeral wallets only.)\n\n');
2613
+ passphrase = await promptNewPassphrase('vault wallet');
2614
+ }
2615
+ else {
2616
+ err('Wallet creation requires a recoverable passphrase fallback OR an explicit opt-out:\n\n' +
2617
+ ' PALMYR_WALLET_PASSPHRASE="<phrase>" palmyr wallet create [...] # recommended (env keeps phrase out of shell history)\n' +
2618
+ ' palmyr wallet create --passphrase "<phrase>" [...] # equivalent\n' +
2619
+ ' palmyr wallet create --session-only [...] # OPT OUT — wallet dies with this machine\'s OS keychain\n\n' +
2620
+ 'Session-only wallets are NOT recoverable from the JSON file alone — reboot, keyring change, or host copy renders them unusable.', EXIT.BAD_INPUT);
2621
+ }
2622
+ }
2217
2623
  // ─── Bulk path ───
2218
2624
  if (count > 1) {
2219
2625
  if (isManaged)
@@ -2227,17 +2633,36 @@ async function main() {
2227
2633
  const { storeSecretsBatch } = await import('./credential-store.js');
2228
2634
  // Progress to stderr so JSON on stdout stays clean
2229
2635
  if (!AGENT_MODE)
2230
- process.stderr.write(`creating ${count} wallets under tag "${tagRaw}"${passphrase ? ' (+ passphrase fallback)' : ''}...\n`);
2636
+ process.stderr.write(`creating ${count} wallets under tag "${tagRaw}"${passphrase ? ' (+ passphrase fallback)' : ' (session-only)'}...\n`);
2231
2637
  const results = createLocalWalletsBatch(prefix, count, 'unmanaged', { tag: tagRaw, chains, passphrase });
2232
2638
  if (!AGENT_MODE)
2233
2639
  process.stderr.write(`sealing ${count} session secrets in OS credential store...\n`);
2234
- storeSecretsBatch(results.map(r => ({ account: r.id, secret: r.sessionSecret })));
2235
- log(`wallet create: ${count} wallets under tag "${tagRaw}" (chains=${chains.join(',')}${passphrase ? ', passphrase=set' : ''})`);
2640
+ // Keychain failure is non-fatal IFF a passphrase fallback was
2641
+ // written the wallets are still recoverable via the env var.
2642
+ let keychainStoreWarning = null;
2643
+ try {
2644
+ storeSecretsBatch(results.map(r => ({ account: r.id, secret: r.sessionSecret })));
2645
+ }
2646
+ catch (e) {
2647
+ if (passphrase) {
2648
+ keychainStoreWarning = e?.message || 'keychain store failed';
2649
+ if (!AGENT_MODE)
2650
+ process.stderr.write(` warning: OS keychain unavailable (${keychainStoreWarning}); wallets remain decryptable via PALMYR_WALLET_PASSPHRASE\n`);
2651
+ }
2652
+ else {
2653
+ throw e;
2654
+ }
2655
+ }
2656
+ if (sessionOnly && !AGENT_MODE)
2657
+ emitSessionOnlyWarning(process.stderr.write.bind(process.stderr));
2658
+ log(`wallet create: ${count} wallets under tag "${tagRaw}" (chains=${chains.join(',')}, mode=${passphrase ? 'passphrase' : 'session-only'}${keychainStoreWarning ? ', keychain=failed' : ''})`);
2236
2659
  if (AGENT_MODE) {
2237
2660
  print({
2238
2661
  count: results.length,
2239
2662
  tag: tagRaw,
2240
2663
  chains,
2664
+ recoverable: !!passphrase,
2665
+ ...(keychainStoreWarning ? { keychainWarning: keychainStoreWarning } : {}),
2241
2666
  wallets: results.map(r => ({
2242
2667
  id: r.id,
2243
2668
  name: r.name,
@@ -2253,6 +2678,7 @@ async function main() {
2253
2678
  console.log(`\n ${t.success}✔${t.reset} Created ${count} wallets under tag ${t.accent}${tagRaw}${t.reset}`);
2254
2679
  console.log(` ${t.muted}chains:${t.reset} ${chains.join(', ')}`);
2255
2680
  console.log(` ${t.muted}names: ${t.reset}${results[0].name} … ${results[results.length - 1].name}`);
2681
+ console.log(` ${t.muted}recoverable:${t.reset} ${passphrase ? 'yes (passphrase fallback set)' : 'NO — session-only'}`);
2256
2682
  console.log(`\n ${t.muted}List them: ${t.reset}palmyr wallet list --tag ${tagRaw}`);
2257
2683
  console.log(` ${t.muted}Delete all: ${t.reset}palmyr wallet tag-delete ${tagRaw} --confirm\n`);
2258
2684
  }
@@ -2265,10 +2691,26 @@ async function main() {
2265
2691
  // Create locally — no server needed for the key material
2266
2692
  const { createLocalWallet } = await import('./vault.js');
2267
2693
  const w = createLocalWallet(name, mode, { tag: tagRaw, chains, passphrase });
2268
- // Store session secret in OS credential store
2694
+ // Store session secret in OS credential store. Keychain failure is
2695
+ // non-fatal when a passphrase fallback was written.
2269
2696
  const { storeSecret } = await import('./credential-store.js');
2270
- storeSecret(w.id, w.sessionSecret);
2271
- log(`wallet create: ${w.id} (${mode}${tagRaw ? `, tag=${tagRaw}` : ''}, chains=${chains.join(',')})`);
2697
+ let keychainStoreWarning = null;
2698
+ try {
2699
+ storeSecret(w.id, w.sessionSecret);
2700
+ }
2701
+ catch (e) {
2702
+ if (passphrase) {
2703
+ keychainStoreWarning = e?.message || 'keychain store failed';
2704
+ if (!AGENT_MODE)
2705
+ process.stderr.write(` warning: OS keychain unavailable (${keychainStoreWarning}); wallet remains decryptable via PALMYR_WALLET_PASSPHRASE\n`);
2706
+ }
2707
+ else {
2708
+ throw e;
2709
+ }
2710
+ }
2711
+ if (sessionOnly && !AGENT_MODE)
2712
+ emitSessionOnlyWarning(process.stderr.write.bind(process.stderr));
2713
+ log(`wallet create: ${w.id} (${mode}${tagRaw ? `, tag=${tagRaw}` : ''}, chains=${chains.join(',')}, mode=${passphrase ? 'passphrase' : 'session-only'}${keychainStoreWarning ? ', keychain=failed' : ''})`);
2272
2714
  // For managed wallets, register metadata with the server to get a setup link
2273
2715
  let setupLink;
2274
2716
  if (isManaged) {
@@ -2312,7 +2754,7 @@ async function main() {
2312
2754
  }
2313
2755
  }
2314
2756
  else {
2315
- print({ ...w, setupLink });
2757
+ print({ ...w, setupLink, recoverable: !!passphrase, ...(keychainStoreWarning ? { keychainWarning: keychainStoreWarning } : {}) });
2316
2758
  }
2317
2759
  break;
2318
2760
  }
@@ -2328,13 +2770,52 @@ async function main() {
2328
2770
  const chains = (wantSol && !wantBase) ? ['solana']
2329
2771
  : (wantBase && !wantSol) ? ['base']
2330
2772
  : ['solana', 'base'];
2331
- const passphrase = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE || undefined;
2773
+ // Same recoverability gate as `create`. Import is even more
2774
+ // commonly run on a "new machine" after losing access on the
2775
+ // original — going session-only here would re-trap the user in
2776
+ // the same hole they're recovering from.
2777
+ const importSessionOnly = !!flags['session-only'];
2778
+ let importPassphrase = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE || undefined;
2779
+ if (importPassphrase && importSessionOnly) {
2780
+ err('Pass either --passphrase / PALMYR_WALLET_PASSPHRASE OR --session-only, not both.', EXIT.BAD_INPUT);
2781
+ }
2782
+ if (!importPassphrase && !importSessionOnly) {
2783
+ if (process.stdin.isTTY) {
2784
+ const { promptNewPassphrase } = await import('./passphrase-prompt.js');
2785
+ if (!AGENT_MODE)
2786
+ process.stderr.write('\n Import needs a passphrase fallback so the wallet survives a reboot / OS-keychain change / host migration.\n' +
2787
+ ' (Re-run with --session-only to opt out — ephemeral wallets only.)\n\n');
2788
+ importPassphrase = await promptNewPassphrase('vault wallet');
2789
+ }
2790
+ else {
2791
+ err('Wallet import requires a recoverable passphrase fallback OR an explicit opt-out:\n\n' +
2792
+ ' PALMYR_WALLET_PASSPHRASE="<phrase>" palmyr wallet import --mnemonic "..." # recommended\n' +
2793
+ ' palmyr wallet import --mnemonic "..." --passphrase "<phrase>" # equivalent\n' +
2794
+ ' palmyr wallet import --mnemonic "..." --session-only # OPT OUT — wallet dies with this machine\'s OS keychain', EXIT.BAD_INPUT);
2795
+ }
2796
+ }
2332
2797
  const { importLocalWallet } = await import('./vault.js');
2333
- const w = importLocalWallet(name, mnemonic, mode, { tag: tagRaw, chains, passphrase });
2334
- // Store session secret
2798
+ const w = importLocalWallet(name, mnemonic, mode, { tag: tagRaw, chains, passphrase: importPassphrase });
2799
+ // Store session secret. Keychain failure is non-fatal when a
2800
+ // passphrase fallback was written.
2335
2801
  const { storeSecret } = await import('./credential-store.js');
2336
- storeSecret(w.id, w.sessionSecret);
2337
- log(`wallet import: ${w.id}${passphrase ? ' (+ passphrase fallback)' : ''}`);
2802
+ let importKeychainWarning = null;
2803
+ try {
2804
+ storeSecret(w.id, w.sessionSecret);
2805
+ }
2806
+ catch (e) {
2807
+ if (importPassphrase) {
2808
+ importKeychainWarning = e?.message || 'keychain store failed';
2809
+ if (!AGENT_MODE)
2810
+ process.stderr.write(` warning: OS keychain unavailable (${importKeychainWarning}); wallet remains decryptable via PALMYR_WALLET_PASSPHRASE\n`);
2811
+ }
2812
+ else {
2813
+ throw e;
2814
+ }
2815
+ }
2816
+ if (importSessionOnly && !AGENT_MODE)
2817
+ emitSessionOnlyWarning(process.stderr.write.bind(process.stderr));
2818
+ log(`wallet import: ${w.id} (mode=${importPassphrase ? 'passphrase' : 'session-only'}${importKeychainWarning ? ', keychain=failed' : ''})`);
2338
2819
  if (!AGENT_MODE) {
2339
2820
  render(React.createElement(WalletCreateScreen, {
2340
2821
  version: VERSION,
@@ -2347,7 +2828,7 @@ async function main() {
2347
2828
  }));
2348
2829
  }
2349
2830
  else {
2350
- print(w);
2831
+ print({ ...w, recoverable: !!importPassphrase, ...(importKeychainWarning ? { keychainWarning: importKeychainWarning } : {}) });
2351
2832
  }
2352
2833
  break;
2353
2834
  }
@@ -2517,9 +2998,13 @@ async function main() {
2517
2998
  const msg = flags.msg || flags.message;
2518
2999
  if (!chain || !msg)
2519
3000
  err('--chain and --msg required');
2520
- // Sign locally — no server needed
3001
+ // Sign locally — no server needed.
3002
+ // Read the same passphrase channel as pay / export / rekey so a
3003
+ // passphrase-backed wallet signs from any machine the env var
3004
+ // reaches (was missing in 1.8.2 — inconsistent with other commands).
3005
+ const signPass = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE || undefined;
2521
3006
  const { signMessageLocal } = await import('./vault.js');
2522
- const data = signMessageLocal(walletId, chain, msg);
3007
+ const data = signMessageLocal(walletId, chain, msg, signPass);
2523
3008
  return print({ success: true, ...data });
2524
3009
  render(React.createElement(SuccessScreen, {
2525
3010
  version: VERSION,
@@ -4854,7 +5339,12 @@ async function main() {
4854
5339
  log(`auto-imported @${username} from server (${sourceLabel} → local vault)`);
4855
5340
  return summary;
4856
5341
  };
4857
- if (!subcommand) {
5342
+ // Help guard. `palmyr twitter buy --help` MUST never dispatch to the
5343
+ // paid `case 'buy'` below — 1.8.3 had no guard here and a real user
5344
+ // got charged $5 for a help command. Falls back to the top-level menu
5345
+ // when the subcommand has no per-subcommand help entry, so even an
5346
+ // unrecognized `palmyr twitter <whatever> --help` is safe to run.
5347
+ if (!subcommand || (flags.help && !TWITTER_HELP[subcommand])) {
4858
5348
  showMenu({
4859
5349
  command: 'twitter',
4860
5350
  title: 'twitter',
@@ -4870,7 +5360,10 @@ async function main() {
4870
5360
  { name: 'buy', description: 'Purchase an aged account (requires server supplier config)', hint: '--age 1y --country US' },
4871
5361
  { name: 'login', description: 'Force a fresh server-side session (requires browser runtime)', hint: '<username>' },
4872
5362
  { name: 'post', description: 'Post a tweet (requires server browser runtime)', hint: '<username> --body "..."' },
4873
- { name: 'status', description: 'Check if the account is alive / shadow-banned', hint: '<username>' },
5363
+ // `status` is not wired yet (Phase 3). Hidden from this menu so
5364
+ // users don't try a command that will only error; `session`
5365
+ // covers the most useful subset (cached server-side login state).
5366
+ { name: 'session', description: 'Inspect cached server-side session for an account', hint: '<username>' },
4874
5367
  { name: 'transfer', description: 'Hand an account to another wallet (rotates password; auto-registers if needed)', hint: '<username> --to <wallet> --confirm' },
4875
5368
  { name: 'share', description: 'Grant another wallet shared access', hint: '<username> --with <wallet>' },
4876
5369
  { name: 'unshare', description: 'Revoke a wallet’s shared access', hint: '<username> --from <wallet> [--rotate]' },
@@ -4880,6 +5373,10 @@ async function main() {
4880
5373
  });
4881
5374
  return;
4882
5375
  }
5376
+ if (flags.help && subcommand && TWITTER_HELP[subcommand]) {
5377
+ subcommandHelp('twitter', subcommand, TWITTER_HELP[subcommand]);
5378
+ return;
5379
+ }
4883
5380
  switch (subcommand) {
4884
5381
  case 'import': {
4885
5382
  // Option 1: --credentials-line "login:password:email:email_pw:2fa:ct0:auth_token"
@@ -5817,14 +6314,22 @@ async function main() {
5817
6314
  return print(data);
5818
6315
  }
5819
6316
  case 'status': {
5820
- err(`twitter status: not wired yet. Phase 3 will add it.`, EXIT.GENERAL);
6317
+ // `twitter status` was meant to check live shadow-ban / suspension
6318
+ // state via a server-side probe. That's a Phase 3 build (needs a
6319
+ // browser runtime on the server to render the profile). Until
6320
+ // then, point users at `session` for the cached login state and
6321
+ // `info` for vault metadata — together they cover the practical
6322
+ // "is this account still usable from the agent's side" question.
6323
+ err(`twitter status: not wired yet (Phase 3). Closest equivalents available today: ` +
6324
+ `\`palmyr twitter session <username>\` (cached server-side login validity) and ` +
6325
+ `\`palmyr twitter info <username>\` (local vault record).`, EXIT.GENERAL);
5821
6326
  }
5822
6327
  case 'transfer': {
5823
6328
  // Hand the X account to another wallet. End-to-end one-command:
5824
6329
  // 1. If the account is only in the local vault, auto-register it
5825
6330
  // with the server (uploads encrypted creds; $0.01 USDC).
5826
6331
  // 2. Server rotates the password and revokes other sessions
5827
- // ($0.0001 USDC ownership proof).
6332
+ // ($0.01 USDC ownership proof).
5828
6333
  // 3. Atomically flips ownership in the DB.
5829
6334
  // Receiver picks up the rotated credentials via `palmyr twitter
5830
6335
  // list` (which now surfaces server-side accounts) and/or `claim`.
@@ -6196,7 +6701,9 @@ async function main() {
6196
6701
  case 'tiktok': {
6197
6702
  const sv = await import('./social-vault.js');
6198
6703
  const platform = 'tiktok';
6199
- if (!subcommand) {
6704
+ // Same help guard as `twitter` — prevents `--help` from dispatching
6705
+ // a paid subcommand. Same bug class lived here too in 1.8.3.
6706
+ if (!subcommand || (flags.help && !TIKTOK_HELP[subcommand])) {
6200
6707
  showMenu({
6201
6708
  command: 'tiktok',
6202
6709
  title: 'tiktok',
@@ -6223,6 +6730,10 @@ async function main() {
6223
6730
  });
6224
6731
  return;
6225
6732
  }
6733
+ if (flags.help && subcommand && TIKTOK_HELP[subcommand]) {
6734
+ subcommandHelp('tiktok', subcommand, TIKTOK_HELP[subcommand]);
6735
+ return;
6736
+ }
6226
6737
  switch (subcommand) {
6227
6738
  case 'import': {
6228
6739
  // Two formats:
@@ -6575,12 +7086,18 @@ async function main() {
6575
7086
  const { join } = await import('path');
6576
7087
  const vaultDir = process.env.PALMYR_WALLET_PATH || join(homedir(), '.palmyr', 'wallet');
6577
7088
  const { isCredentialStoreAvailable } = await import('./credential-store.js');
7089
+ const { hasLegacyKeyfileWallet } = await import('./config.js');
7090
+ // `defaultChain` is legacy keyfile-flow state — only show it when a
7091
+ // keyfile wallet is actually configured. The functional field is
7092
+ // `payChain` (renamed from the misleadingly-similar `defaultPayChain`
7093
+ // disk key), which the x402 pay path reads.
7094
+ const showLegacyChain = hasLegacyKeyfileWallet(cfg);
6578
7095
  const configData = {
6579
7096
  api: cfg.api,
6580
- defaultChain: cfg.defaultChain,
6581
7097
  setupDone: cfg.setupDone,
7098
+ ...(showLegacyChain ? { legacyKeyfileChain: cfg.defaultChain || 'solana' } : {}),
6582
7099
  defaultPayWalletId: cfg.defaultPayWalletId || null,
6583
- defaultPayChain: cfg.defaultPayChain || 'solana',
7100
+ payChain: cfg.defaultPayChain || 'solana',
6584
7101
  vaultPath: vaultDir,
6585
7102
  credentialStore: isCredentialStoreAvailable() ? 'available' : 'unavailable',
6586
7103
  configPath: join(homedir(), '.palmyr', 'config.json'),
@@ -6611,21 +7128,67 @@ async function main() {
6611
7128
  const { listVaultWallets } = await import('./vault.js');
6612
7129
  const wallets = listVaultWallets();
6613
7130
  checks.push({ name: 'Local wallets', status: wallets.length > 0 ? 'pass' : 'warn', detail: `${wallets.length} wallet(s) found` });
6614
- // 4. Session secrets present for wallets
7131
+ // 4. Decryption readiness for each wallet — tri-state.
7132
+ //
7133
+ // pass → wallet has keychain secret, OR has a passphrase fallback
7134
+ // AND PALMYR_WALLET_PASSPHRASE is set
7135
+ // warn → has passphrase fallback but env unset (recoverable, but
7136
+ // the next command will fail until env is set)
7137
+ // fail → session-only AND keychain secret is gone (unrecoverable
7138
+ // from this machine — needs mnemonic re-import or
7139
+ // rekey-on-original)
6615
7140
  const { retrieveSecret } = await import('./credential-store.js');
6616
- let secretsOk = 0, secretsMissing = 0;
7141
+ const { hasPassphraseFallback } = await import('./vault.js');
7142
+ const envSet = !!process.env.PALMYR_WALLET_PASSPHRASE;
7143
+ let keychainOk = 0;
7144
+ let needsEnv = 0;
7145
+ let unrecoverable = [];
6617
7146
  for (const w of wallets) {
6618
- if (retrieveSecret(w.id))
6619
- secretsOk++;
6620
- else
6621
- secretsMissing++;
7147
+ if (retrieveSecret(w.id)) {
7148
+ keychainOk++;
7149
+ continue;
7150
+ }
7151
+ let hasFallback = false;
7152
+ try {
7153
+ hasFallback = hasPassphraseFallback(w.id);
7154
+ }
7155
+ catch { }
7156
+ if (hasFallback) {
7157
+ needsEnv++;
7158
+ }
7159
+ else {
7160
+ unrecoverable.push(w.name || w.id.slice(0, 8));
7161
+ }
6622
7162
  }
6623
7163
  if (wallets.length > 0) {
6624
- checks.push({
6625
- name: 'Session secrets',
6626
- status: secretsMissing === 0 ? 'pass' : 'fail',
6627
- detail: secretsMissing === 0 ? `All ${secretsOk} wallet(s) have secrets stored` : `${secretsMissing} wallet(s) missing session secret`,
6628
- });
7164
+ if (unrecoverable.length > 0) {
7165
+ checks.push({
7166
+ name: 'Wallet decryption',
7167
+ status: 'fail',
7168
+ detail: `${unrecoverable.length} session-only wallet(s) UNRECOVERABLE from this machine — keychain secret missing and no passphrase fallback (${unrecoverable.slice(0, 3).join(', ')}${unrecoverable.length > 3 ? ', …' : ''}). Recover by importing the mnemonic here, or running \`palmyr wallet rekey <id> --passphrase <p>\` on the original machine.`,
7169
+ });
7170
+ }
7171
+ else if (needsEnv > 0 && !envSet) {
7172
+ checks.push({
7173
+ name: 'Wallet decryption',
7174
+ status: 'warn',
7175
+ detail: `${needsEnv} wallet(s) need PALMYR_WALLET_PASSPHRASE to decrypt (keychain secret missing, passphrase fallback present). Set the env var to unblock pay / sign / export.`,
7176
+ });
7177
+ }
7178
+ else if (needsEnv > 0) {
7179
+ checks.push({
7180
+ name: 'Wallet decryption',
7181
+ status: 'pass',
7182
+ detail: `${keychainOk} via OS keychain, ${needsEnv} via PALMYR_WALLET_PASSPHRASE (env set)`,
7183
+ });
7184
+ }
7185
+ else {
7186
+ checks.push({
7187
+ name: 'Wallet decryption',
7188
+ status: 'pass',
7189
+ detail: `All ${keychainOk} wallet(s) have keychain secrets`,
7190
+ });
7191
+ }
6629
7192
  }
6630
7193
  // 5. API connectivity
6631
7194
  try {