@palmyr/cli 1.8.3 → 1.9.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.
package/dist/cli.js CHANGED
@@ -17,6 +17,7 @@ import { render as inkRender } from 'ink';
17
17
  import { ConfigScreen, Dashboard, DoctorScreen, DomainCheckScreen, DomainPricingScreen, ErrorScreen, HealthScreen, MenuScreen, PricingScreen, RecordsScreen, SetupScreen, StatusScreen, SuccessScreen, WalletCreateScreen, WalletStatusScreen, WalletListScreen } from './app.js';
18
18
  import { Palmyr } from './sdk.js';
19
19
  import { loadConfig, saveConfig, ensureDirs, log, addPhone, addDomain, addNote } from './config.js';
20
+ import { getState as getTelemetryState, setEnabled as setTelemetryEnabled, queuedCount as telemetryQueuedCount, appendEventSync as telemetryAppendEvent, flushQueue as telemetryFlushQueue } from './telemetry.js';
20
21
  import { theme as t, icon, Spinner, warn, table, kv, section, setAgentMode as setUiAgentMode } from './ui.js';
21
22
  import { existsSync, readFileSync } from 'fs';
22
23
  import { homedir } from 'os';
@@ -229,11 +230,10 @@ function emitSessionOnlyWarning(write) {
229
230
  const WALLET_HELP = {
230
231
  create: [
231
232
  { flag: '--name <name>', desc: 'Wallet name', hint: 'default: "My Wallet"' },
232
- { flag: '--managed', desc: 'Create managed wallet with human oversight via passkey (single-create only)' },
233
233
  { flag: '--solana', desc: 'Materialize the Solana account only', hint: 'default: both chains' },
234
234
  { flag: '--base', desc: 'Materialize the Base/EVM account only', hint: 'pair with --solana for both (default)' },
235
235
  { flag: '--tag <name>', desc: 'Folder-like grouping tag', hint: 'e.g. palmyr-demo — required with --count' },
236
- { flag: '--count <N>', desc: 'Bulk-create N wallets in one call (1-500)', hint: 'unmanaged only; requires --tag' },
236
+ { flag: '--count <N>', desc: 'Bulk-create N wallets in one call (1-500)', hint: 'requires --tag' },
237
237
  { flag: '--name-prefix <p>', desc: 'Bulk name prefix; suffixed `-001..-N`', hint: 'default: same as --tag' },
238
238
  { 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.' },
239
239
  { 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' },
@@ -241,7 +241,6 @@ const WALLET_HELP = {
241
241
  import: [
242
242
  { flag: '--mnemonic <words>', desc: 'BIP-39 mnemonic phrase (required)' },
243
243
  { flag: '--name <name>', desc: 'Wallet name', hint: 'default: "Imported Wallet"' },
244
- { flag: '--managed', desc: 'Import as managed wallet' },
245
244
  { flag: '--solana', desc: 'Materialize the Solana account only' },
246
245
  { flag: '--base', desc: 'Materialize the Base/EVM account only' },
247
246
  { flag: '--tag <name>', desc: 'Assign a tag at import time' },
@@ -449,6 +448,15 @@ const PHONE_HELP = {
449
448
  { flag: '(price)', desc: '$0.02 per call' },
450
449
  { flag: '(example)', desc: 'palmyr phone messages --id PN_abc' },
451
450
  ],
451
+ message: [
452
+ { flag: '--id <MESSAGE_ID>', desc: 'SMS message id (Telnyx-supplied; positional also accepted)' },
453
+ { flag: '(price)', desc: '$0.005 per readback — cheap so agents can poll until delivery_status is terminal' },
454
+ { flag: '(example)', desc: 'palmyr phone message <message-id-from-sms-response>' },
455
+ ],
456
+ 'sms-status': [
457
+ { flag: '<message-id>', desc: 'Alias for `palmyr phone message <id>` — readback by id' },
458
+ { flag: '(price)', desc: '$0.005 per readback' },
459
+ ],
452
460
  calls: [
453
461
  { flag: '--id <PHONE_ID>', desc: 'Phone number id to list calls for (required; positional also accepted)' },
454
462
  { flag: '(price)', desc: '$0.02 per call' },
@@ -527,7 +535,7 @@ const EMAIL_HELP = {
527
535
  create: [
528
536
  { flag: '--name <name>', desc: 'Inbox name (required)' },
529
537
  { flag: '--domain <domain>', desc: 'Wallet-owned domain to host the inbox on (optional)', hint: 'default: Palmyr-hosted domain' },
530
- { flag: '--wallet <id|name>', desc: 'Wallet to own the inbox (optional)' },
538
+ { 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' },
531
539
  { flag: '(price)', desc: '$2.00 per inbox provisioned' },
532
540
  { flag: '(example)', desc: 'palmyr email create --name agent --domain example.com' },
533
541
  ],
@@ -733,6 +741,337 @@ const CHAT_HELP = {
733
741
  { flag: '(example)', desc: 'palmyr chat providers --capability web_search' },
734
742
  ],
735
743
  };
744
+ // Help tables for the two social subsystems. Their presence is what gates
745
+ // `--help` from dispatching paid actions — see the `case 'twitter'` and
746
+ // `case 'tiktok'` blocks below. 1.8.3 had no entries here and the
747
+ // `case 'buy'` arm immediately called the $5 paid endpoint when a user
748
+ // (reasonably) ran `palmyr twitter buy --help`. Every entry below MUST
749
+ // flag the price for paid subcommands so future readers can scan it.
750
+ const TWITTER_HELP = {
751
+ import: [
752
+ { flag: '<username>', desc: 'Twitter handle to import' },
753
+ { flag: '--credentials-line "..."', desc: 'login:password:email:email_pw:totp_seed:ct0:auth_token format' },
754
+ { flag: '--username --password ...', desc: 'Alternative: individual flags for each field' },
755
+ { flag: '(price)', desc: 'Free — local vault only' },
756
+ ],
757
+ list: [
758
+ { flag: '--local', desc: 'Skip server check; show only locally-vaulted accounts' },
759
+ { flag: '(price)', desc: 'Free local listing + paid lookups when --local is omitted (~$0.001 to enumerate server-side access)' },
760
+ ],
761
+ info: [
762
+ { flag: '<username>', desc: 'Account to inspect' },
763
+ { flag: '(price)', desc: 'Free — local vault read' },
764
+ ],
765
+ rename: [
766
+ { flag: '<old>', desc: 'Current local handle' },
767
+ { flag: '--to <new>', desc: 'New handle (after a real-server rename)' },
768
+ { flag: '(price)', desc: 'Free — local-only metadata update' },
769
+ ],
770
+ remove: [
771
+ { flag: '<username>', desc: 'Account to remove' },
772
+ { flag: '--confirm', desc: 'Required — local delete is irreversible' },
773
+ { flag: '(price)', desc: 'Free — local vault only' },
774
+ ],
775
+ totp: [
776
+ { flag: '<username>', desc: 'Account whose current TOTP code to print' },
777
+ { flag: '(price)', desc: 'Free — local TOTP generation' },
778
+ ],
779
+ buy: [
780
+ { flag: '(no args)', desc: 'Purchase the oldest ready X account from the supplier pool. Default for every filter below is random.' },
781
+ { flag: '--country <CC>', desc: 'Filter to a specific country (ISO alpha-2: US, GB, DE, …). Run `pool-prices` first to see what is priced.' },
782
+ { flag: '--source web|mobile', desc: 'Filter by registration source from X about-profile. Multiplies country price by source multiplier (default 1.0).' },
783
+ { flag: '--max-renames N', desc: 'Cap username-change count. --max-renames 0 = never renamed. NULL on row means unknown → does not match.' },
784
+ { flag: '--age 1y|2y|...', desc: 'Optional age category filter' },
785
+ { flag: '(price)', desc: '$5 USDC default; country_price * source_multiplier when filters are passed.' },
786
+ { flag: '(example)', desc: 'palmyr twitter buy --country US --source web --max-renames 0' },
787
+ ],
788
+ login: [
789
+ { flag: '<username>', desc: 'Force a fresh server-side session (browser runtime)' },
790
+ { flag: '(price)', desc: '$0.005 USDC' },
791
+ ],
792
+ 'manual-login': [
793
+ { flag: '<username>', desc: 'Open a remote browser session you sign in to manually' },
794
+ { flag: '(price)', desc: 'Variable — server-side browser session cost' },
795
+ ],
796
+ session: [
797
+ { flag: '<username>', desc: 'Inspect cached server-side session status' },
798
+ { flag: '(price)', desc: 'Free' },
799
+ ],
800
+ post: [
801
+ { flag: '<username>', desc: 'Account to post from' },
802
+ { flag: '--body "..."', desc: 'Tweet body (required)' },
803
+ { flag: '(price)', desc: '$0.001 USDC per post' },
804
+ ],
805
+ reply: [
806
+ { flag: '<username>', desc: 'Account to reply from' },
807
+ { flag: '--to <url>', desc: 'Tweet URL to reply to' },
808
+ { flag: '--body "..."', desc: 'Reply body' },
809
+ { flag: '(price)', desc: '$0.001 USDC' },
810
+ ],
811
+ like: [
812
+ { flag: '<username>', desc: 'Account doing the like' },
813
+ { flag: '--tweet <url>', desc: 'Tweet to like' },
814
+ { flag: '(price)', desc: '$0.001 USDC' },
815
+ ],
816
+ retweet: [
817
+ { flag: '<username>', desc: 'Account doing the retweet' },
818
+ { flag: '--tweet <url>', desc: 'Tweet to retweet' },
819
+ { flag: '(price)', desc: '$0.001 USDC' },
820
+ ],
821
+ follow: [
822
+ { flag: '<username>', desc: 'Account doing the follow' },
823
+ { flag: '--user @handle', desc: 'User to follow' },
824
+ { flag: '(price)', desc: '$0.001 USDC' },
825
+ ],
826
+ unfollow: [
827
+ { flag: '<username>', desc: 'Account doing the unfollow' },
828
+ { flag: '--user @handle', desc: 'User to unfollow' },
829
+ { flag: '(price)', desc: '$0.001 USDC' },
830
+ ],
831
+ delete: [
832
+ { flag: '<username>', desc: 'Account that posted the tweet' },
833
+ { flag: '--tweet <url>', desc: 'Tweet to delete' },
834
+ { flag: '(price)', desc: '$0.001 USDC' },
835
+ ],
836
+ 'list-tweets': [
837
+ { flag: '<username>', desc: 'Account whose timeline to fetch' },
838
+ { flag: '(price)', desc: '$0.001 USDC' },
839
+ ],
840
+ bio: [
841
+ { flag: '<username>', desc: 'Account whose bio to update' },
842
+ { flag: '--text "..."', desc: 'New bio (<=160 chars)' },
843
+ { flag: '(price)', desc: '$0.001 USDC' },
844
+ ],
845
+ name: [
846
+ { flag: '<username>', desc: 'Account whose display name to update' },
847
+ { flag: '--display "..."', desc: 'New display name' },
848
+ { flag: '(price)', desc: '$0.001 USDC' },
849
+ ],
850
+ location: [
851
+ { flag: '<username>', desc: 'Account whose location to update' },
852
+ { flag: '--location "..."', desc: 'New location string' },
853
+ { flag: '(price)', desc: '$0.001 USDC' },
854
+ ],
855
+ website: [
856
+ { flag: '<username>', desc: 'Account whose profile website to update' },
857
+ { flag: '--url https://...', desc: 'New website URL' },
858
+ { flag: '(price)', desc: '$0.001 USDC' },
859
+ ],
860
+ pfp: [
861
+ { flag: '<username>', desc: 'Account whose avatar to update' },
862
+ { flag: '--file pic.png', desc: 'Image file (jpeg / png)' },
863
+ { flag: '(price)', desc: '$0.005 USDC' },
864
+ ],
865
+ banner: [
866
+ { flag: '<username>', desc: 'Account whose banner to update' },
867
+ { flag: '--file banner.png', desc: 'Image file' },
868
+ { flag: '(price)', desc: '$0.005 USDC' },
869
+ ],
870
+ username: [
871
+ { flag: '<username>', desc: 'Current account handle' },
872
+ { flag: '--to <new>', desc: 'New handle' },
873
+ { flag: '(price)', desc: '$0.005 USDC' },
874
+ ],
875
+ transfer: [
876
+ { flag: '<username>', desc: 'Account to transfer' },
877
+ { flag: '--to <wallet>', desc: 'Destination wallet address' },
878
+ { flag: '--confirm', desc: 'Required — rotates password, revokes other sessions' },
879
+ { flag: '(price)', desc: 'Free for vaulted accounts; ~$0.01 USDC to auto-register an imported-only account first' },
880
+ ],
881
+ share: [
882
+ { flag: '<username>', desc: 'Account to share' },
883
+ { flag: '--with <wallet>', desc: 'Wallet to grant access to' },
884
+ { flag: '(price)', desc: 'Free for shared access (no password rotation)' },
885
+ ],
886
+ unshare: [
887
+ { flag: '<username>', desc: 'Account to revoke share on' },
888
+ { flag: '--from <wallet>', desc: 'Wallet to revoke' },
889
+ { flag: '--rotate', desc: 'Also rotate password so cached cookies stop working' },
890
+ { flag: '(price)', desc: 'Free; --rotate runs async like transfer (~30-90s)' },
891
+ ],
892
+ claim: [
893
+ { flag: '(no args)', desc: 'Pull every server-side X account the wallet can access into the local vault' },
894
+ { flag: '(price)', desc: '~$0.001 USDC per account claimed (creds-decryption fee)' },
895
+ ],
896
+ // Server-backed account registration. Without these entries, `palmyr twitter
897
+ // register --help` (and friends) fell through to the top-level menu instead
898
+ // of explaining their flags. Subcommands listed in the parent switch must
899
+ // always have an entry here so the help guard fires before the case body.
900
+ thread: [
901
+ { flag: '<username>', desc: 'Account to post the thread from' },
902
+ { flag: '--texts \'["...","..."]\'', desc: 'JSON array of tweets (in order)' },
903
+ { flag: '--file path.json', desc: 'Alternative: read the JSON array from a file' },
904
+ { flag: '(price)', desc: '$0.005 USDC' },
905
+ ],
906
+ register: [
907
+ { flag: '<username>', desc: 'Account handle' },
908
+ { flag: '--password <pw>', desc: 'Required if the account is not already in the local vault' },
909
+ { flag: '--login --email --totp-seed --auth-token --ct0', desc: 'Optional; auto-pulled from local vault when not passed' },
910
+ { flag: '--country <CC>', desc: 'Optional residency hint stored alongside the encrypted credentials' },
911
+ { flag: '(price)', desc: 'Free — server tests login + encrypts creds at rest. Enables scheduling.' },
912
+ ],
913
+ unregister: [
914
+ { flag: '<username-or-id>', desc: 'Handle or 32-char hex account id' },
915
+ { flag: '(price)', desc: 'Free — wipes server-side credentials, account no longer schedulable' },
916
+ ],
917
+ registered: [
918
+ { flag: '(no args)', desc: 'List every server-registered X account this wallet owns' },
919
+ { flag: '(price)', desc: 'Free' },
920
+ ],
921
+ schedule: [
922
+ { flag: '<username>', desc: 'Account to post from (must be `register`-ed)' },
923
+ { flag: '--at "ISO8601"', desc: 'When to fire (e.g. --at "2026-05-15T14:00:00Z")' },
924
+ { flag: '--body "..."', desc: 'Text-only post' },
925
+ { flag: '--texts \'["..."]\' / --file path.json', desc: 'Thread' },
926
+ { flag: '--image / --video / --media-json', desc: 'Media attachments' },
927
+ { flag: '--community <id>', desc: 'Post into an X Community' },
928
+ { flag: '(price)', desc: '$0.001 USDC (text) or $0.005 USDC (thread / media) — paid up front' },
929
+ ],
930
+ queue: [
931
+ { flag: '--status pending|in_progress|completed|failed|cancelled', desc: 'Filter by status' },
932
+ { flag: '--from / --to "ISO8601"', desc: 'Filter by post-at window' },
933
+ { flag: '--account-id <id>', desc: 'Filter to one account' },
934
+ { flag: '--limit <n>', desc: 'Cap result count' },
935
+ { flag: '(price)', desc: 'Free' },
936
+ ],
937
+ cancel: [
938
+ { flag: '<schedule-id>', desc: 'Id from `palmyr twitter queue`' },
939
+ { flag: '(price)', desc: 'Free — only cancels pending posts; in-flight ones are already settled' },
940
+ ],
941
+ status: [
942
+ { flag: '<username>', desc: 'Account to inspect' },
943
+ { flag: '(price)', desc: 'Server-side liveness/shadow-ban check (not yet wired — see `palmyr twitter session` for cached login state)' },
944
+ ],
945
+ 'pool-add': [
946
+ { flag: '--credentials-line "..."', desc: 'Single account creds (login:pw:email:email_pw[:2fa[:ct0:auth_token]])' },
947
+ { flag: '--file path.txt', desc: 'Bulk: one credentials-line per row (# = comment)' },
948
+ { flag: '--price <USDC>', desc: 'Required — per-account sale_price_usdc (legacy fallback when no country price row exists)' },
949
+ { flag: '--country <CC>', desc: 'Optional override; twitterapi.io detects country + source + rename count from about_profile at seed time. Admin wins on country mismatch (flagged in response).' },
950
+ { flag: '--age 1y|2y|3y|...', desc: 'Optional age category metadata' },
951
+ { flag: '(auth)', desc: 'Admin-signed call — requires PALMYR_ADMIN_KEY' },
952
+ { flag: '(price)', desc: 'Free — server-side seeding by pool operator' },
953
+ ],
954
+ 'pool-prices': [
955
+ { flag: '(no args)', desc: 'List per-country prices set by the pool admin (public, free).' },
956
+ { flag: '(price)', desc: 'Free' },
957
+ ],
958
+ 'pool-set-price': [
959
+ { flag: '--country <CC>', desc: 'ISO 3166-1 alpha-2 country code (US, GB, DE, …)' },
960
+ { flag: '--price <USDC>', desc: 'USDC amount the `buy --country <CC>` route will charge' },
961
+ { flag: '(auth)', desc: 'Admin-signed call — requires PALMYR_ADMIN_KEY' },
962
+ { flag: '(price)', desc: 'Free' },
963
+ ],
964
+ 'pool-delete-price': [
965
+ { flag: '--country <CC>', desc: 'Country code to remove pricing for' },
966
+ { flag: '(auth)', desc: 'Admin-signed call — requires PALMYR_ADMIN_KEY' },
967
+ { flag: '(price)', desc: 'Free' },
968
+ ],
969
+ 'pool-set-source-multiplier': [
970
+ { flag: '--source web|mobile|<id>', desc: 'Source identifier (matches the `source` column populated by twitterapi.io)' },
971
+ { flag: '--multiplier <number>', desc: 'Positive scaling factor applied on top of country price when --source is passed at buy time. 1.0 = no change.' },
972
+ { flag: '(auth)', desc: 'Admin-signed call — requires PALMYR_ADMIN_KEY' },
973
+ { flag: '(price)', desc: 'Free' },
974
+ ],
975
+ 'pool-delete-source-multiplier': [
976
+ { flag: '--source <id>', desc: 'Source identifier to remove the multiplier for (buy still works, just reverts to multiplier=1.0)' },
977
+ { flag: '(auth)', desc: 'Admin-signed call — requires PALMYR_ADMIN_KEY' },
978
+ { flag: '(price)', desc: 'Free' },
979
+ ],
980
+ dispute: [
981
+ { flag: '<account_id>', desc: 'Account id returned by `twitter buy` (the 32-char hex)' },
982
+ { flag: '--reason suspended|other', desc: 'Default "suspended" — triggers auto-verify via twitterapi.io' },
983
+ { flag: '--evidence "..."', desc: 'Optional note shown to the admin if the dispute ends up in admin_review' },
984
+ { flag: '(price)', desc: '$0.01 USDC ownership-proof. 7-day window from purchase.' },
985
+ { flag: '(example)', desc: 'palmyr twitter dispute abcd1234… --reason suspended' },
986
+ ],
987
+ disputes: [
988
+ { flag: '<dispute_id>', desc: 'Look up the status of a previously filed dispute' },
989
+ { flag: '(price)', desc: '$0.001 USDC' },
990
+ ],
991
+ 'pool-disputes': [
992
+ { flag: '(no args)', desc: 'List every dispute in the system (admin)' },
993
+ { flag: '--status admin_review|pending|replaced|refunded|rejected', desc: 'Filter by status' },
994
+ { flag: '(auth)', desc: 'Admin-signed call — requires PALMYR_ADMIN_KEY' },
995
+ { flag: '(price)', desc: 'Free' },
996
+ ],
997
+ 'pool-resolve-dispute': [
998
+ { flag: '<dispute_id>', desc: 'Id from `pool-disputes`' },
999
+ { flag: '--action replace|refund|reject', desc: 'replace = grant same-country swap; refund = USDC back to payer; reject = decline' },
1000
+ { flag: '--note "..."', desc: 'Optional admin note appended to the resolution' },
1001
+ { flag: '(auth)', desc: 'Admin-signed call — requires PALMYR_ADMIN_KEY' },
1002
+ { flag: '(price)', desc: 'Free' },
1003
+ ],
1004
+ 'pool-status': [
1005
+ { flag: '(no args)', desc: 'Available / sold / reserved counts in the X account pool' },
1006
+ { flag: '(auth)', desc: 'Admin-signed call — requires PALMYR_ADMIN_KEY' },
1007
+ { flag: '(price)', desc: 'Free' },
1008
+ ],
1009
+ };
1010
+ const TIKTOK_HELP = {
1011
+ import: [
1012
+ { flag: '<username>', desc: 'TikTok handle to import' },
1013
+ { flag: '--sessionid <s> --csrf <c> --webid <w>', desc: 'Cookies from a logged-in TikTok browser' },
1014
+ { flag: '--credentials-line "..."', desc: 'Marketplace login:pw:email:email_pw format' },
1015
+ { flag: '(price)', desc: 'Free — local vault only' },
1016
+ ],
1017
+ list: [
1018
+ { flag: '(no args)', desc: 'List all local TikTok accounts' },
1019
+ { flag: '(price)', desc: 'Free' },
1020
+ ],
1021
+ info: [{ flag: '<username>', desc: 'Show one account' }, { flag: '(price)', desc: 'Free' }],
1022
+ rename: [
1023
+ { flag: '<old>', desc: 'Current local handle' },
1024
+ { flag: '--to <new>', desc: 'New handle' },
1025
+ { flag: '(price)', desc: 'Free — local-only metadata update' },
1026
+ ],
1027
+ remove: [
1028
+ { flag: '<username>', desc: 'Account to delete from local vault' },
1029
+ { flag: '--confirm', desc: 'Required' },
1030
+ { flag: '(price)', desc: 'Free' },
1031
+ ],
1032
+ totp: [{ flag: '<username>', desc: 'Print current TOTP code' }, { flag: '(price)', desc: 'Free' }],
1033
+ login: [
1034
+ { flag: '<username>', desc: 'Validate cookies and cache the session' },
1035
+ { flag: '(price)', desc: '$0.005 USDC' },
1036
+ ],
1037
+ session: [{ flag: '<username>', desc: 'Check cached session' }, { flag: '(price)', desc: 'Free' }],
1038
+ post: [
1039
+ { flag: '<username>', desc: 'Account to post from' },
1040
+ { flag: '--file video.mp4', desc: 'Video file' },
1041
+ { flag: '--caption "..."', desc: 'Caption' },
1042
+ { flag: '(price)', desc: '$0.001 USDC' },
1043
+ ],
1044
+ follow: [
1045
+ { flag: '<username>', desc: 'Account doing the follow' },
1046
+ { flag: '--user @handle', desc: 'User to follow' },
1047
+ { flag: '(price)', desc: '$0.001 USDC' },
1048
+ ],
1049
+ like: [
1050
+ { flag: '<username>', desc: 'Account doing the like' },
1051
+ { flag: '--video <url>', desc: 'Video URL to like' },
1052
+ { flag: '(price)', desc: '$0.001 USDC' },
1053
+ ],
1054
+ delete: [
1055
+ { flag: '<username>', desc: 'Account that posted the video' },
1056
+ { flag: '--video <url>', desc: 'Video to delete' },
1057
+ { flag: '(price)', desc: '$0.001 USDC' },
1058
+ ],
1059
+ bio: [
1060
+ { flag: '<username>', desc: 'Account whose bio to update' },
1061
+ { flag: '--text "..."', desc: 'New bio (<=80 chars)' },
1062
+ { flag: '(price)', desc: '$0.001 USDC' },
1063
+ ],
1064
+ name: [
1065
+ { flag: '<username>', desc: 'Account whose display name to update' },
1066
+ { flag: '--display "..."', desc: 'New display name (<=30 chars)' },
1067
+ { flag: '(price)', desc: '$0.001 USDC' },
1068
+ ],
1069
+ pfp: [
1070
+ { flag: '<username>', desc: 'Account whose avatar to update' },
1071
+ { flag: '--file pic.png', desc: 'Image file' },
1072
+ { flag: '(price)', desc: '$0.005 USDC' },
1073
+ ],
1074
+ };
736
1075
  /**
737
1076
  * Render a per-command menu (no subcommand given). On a TTY → Ink MenuScreen
738
1077
  * with the Palmyr aesthetic. In agent mode → flat JSON listing the available
@@ -849,6 +1188,7 @@ const TOP_LEVEL_COMMANDS = [
849
1188
  { name: 'doctor', description: 'Verify system health (cred store, vault, API)' },
850
1189
  { name: 'pricing', description: 'All service prices' },
851
1190
  { name: 'health', description: 'API status + version check' },
1191
+ { name: 'telemetry', description: 'on · off · status (opt-in anonymous usage stats)' },
852
1192
  ];
853
1193
  // ─── Help ───
854
1194
  function help() {
@@ -942,6 +1282,22 @@ async function main() {
942
1282
  const startTime = Date.now();
943
1283
  // No first-time banner — agent-first CLI should never pollute output.
944
1284
  const url = process.env.PALMYR_API || config.api;
1285
+ // Opt-in telemetry. If the user has explicitly enabled it, queue this run's
1286
+ // exit-code + duration on shutdown (sync — async work in 'exit' is dropped)
1287
+ // and fire-and-forget any previously-queued events to the API right now.
1288
+ // Both no-ops when telemetry is off. Never blocks the user's command —
1289
+ // the flush races their work and Node exits once both finish.
1290
+ process.on('exit', (code) => {
1291
+ telemetryAppendEvent({
1292
+ cmd: subcommand ? `${command} ${subcommand}` : command,
1293
+ exitCode: code,
1294
+ durationMs: Date.now() - startTime,
1295
+ cliVersion: VERSION,
1296
+ nodeVersion: process.version,
1297
+ platform: process.platform,
1298
+ });
1299
+ });
1300
+ void telemetryFlushQueue(url);
945
1301
  const token = flags.token || config.apiKey || process.env.PALMYR_TOKEN || process.env.PALMYR_API_KEY;
946
1302
  const passphrase = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE;
947
1303
  const ao = new Palmyr(url, true, token, passphrase);
@@ -1042,6 +1398,7 @@ async function main() {
1042
1398
  { name: 'release', description: 'Release a phone number', hint: '--id PHONE_ID' },
1043
1399
  { name: 'sms', description: 'Send an SMS', hint: '--id ID --to +1... --body "hi"' },
1044
1400
  { name: 'messages', description: 'Read SMS messages received on a number', hint: '--id PHONE_ID' },
1401
+ { name: 'message', description: 'Get one SMS message by id (incl. delivery status)', hint: '--id MESSAGE_ID' },
1045
1402
  { name: 'call', description: 'Place a voice call', hint: '--id ID --to +1... --tts "hello"' },
1046
1403
  { name: 'calls', description: 'List calls placed/received on a number', hint: '--id PHONE_ID' },
1047
1404
  { name: 'call-info', description: 'Get details on a single call', hint: '--call CALL_CONTROL_ID' },
@@ -1149,6 +1506,18 @@ async function main() {
1149
1506
  const data = await ao.phoneMessages(id);
1150
1507
  return print(data);
1151
1508
  }
1509
+ case 'message':
1510
+ case 'sms-status': {
1511
+ // Per-message readback. Mirrors `phone call-info` for SMS:
1512
+ // the Telnyx webhook updates delivery_status on the row, and this
1513
+ // endpoint serves it back. Useful when the immediate sms response
1514
+ // is 'queued' and the caller wants to confirm delivery.
1515
+ const messageId = flags.id || flags.message || positional[0];
1516
+ if (!messageId)
1517
+ err('Usage: palmyr phone message <message-id>');
1518
+ const data = await ao.phoneMessage(messageId);
1519
+ return print(data);
1520
+ }
1152
1521
  case 'calls': {
1153
1522
  const id = flags.id || positional[0];
1154
1523
  if (!id)
@@ -1287,13 +1656,58 @@ async function main() {
1287
1656
  switch (subcommand) {
1288
1657
  case 'create': {
1289
1658
  const name = flags.name || positional[0];
1290
- const wallet = flags.wallet;
1659
+ const walletInput = flags.wallet;
1291
1660
  const domain = flags.domain;
1292
1661
  if (!name)
1293
1662
  err('--name required (e.g. palmyr email create --name hello [--domain example.com])');
1663
+ // --wallet accepts three forms: vault id, vault name, or a raw
1664
+ // Solana base58 pubkey. The server only accepts a Solana pubkey
1665
+ // (E2E encryption is Ed25519→X25519), so resolve id/name to a
1666
+ // pubkey here before the request. Raw pubkeys pass through.
1667
+ // Doing this resolution client-side also means a Base-paying user
1668
+ // doesn't need a Solana wallet — their vault already has one
1669
+ // (single mnemonic, both chains), and we can find it without
1670
+ // making the server reach back for client-side keys.
1671
+ let walletAddress;
1672
+ if (walletInput) {
1673
+ const looksLikeSolPubkey = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(walletInput);
1674
+ if (looksLikeSolPubkey) {
1675
+ walletAddress = walletInput;
1676
+ }
1677
+ else {
1678
+ const { listVaultWallets } = await import('./vault.js');
1679
+ const wallets = listVaultWallets();
1680
+ const match = wallets.find(w => w.id === walletInput || w.name === walletInput);
1681
+ if (!match)
1682
+ err(`--wallet "${walletInput}" did not match any vault id, name, or look like a Solana pubkey`);
1683
+ if (!match.solanaAddress)
1684
+ err(`Wallet "${walletInput}" has no Solana address — email inboxes require one (E2E uses Ed25519). Re-create with: palmyr wallet create`);
1685
+ walletAddress = match.solanaAddress;
1686
+ }
1687
+ }
1688
+ else {
1689
+ // No --wallet: the server would normally default the inbox owner
1690
+ // to the x402 payer. That works for Solana-paid calls but
1691
+ // 400s on Base because the payer is an EVM address. Auto-fill
1692
+ // the *paying wallet's* Solana address here so a single
1693
+ // mnemonic-derived vault wallet works on either pay chain.
1694
+ const cfg = loadConfig();
1695
+ const payChain = (cfg.defaultPayChain || 'solana');
1696
+ if (payChain === 'base') {
1697
+ const { listVaultWallets } = await import('./vault.js');
1698
+ const wallets = listVaultWallets();
1699
+ const targetId = cfg.defaultPayWalletId || process.env.PALMYR_PAY_WALLET;
1700
+ const paying = (targetId && wallets.find(w => w.id === targetId)) || wallets.find(w => w.evmAddress && w.solanaAddress);
1701
+ if (paying?.solanaAddress)
1702
+ walletAddress = paying.solanaAddress;
1703
+ // If no Solana address is reachable, fall through and let the
1704
+ // server return its actionable 400 — silent failure would be
1705
+ // worse than a clear error.
1706
+ }
1707
+ }
1294
1708
  const spin = new Spinner();
1295
1709
  spin.start('Creating inbox...');
1296
- const data = await ao.emailCreate(name, wallet, domain);
1710
+ const data = await ao.emailCreate(name, walletAddress, domain);
1297
1711
  spin.stop('Inbox created', true);
1298
1712
  return print(data);
1299
1713
  }
@@ -1589,6 +2003,26 @@ async function main() {
1589
2003
  const localKeyPath = generatedKeyMeta?.privateKeyPath
1590
2004
  || (pubkeyFile ? pubkeyFile.replace(/\.pub$/, '').replace('~', homedir()) : undefined)
1591
2005
  || (explicitKeyPath ? explicitKeyPath.replace('~', homedir()) : undefined);
2006
+ // --ssh-key <id> uploads a server-side key but doesn't tell us where
2007
+ // the matching private key lives on this machine. Without
2008
+ // --key-path, the SSH readiness gate later silently skips and the
2009
+ // deploy reports `ssh: skipped` while still marking the server as
2010
+ // "ready" — a real foot-gun (dogfood report 2026-05-25 hit this
2011
+ // exact path). Be loud about it now, before anyone pays.
2012
+ if (sshKeyIds && !localKeyPath) {
2013
+ const msg = 'Warning: --ssh-key <id> uploaded the public key server-side, ' +
2014
+ 'but no matching private key was passed to this CLI. ' +
2015
+ 'SSH readiness cannot be verified locally — `compute wait` and the ' +
2016
+ 'inline --wait check will skip the SSH gate. Pass ' +
2017
+ '`--key-path /path/to/private_key` (or `--private-key`) to ' +
2018
+ 'enable the verification.';
2019
+ if (AGENT_MODE) {
2020
+ process.stderr.write(JSON.stringify({ event: 'warning', code: 'ssh_key_no_local_key', message: msg }) + '\n');
2021
+ }
2022
+ else {
2023
+ process.stderr.write(`\n${msg}\n\n`);
2024
+ }
2025
+ }
1592
2026
  // Progress events to stderr — default ON in agent mode so a
1593
2027
  // long deploy isn't a 10-minute silence. Pass --no-progress to
1594
2028
  // opt out. Stdout still gets one final JSON object, so jq
@@ -2169,7 +2603,7 @@ async function main() {
2169
2603
  subtitle: 'Non-custodial HD wallet',
2170
2604
  footerLeft: 'Solana + Base wallet operations',
2171
2605
  commands: [
2172
- { name: 'create', description: 'Create one or many wallets', hint: '[--tag X --count 100] [--solana|--base] [--managed]' },
2606
+ { name: 'create', description: 'Create one or many wallets', hint: '[--tag X --count 100] [--solana|--base]' },
2173
2607
  { name: 'import', description: 'Import from mnemonic', hint: '--mnemonic "..." [--tag X]' },
2174
2608
  { name: 'list', description: 'List all wallets', hint: '[--tag <name>]' },
2175
2609
  { name: 'info', description: 'Wallet details', hint: 'WALLET_ID' },
@@ -2183,7 +2617,6 @@ async function main() {
2183
2617
  { name: 'api-key', description: 'Create agent API key', hint: 'WALLET_ID --name my-agent' },
2184
2618
  { name: 'config', description: 'Get agent config', hint: 'WALLET_ID' },
2185
2619
  { name: 'use', description: 'Set default pay wallet', hint: 'WALLET_ID' },
2186
- { name: 'request-approval', description: 'Request human approval (managed)', hint: 'WALLET_ID --action limits --daily 100' },
2187
2620
  { name: 'buy', description: 'Open a trading position', hint: 'solana <CA> --amount 0.5sol --thesis "..."' },
2188
2621
  { name: 'cohort', description: 'Split a buy across N derived wallets with jitter (Phase 4c)', hint: 'buy <CHAIN> <CA> --total ... --split N' },
2189
2622
  { name: 'template', description: 'Manage YAML strategy templates', hint: 'list | show <name> | path <name> | delete <name>' },
@@ -4978,7 +5411,12 @@ async function main() {
4978
5411
  log(`auto-imported @${username} from server (${sourceLabel} → local vault)`);
4979
5412
  return summary;
4980
5413
  };
4981
- if (!subcommand) {
5414
+ // Help guard. `palmyr twitter buy --help` MUST never dispatch to the
5415
+ // paid `case 'buy'` below — 1.8.3 had no guard here and a real user
5416
+ // got charged $5 for a help command. Falls back to the top-level menu
5417
+ // when the subcommand has no per-subcommand help entry, so even an
5418
+ // unrecognized `palmyr twitter <whatever> --help` is safe to run.
5419
+ if (!subcommand || (flags.help && !TWITTER_HELP[subcommand])) {
4982
5420
  showMenu({
4983
5421
  command: 'twitter',
4984
5422
  title: 'twitter',
@@ -4994,7 +5432,10 @@ async function main() {
4994
5432
  { name: 'buy', description: 'Purchase an aged account (requires server supplier config)', hint: '--age 1y --country US' },
4995
5433
  { name: 'login', description: 'Force a fresh server-side session (requires browser runtime)', hint: '<username>' },
4996
5434
  { name: 'post', description: 'Post a tweet (requires server browser runtime)', hint: '<username> --body "..."' },
4997
- { name: 'status', description: 'Check if the account is alive / shadow-banned', hint: '<username>' },
5435
+ // `status` is not wired yet (Phase 3). Hidden from this menu so
5436
+ // users don't try a command that will only error; `session`
5437
+ // covers the most useful subset (cached server-side login state).
5438
+ { name: 'session', description: 'Inspect cached server-side session for an account', hint: '<username>' },
4998
5439
  { name: 'transfer', description: 'Hand an account to another wallet (rotates password; auto-registers if needed)', hint: '<username> --to <wallet> --confirm' },
4999
5440
  { name: 'share', description: 'Grant another wallet shared access', hint: '<username> --with <wallet>' },
5000
5441
  { name: 'unshare', description: 'Revoke a wallet’s shared access', hint: '<username> --from <wallet> [--rotate]' },
@@ -5004,6 +5445,10 @@ async function main() {
5004
5445
  });
5005
5446
  return;
5006
5447
  }
5448
+ if (flags.help && subcommand && TWITTER_HELP[subcommand]) {
5449
+ subcommandHelp('twitter', subcommand, TWITTER_HELP[subcommand]);
5450
+ return;
5451
+ }
5007
5452
  switch (subcommand) {
5008
5453
  case 'import': {
5009
5454
  // Option 1: --credentials-line "login:password:email:email_pw:2fa:ct0:auth_token"
@@ -5829,10 +6274,27 @@ async function main() {
5829
6274
  return print({ success: true, platform, username, op: subcommand, ...(data?.data || {}) });
5830
6275
  }
5831
6276
  case 'buy': {
5832
- // Agents just say "buy." Server picks the oldest ready account.
6277
+ // Agents say "buy" with optional --country / --source / --max-renames.
6278
+ // Each filter is independent (default: random across that
6279
+ // dimension). Pricing = country_price * source_multiplier:
6280
+ // - --country US → country_prices.US (e.g. $8)
6281
+ // - --source web → multiplied by web's row in
6282
+ // source_multipliers (e.g. 1.2)
6283
+ // - --max-renames 0 → filter only, no price impact
6284
+ // Without --country, falls back to the legacy $5 flat rate.
6285
+ const country = (flags.country || '').trim().toUpperCase() || undefined;
6286
+ const ageCategory = flags.age || flags['age-category'] || undefined;
6287
+ const source = (flags.source || '').trim().toLowerCase() || undefined;
6288
+ const maxRenamesRaw = flags['max-renames'];
6289
+ const maxUsernameChanges = maxRenamesRaw === undefined || maxRenamesRaw === ''
6290
+ ? undefined
6291
+ : Number(maxRenamesRaw);
6292
+ if (maxUsernameChanges !== undefined && (!Number.isFinite(maxUsernameChanges) || maxUsernameChanges < 0)) {
6293
+ err('--max-renames must be a non-negative integer (e.g. 0 = never renamed)');
6294
+ }
5833
6295
  let data;
5834
6296
  try {
5835
- data = await ao.socialTwitterBuy();
6297
+ data = await ao.socialTwitterBuy(country, ageCategory, { source, maxUsernameChanges });
5836
6298
  }
5837
6299
  catch (e) {
5838
6300
  err(`Buy failed: ${e.message}`, EXIT.GENERAL);
@@ -5844,10 +6306,15 @@ async function main() {
5844
6306
  // Auto-import into the local vault + prime the session cache so
5845
6307
  // the buyer can post immediately with the cookies the admin
5846
6308
  // pre-seasoned at pool-add time.
6309
+ const filterTags = [
6310
+ country && `country=${country}`,
6311
+ source && `source=${source}`,
6312
+ maxUsernameChanges !== undefined && `max_renames=${maxUsernameChanges}`,
6313
+ ].filter(Boolean).join(', ');
5847
6314
  const summary = sv.importAccount(platform, account.username, account.credentials, {
5848
6315
  source: 'pool',
5849
6316
  proxy_session_id: account.proxy_session_id,
5850
- notes: 'Bought from pool',
6317
+ notes: filterTags ? `Bought from pool (${filterTags})` : 'Bought from pool',
5851
6318
  });
5852
6319
  sv.saveSession(summary.id, platform, account.cookies || []);
5853
6320
  sv.updateMeta(platform, summary.username, { last_action_at: new Date().toISOString() });
@@ -5855,8 +6322,177 @@ async function main() {
5855
6322
  success: true,
5856
6323
  platform,
5857
6324
  username: summary.username,
5858
- hint: `Ready to post — try: palmyr twitter post ${summary.username} --body "gm"`,
6325
+ country: account.country,
6326
+ source: account.source,
6327
+ username_change_count: account.username_change_count,
6328
+ account_based_in: account.account_based_in,
6329
+ account_id: account.id,
6330
+ hint: `Ready to post — try: palmyr twitter post ${summary.username} --body "gm". ` +
6331
+ `If the account is suspended within 7 days, run: palmyr twitter dispute ${account.id}`,
6332
+ });
6333
+ }
6334
+ case 'pool-prices': {
6335
+ // Public: which countries are priced and what they cost. Run
6336
+ // before `buy --country X` to confirm the country is available.
6337
+ let data;
6338
+ try {
6339
+ data = await ao.socialTwitterPoolPrices();
6340
+ }
6341
+ catch (e) {
6342
+ err(`pool-prices failed: ${e.message}`, EXIT.GENERAL);
6343
+ }
6344
+ return print(data);
6345
+ }
6346
+ case 'pool-set-price': {
6347
+ // Admin: set USDC price for a single country code. Idempotent.
6348
+ const { buildAdminHeaders } = await import('./admin-auth.js');
6349
+ const country = (flags.country || '').trim().toUpperCase();
6350
+ const price = flags.price !== undefined ? Number(flags.price) : NaN;
6351
+ if (!country)
6352
+ err('--country <CC> required (ISO 3166-1 alpha-2: US, GB, DE, …)');
6353
+ if (!Number.isFinite(price) || price <= 0)
6354
+ err('--price <USDC> required (positive number)');
6355
+ const path = `/social/twitter/pool/prices/${encodeURIComponent(country)}`;
6356
+ const headers = buildAdminHeaders('PUT', path);
6357
+ const res = await fetch(ao.api + path, {
6358
+ method: 'PUT',
6359
+ headers: { 'Content-Type': 'application/json', ...headers },
6360
+ body: JSON.stringify({ price_usdc: price }),
5859
6361
  });
6362
+ const data = await res.json();
6363
+ if (!res.ok || !data.success)
6364
+ err(`pool-set-price failed: ${data.error || `HTTP ${res.status}`}`, EXIT.GENERAL);
6365
+ return print(data);
6366
+ }
6367
+ case 'pool-delete-price': {
6368
+ // Admin: remove the row for a country. Subsequent `buy --country X`
6369
+ // will return 400 "Country not priced" until set again.
6370
+ const { buildAdminHeaders } = await import('./admin-auth.js');
6371
+ const country = (flags.country || '').trim().toUpperCase();
6372
+ if (!country)
6373
+ err('--country <CC> required');
6374
+ const path = `/social/twitter/pool/prices/${encodeURIComponent(country)}`;
6375
+ const headers = buildAdminHeaders('DELETE', path);
6376
+ const res = await fetch(ao.api + path, { method: 'DELETE', headers });
6377
+ const data = await res.json();
6378
+ if (!res.ok)
6379
+ err(`pool-delete-price failed: ${data.error || `HTTP ${res.status}`}`, EXIT.GENERAL);
6380
+ return print(data);
6381
+ }
6382
+ case 'pool-set-source-multiplier': {
6383
+ // Admin: scale the country price for buys filtered by a given
6384
+ // source ('web', 'mobile', …). e.g. mult=1.2 → web buys cost
6385
+ // 20% more than the base country price.
6386
+ const { buildAdminHeaders } = await import('./admin-auth.js');
6387
+ const source = (flags.source || '').trim().toLowerCase();
6388
+ const mult = flags.multiplier !== undefined ? Number(flags.multiplier) : NaN;
6389
+ if (!source)
6390
+ err('--source <name> required (e.g. web, mobile)');
6391
+ if (!Number.isFinite(mult) || mult <= 0)
6392
+ err('--multiplier <number> required (positive)');
6393
+ const path = `/social/twitter/pool/source-multipliers/${encodeURIComponent(source)}`;
6394
+ const headers = buildAdminHeaders('PUT', path);
6395
+ const res = await fetch(ao.api + path, {
6396
+ method: 'PUT',
6397
+ headers: { 'Content-Type': 'application/json', ...headers },
6398
+ body: JSON.stringify({ multiplier: mult }),
6399
+ });
6400
+ const data = await res.json();
6401
+ if (!res.ok || !data.success)
6402
+ err(`pool-set-source-multiplier failed: ${data.error || `HTTP ${res.status}`}`, EXIT.GENERAL);
6403
+ return print(data);
6404
+ }
6405
+ case 'pool-delete-source-multiplier': {
6406
+ // Admin: drop the multiplier for a source. Subsequent `buy
6407
+ // --source X` reverts to using 1.0 (filter only, no price scaling).
6408
+ const { buildAdminHeaders } = await import('./admin-auth.js');
6409
+ const source = (flags.source || '').trim().toLowerCase();
6410
+ if (!source)
6411
+ err('--source <name> required');
6412
+ const path = `/social/twitter/pool/source-multipliers/${encodeURIComponent(source)}`;
6413
+ const headers = buildAdminHeaders('DELETE', path);
6414
+ const res = await fetch(ao.api + path, { method: 'DELETE', headers });
6415
+ const data = await res.json();
6416
+ if (!res.ok)
6417
+ err(`pool-delete-source-multiplier failed: ${data.error || `HTTP ${res.status}`}`, EXIT.GENERAL);
6418
+ return print(data);
6419
+ }
6420
+ case 'dispute': {
6421
+ // Buyer: file a dispute for a pool-bought account that got
6422
+ // suspended. Server auto-verifies via twitterapi.io and either
6423
+ // hands over a same-country replacement, refunds USDC, or queues
6424
+ // for admin review when the signal is ambiguous.
6425
+ const accountId = positional[0] || flags['account-id'] || flags.id;
6426
+ const reason = (flags.reason || 'suspended');
6427
+ const evidence = flags.evidence || flags.note || undefined;
6428
+ if (!accountId)
6429
+ err('<account_id> required (the id returned by `palmyr twitter buy`)');
6430
+ if (reason !== 'suspended' && reason !== 'other')
6431
+ err('--reason must be "suspended" or "other"');
6432
+ let data;
6433
+ try {
6434
+ data = await ao.socialTwitterDispute(accountId, { reason, evidence });
6435
+ }
6436
+ catch (e) {
6437
+ err(`Dispute failed: ${e.message}`, EXIT.GENERAL);
6438
+ }
6439
+ if (!data?.success)
6440
+ err(`Dispute failed: ${data?.error || 'unknown'}`, EXIT.GENERAL);
6441
+ return print(data);
6442
+ }
6443
+ case 'disputes': {
6444
+ // Buyer: list ONE specific dispute by id. Listing all your
6445
+ // disputes isn't supported by the buyer surface today — track
6446
+ // the id printed by `dispute` and call `disputes <id>`.
6447
+ const id = positional[0] || flags.id;
6448
+ if (!id)
6449
+ err('<dispute_id> required');
6450
+ let data;
6451
+ try {
6452
+ data = await ao.socialTwitterDisputeGet(id);
6453
+ }
6454
+ catch (e) {
6455
+ err(`Get dispute failed: ${e.message}`, EXIT.GENERAL);
6456
+ }
6457
+ return print(data);
6458
+ }
6459
+ case 'pool-disputes': {
6460
+ // Admin: list every dispute, optionally filter by status. The
6461
+ // `admin_review` queue is the human-decision backlog.
6462
+ const { buildAdminHeaders } = await import('./admin-auth.js');
6463
+ const status = flags.status || undefined;
6464
+ const path = '/social/twitter/pool/disputes' + (status ? `?status=${encodeURIComponent(status)}` : '');
6465
+ const headers = buildAdminHeaders('GET', path);
6466
+ const res = await fetch(ao.api + path, { headers });
6467
+ const data = await res.json();
6468
+ if (!res.ok)
6469
+ err(`pool-disputes failed: ${data.error || `HTTP ${res.status}`}`, EXIT.GENERAL);
6470
+ return print(data);
6471
+ }
6472
+ case 'pool-resolve-dispute': {
6473
+ // Admin: resolve an admin_review dispute. action ∈ replace |
6474
+ // refund | reject. `replace` needs same-country stock; `refund`
6475
+ // needs payment provenance on the row (auto-saved at buy time).
6476
+ const { buildAdminHeaders } = await import('./admin-auth.js');
6477
+ const id = positional[0] || flags.id;
6478
+ const action = flags.action;
6479
+ const note = flags.note || undefined;
6480
+ if (!id)
6481
+ err('<dispute_id> required');
6482
+ if (action !== 'replace' && action !== 'refund' && action !== 'reject') {
6483
+ err('--action must be "replace", "refund", or "reject"');
6484
+ }
6485
+ const path = `/social/twitter/pool/disputes/${encodeURIComponent(id)}/resolve`;
6486
+ const headers = buildAdminHeaders('POST', path);
6487
+ const res = await fetch(ao.api + path, {
6488
+ method: 'POST',
6489
+ headers: { 'Content-Type': 'application/json', ...headers },
6490
+ body: JSON.stringify({ action, ...(note ? { note } : {}) }),
6491
+ });
6492
+ const data = await res.json();
6493
+ if (!res.ok || !data.success)
6494
+ err(`pool-resolve-dispute failed: ${data.error || `HTTP ${res.status}`}`, EXIT.GENERAL);
6495
+ return print(data);
5860
6496
  }
5861
6497
  case 'pool-add': {
5862
6498
  const { buildAdminHeaders } = await import('./admin-auth.js');
@@ -5941,14 +6577,22 @@ async function main() {
5941
6577
  return print(data);
5942
6578
  }
5943
6579
  case 'status': {
5944
- err(`twitter status: not wired yet. Phase 3 will add it.`, EXIT.GENERAL);
6580
+ // `twitter status` was meant to check live shadow-ban / suspension
6581
+ // state via a server-side probe. That's a Phase 3 build (needs a
6582
+ // browser runtime on the server to render the profile). Until
6583
+ // then, point users at `session` for the cached login state and
6584
+ // `info` for vault metadata — together they cover the practical
6585
+ // "is this account still usable from the agent's side" question.
6586
+ err(`twitter status: not wired yet (Phase 3). Closest equivalents available today: ` +
6587
+ `\`palmyr twitter session <username>\` (cached server-side login validity) and ` +
6588
+ `\`palmyr twitter info <username>\` (local vault record).`, EXIT.GENERAL);
5945
6589
  }
5946
6590
  case 'transfer': {
5947
6591
  // Hand the X account to another wallet. End-to-end one-command:
5948
6592
  // 1. If the account is only in the local vault, auto-register it
5949
6593
  // with the server (uploads encrypted creds; $0.01 USDC).
5950
6594
  // 2. Server rotates the password and revokes other sessions
5951
- // ($0.0001 USDC ownership proof).
6595
+ // ($0.01 USDC ownership proof).
5952
6596
  // 3. Atomically flips ownership in the DB.
5953
6597
  // Receiver picks up the rotated credentials via `palmyr twitter
5954
6598
  // list` (which now surfaces server-side accounts) and/or `claim`.
@@ -6320,7 +6964,9 @@ async function main() {
6320
6964
  case 'tiktok': {
6321
6965
  const sv = await import('./social-vault.js');
6322
6966
  const platform = 'tiktok';
6323
- if (!subcommand) {
6967
+ // Same help guard as `twitter` — prevents `--help` from dispatching
6968
+ // a paid subcommand. Same bug class lived here too in 1.8.3.
6969
+ if (!subcommand || (flags.help && !TIKTOK_HELP[subcommand])) {
6324
6970
  showMenu({
6325
6971
  command: 'tiktok',
6326
6972
  title: 'tiktok',
@@ -6347,6 +6993,10 @@ async function main() {
6347
6993
  });
6348
6994
  return;
6349
6995
  }
6996
+ if (flags.help && subcommand && TIKTOK_HELP[subcommand]) {
6997
+ subcommandHelp('tiktok', subcommand, TIKTOK_HELP[subcommand]);
6998
+ return;
6999
+ }
6350
7000
  switch (subcommand) {
6351
7001
  case 'import': {
6352
7002
  // Two formats:
@@ -6693,6 +7343,55 @@ async function main() {
6693
7343
  'The Palmyr server fires them at post_at without any client process.', EXIT.BAD_INPUT);
6694
7344
  break;
6695
7345
  }
7346
+ case 'telemetry': {
7347
+ // Off by default. Opt-in only. We never auto-enable, never prompt at
7348
+ // startup, never write to stdout outside this command. Captured fields
7349
+ // and storage location are documented in cli/telemetry.ts.
7350
+ const action = (subcommand || 'status').toLowerCase();
7351
+ if (action !== 'on' && action !== 'off' && action !== 'status') {
7352
+ err(`Unknown telemetry action: ${action}. Use: on | off | status`, EXIT.BAD_INPUT);
7353
+ }
7354
+ let state;
7355
+ if (action === 'on')
7356
+ state = setTelemetryEnabled(true);
7357
+ else if (action === 'off')
7358
+ state = setTelemetryEnabled(false);
7359
+ else
7360
+ state = getTelemetryState();
7361
+ const payload = {
7362
+ enabled: state.enabled,
7363
+ installId: state.installId || null,
7364
+ optedInAt: state.optedInAt || null,
7365
+ queuedEvents: telemetryQueuedCount(),
7366
+ captures: ['cmd', 'exitCode', 'durationMs', 'cliVersion', 'nodeVersion', 'platform'],
7367
+ neverCaptures: ['flag values', 'positional args', 'stdout/stderr', 'wallet addresses', 'phone numbers', 'any user input'],
7368
+ };
7369
+ if (AGENT_MODE) {
7370
+ print(payload);
7371
+ }
7372
+ else {
7373
+ const status = state.enabled ? `${t.success}on${t.reset}` : `${t.muted}off${t.reset}`;
7374
+ console.log(`Telemetry: ${status}`);
7375
+ if (state.installId)
7376
+ console.log(`Install ID: ${t.muted}${state.installId}${t.reset}`);
7377
+ if (state.optedInAt)
7378
+ console.log(`Opted in: ${state.optedInAt}`);
7379
+ if (payload.queuedEvents)
7380
+ console.log(`Queued: ${payload.queuedEvents} event(s) waiting to send`);
7381
+ console.log('');
7382
+ console.log(`Captures: ${payload.captures.join(', ')}`);
7383
+ console.log(`Never: flag values, positional args, stdout, user input`);
7384
+ if (!state.enabled) {
7385
+ console.log('');
7386
+ console.log(`Opt in: ${t.accent}palmyr telemetry on${t.reset}`);
7387
+ }
7388
+ else {
7389
+ console.log('');
7390
+ console.log(`Opt out: ${t.accent}palmyr telemetry off${t.reset} (drops any queued events)`);
7391
+ }
7392
+ }
7393
+ break;
7394
+ }
6696
7395
  case 'config': {
6697
7396
  const cfg = loadConfig();
6698
7397
  const { homedir } = await import('os');