@palmyr/cli 1.9.0 → 1.10.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
@@ -778,12 +778,14 @@ const TWITTER_HELP = {
778
778
  ],
779
779
  buy: [
780
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.' },
781
+ { flag: '--country <CC>', desc: 'Filter by RESIDENCY (X about_profile.account_based_in). ISO alpha-2 US, GB, DE, …. Run `pool-prices` to see what is priced.' },
782
+ { flag: '--registered-country <CC>', desc: 'Filter by REGISTRATION country where the X account was created from (parsed from X "Connected via" string). May differ from --country.' },
783
+ { flag: '--platform android|ios|web', desc: 'Filter by registration platform (also parsed from X "Connected via" string).' },
784
+ { flag: '--max-renames N', desc: 'Cap username-change count. --max-renames 0 = never renamed. Rows with unknown rename count do not match.' },
785
+ { flag: '--source "raw string"', desc: 'Power-user: exact-match against the lowercased raw "Connected via" string. Used for fine-grained source_multiplier pricing.' },
784
786
  { flag: '--age 1y|2y|...', desc: 'Optional age category filter' },
785
787
  { 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' },
788
+ { flag: '(example)', desc: 'palmyr twitter buy --country GB --registered-country GB --platform android --max-renames 0' },
787
789
  ],
788
790
  login: [
789
791
  { flag: '<username>', desc: 'Force a fresh server-side session (browser runtime)' },
@@ -1012,10 +1014,23 @@ const TIKTOK_HELP = {
1012
1014
  { flag: '<username>', desc: 'TikTok handle to import' },
1013
1015
  { flag: '--sessionid <s> --csrf <c> --webid <w>', desc: 'Cookies from a logged-in TikTok browser' },
1014
1016
  { flag: '--credentials-line "..."', desc: 'Marketplace login:pw:email:email_pw format' },
1017
+ { flag: '--tag <name>', desc: 'Assign a folder-like grouping tag at import' },
1015
1018
  { flag: '(price)', desc: 'Free — local vault only' },
1016
1019
  ],
1020
+ connect: [
1021
+ { flag: '<username>', desc: 'Log in once via your real browser; auto-captures the session' },
1022
+ { flag: '--qr', desc: 'QR login: opens a window with a login QR for a human to scan with the TikTok app (no password/captcha)' },
1023
+ { flag: '--country <iso-2>', desc: 'Optional — auto-detected from your browser; override e.g. --country de' },
1024
+ { flag: '--timeout <sec>', desc: 'How long to wait for login (default 300)' },
1025
+ { flag: '--browser-path <path>', desc: 'Override Chrome/Edge/Brave auto-detection' },
1026
+ { flag: '--no-sandbox', desc: 'Launch without the browser sandbox (auto on root Linux; for headless/CI)' },
1027
+ { flag: '--force', desc: 'Re-capture even if a fresh session is already cached' },
1028
+ { flag: '--tag <name>', desc: 'Save under a folder-like grouping tag (organize many accounts)' },
1029
+ { flag: '(price)', desc: 'Free — local, no server call' },
1030
+ ],
1017
1031
  list: [
1018
1032
  { flag: '(no args)', desc: 'List all local TikTok accounts' },
1033
+ { flag: '--tag <name>', desc: 'Filter to accounts in this folder/tag' },
1019
1034
  { flag: '(price)', desc: 'Free' },
1020
1035
  ],
1021
1036
  info: [{ flag: '<username>', desc: 'Show one account' }, { flag: '(price)', desc: 'Free' }],
@@ -1024,6 +1039,12 @@ const TIKTOK_HELP = {
1024
1039
  { flag: '--to <new>', desc: 'New handle' },
1025
1040
  { flag: '(price)', desc: 'Free — local-only metadata update' },
1026
1041
  ],
1042
+ tag: [
1043
+ { flag: '<username>', desc: 'Account to tag' },
1044
+ { flag: '<tag-name>', desc: 'Folder name (positional) to assign' },
1045
+ { flag: '--clear', desc: "Remove the account's tag instead" },
1046
+ { flag: '(price)', desc: 'Free — local-only metadata' },
1047
+ ],
1027
1048
  remove: [
1028
1049
  { flag: '<username>', desc: 'Account to delete from local vault' },
1029
1050
  { flag: '--confirm', desc: 'Required' },
@@ -1032,14 +1053,22 @@ const TIKTOK_HELP = {
1032
1053
  totp: [{ flag: '<username>', desc: 'Print current TOTP code' }, { flag: '(price)', desc: 'Free' }],
1033
1054
  login: [
1034
1055
  { flag: '<username>', desc: 'Validate cookies and cache the session' },
1035
- { flag: '(price)', desc: '$0.005 USDC' },
1056
+ { flag: '(price)', desc: '$0.02 USDC' },
1036
1057
  ],
1037
1058
  session: [{ flag: '<username>', desc: 'Check cached session' }, { flag: '(price)', desc: 'Free' }],
1038
1059
  post: [
1039
1060
  { flag: '<username>', desc: 'Account to post from' },
1040
1061
  { flag: '--file video.mp4', desc: 'Video file' },
1041
1062
  { flag: '--caption "..."', desc: 'Caption' },
1042
- { flag: '(price)', desc: '$0.001 USDC' },
1063
+ { flag: '--privacy 0|1|2', desc: 'Audience: 0 public (default) · 1 friends · 2 private' },
1064
+ { flag: '(price)', desc: '$0.01 USDC' },
1065
+ ],
1066
+ schedule: [
1067
+ { flag: '<username>', desc: 'Account to post from' },
1068
+ { flag: '--at <iso8601>', desc: 'When TikTok publishes it — ~15 min to ~10 days out' },
1069
+ { flag: '--file video.mp4 | --url <https>', desc: 'Video' },
1070
+ { flag: '--caption "..."', desc: 'Caption' },
1071
+ { flag: '(price)', desc: "Same as post — uses TikTok's own scheduler" },
1043
1072
  ],
1044
1073
  follow: [
1045
1074
  { flag: '<username>', desc: 'Account doing the follow' },
@@ -1071,6 +1100,47 @@ const TIKTOK_HELP = {
1071
1100
  { flag: '--file pic.png', desc: 'Image file' },
1072
1101
  { flag: '(price)', desc: '$0.005 USDC' },
1073
1102
  ],
1103
+ draft: [
1104
+ { flag: '<username>', desc: 'Account to post from' },
1105
+ { flag: '--file video.mp4 | --url <https>', desc: 'Video' },
1106
+ { flag: '--caption "..."', desc: 'Caption' },
1107
+ { flag: '--privacy 0|1|2', desc: 'Audience: 0 public (default) · 1 friends · 2 private' },
1108
+ { flag: '--at <iso8601>', desc: 'Optional — schedule on approval instead of posting now' },
1109
+ { flag: '(price)', desc: 'Free — queues for approval; you pay on approve' },
1110
+ ],
1111
+ drafts: [
1112
+ { flag: '[<username>]', desc: 'List drafts awaiting approval (optionally for one account)' },
1113
+ { flag: '--tag <name>', desc: 'Filter by folder/tag' },
1114
+ { flag: '(price)', desc: 'Free' },
1115
+ ],
1116
+ approve: [
1117
+ { flag: '<draft-id>', desc: 'Publish the queued draft + write it to the post log' },
1118
+ { flag: '(price)', desc: "Same as post — $0.01 (a scheduled draft uses TikTok's scheduler)" },
1119
+ ],
1120
+ reject: [
1121
+ { flag: '<draft-id>', desc: 'Discard a queued draft' },
1122
+ { flag: '(price)', desc: 'Free' },
1123
+ ],
1124
+ logs: [
1125
+ { flag: '[<username>]', desc: 'Recent posts (audit log) — approved drafts + direct posts' },
1126
+ { flag: '--tag <name>', desc: 'Filter by folder/tag' },
1127
+ { flag: '--limit <N>', desc: 'How many to show (default 20)' },
1128
+ { flag: '(price)', desc: 'Free' },
1129
+ ],
1130
+ analytics: [
1131
+ { flag: '<username>', desc: 'Scrape per-post views/likes/comments, categorize into tiers, snapshot the time-series' },
1132
+ { flag: '(price)', desc: '$0.005 USDC (free in self-hosted mode)' },
1133
+ ],
1134
+ review: [
1135
+ { flag: '<username>', desc: 'Performance review — best/worst, tier mix, engagement, trend vs last snapshot' },
1136
+ { flag: '(price)', desc: 'Free — reads the local snapshot store' },
1137
+ ],
1138
+ monitor: [
1139
+ { flag: 'tick | start | stop | status', desc: 'Unattended analytics loop (self-learning)' },
1140
+ { flag: '--every <6h|30m>', desc: 'Interval for `start` (default 6h)' },
1141
+ { flag: '--account a,b', desc: 'Limit to specific accounts (default: all connected)' },
1142
+ { flag: '(price)', desc: 'Free locally; each tick runs `analytics`' },
1143
+ ],
1074
1144
  };
1075
1145
  /**
1076
1146
  * Render a per-command menu (no subcommand given). On a TTY → Ink MenuScreen
@@ -1228,10 +1298,10 @@ async function main() {
1228
1298
  AGENT_MODE = !process.stdout.isTTY || !!flags.json || process.env.PALMYR_JSON === '1';
1229
1299
  setUiAgentMode(AGENT_MODE);
1230
1300
  if (flags.version) {
1231
- if (AGENT_MODE) {
1232
- // Agents need to disambiguate "CLI npm package version" from "Palmyr API
1233
- // server version" and the Node runtime emit all three so they don't
1234
- // have to call multiple commands to assemble a support report.
1301
+ // `--version` follows the universal CLI convention: a bare version string on
1302
+ // stdout so wrappers/CI can grep it. Agents that want the richer cli/node/
1303
+ // platform report opt in explicitly with --json (or PALMYR_JSON=1).
1304
+ if (AGENT_MODE && (!!flags.json || process.env.PALMYR_JSON === '1')) {
1235
1305
  print({
1236
1306
  cliPackageVersion: VERSION,
1237
1307
  version: VERSION, // back-compat alias for the legacy {version} shape
@@ -3125,8 +3195,6 @@ async function main() {
3125
3195
  err('No session secret found. Was this wallet created on this machine?');
3126
3196
  const data = await ao.walletConfig(walletId, sessionSecret);
3127
3197
  return print(data);
3128
- print(data.config || data);
3129
- break;
3130
3198
  }
3131
3199
  case 'use': {
3132
3200
  const walletId = positional[0] || flags.id;
@@ -6274,17 +6342,27 @@ async function main() {
6274
6342
  return print({ success: true, platform, username, op: subcommand, ...(data?.data || {}) });
6275
6343
  }
6276
6344
  case 'buy': {
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.
6345
+ // Agents say "buy" with optional filters. Each is independent
6346
+ // (default: random across that dimension).
6347
+ //
6348
+ // --country GB account_based_in = United Kingdom (residency)
6349
+ // --registered-country GB X's "Connected via …" country
6350
+ // --platform android|ios|web X's "Connected via" platform
6351
+ // --max-renames 0 never-renamed accounts only
6352
+ // --source "raw string" exact-match on the raw "Connected via" string (power user)
6353
+ //
6354
+ // Pricing = country_price * source_multiplier (multiplier
6355
+ // defaults to 1.0 when no source row exists). Without --country,
6356
+ // falls back to the legacy $5 flat rate.
6285
6357
  const country = (flags.country || '').trim().toUpperCase() || undefined;
6286
6358
  const ageCategory = flags.age || flags['age-category'] || undefined;
6287
6359
  const source = (flags.source || '').trim().toLowerCase() || undefined;
6360
+ const registeredCountry = (flags['registered-country'] || '').trim().toUpperCase() || undefined;
6361
+ const platformFlag = (flags.platform || '').trim().toLowerCase() || undefined;
6362
+ if (platformFlag && !['android', 'ios', 'web'].includes(platformFlag)) {
6363
+ err('--platform must be one of: android, ios, web');
6364
+ }
6365
+ const registeredPlatform = platformFlag;
6288
6366
  const maxRenamesRaw = flags['max-renames'];
6289
6367
  const maxUsernameChanges = maxRenamesRaw === undefined || maxRenamesRaw === ''
6290
6368
  ? undefined
@@ -6294,7 +6372,12 @@ async function main() {
6294
6372
  }
6295
6373
  let data;
6296
6374
  try {
6297
- data = await ao.socialTwitterBuy(country, ageCategory, { source, maxUsernameChanges });
6375
+ data = await ao.socialTwitterBuy(country, ageCategory, {
6376
+ source,
6377
+ registeredCountry,
6378
+ registeredPlatform,
6379
+ maxUsernameChanges,
6380
+ });
6298
6381
  }
6299
6382
  catch (e) {
6300
6383
  err(`Buy failed: ${e.message}`, EXIT.GENERAL);
@@ -6308,6 +6391,8 @@ async function main() {
6308
6391
  // pre-seasoned at pool-add time.
6309
6392
  const filterTags = [
6310
6393
  country && `country=${country}`,
6394
+ registeredCountry && `registered_country=${registeredCountry}`,
6395
+ registeredPlatform && `platform=${registeredPlatform}`,
6311
6396
  source && `source=${source}`,
6312
6397
  maxUsernameChanges !== undefined && `max_renames=${maxUsernameChanges}`,
6313
6398
  ].filter(Boolean).join(', ');
@@ -6323,6 +6408,8 @@ async function main() {
6323
6408
  platform,
6324
6409
  username: summary.username,
6325
6410
  country: account.country,
6411
+ registered_country: account.registered_country,
6412
+ registered_platform: account.registered_platform,
6326
6413
  source: account.source,
6327
6414
  username_change_count: account.username_change_count,
6328
6415
  account_based_in: account.account_based_in,
@@ -6963,6 +7050,8 @@ async function main() {
6963
7050
  }
6964
7051
  case 'tiktok': {
6965
7052
  const sv = await import('./social-vault.js');
7053
+ const sd = await import('./social-drafts.js');
7054
+ const sa = await import('./social-analytics.js');
6966
7055
  const platform = 'tiktok';
6967
7056
  // Same help guard as `twitter` — prevents `--help` from dispatching
6968
7057
  // a paid subcommand. Same bug class lived here too in 1.8.3.
@@ -6971,23 +7060,34 @@ async function main() {
6971
7060
  command: 'tiktok',
6972
7061
  title: 'tiktok',
6973
7062
  subtitle: 'Automated TikTok account management',
6974
- footerLeft: 'BYO: export sessionid from a logged-in TikTok browser, import, then post / follow / like.',
7063
+ footerLeft: 'Start with `connect`: log in once in your own browser, then post / follow / like — all server-side.',
6975
7064
  commands: [
6976
- { name: 'import', description: 'Save a BYO TikTok account. Pass --credentials-line "login:pw:email:email_pw" from a marketplace, or extract cookies from DevTools Application Cookies .tiktok.com and pass --sessionid.', hint: '--credentials-line "..." OR <username> --sessionid ... --csrf ... --webid ...' },
6977
- { name: 'list', description: 'List all local TikTok accounts' },
7065
+ { name: 'connect', description: 'Log in once via your own browser — opens TikTok, you sign in (incl. captcha/2FA), and Palmyr auto-captures the session into the local vault. Auto-creates the account and infers the country from your browser. This is the easy path.', hint: '<username> [--tag <folder>]' },
7066
+ { name: 'import', description: 'Manual fallback to `connect`: save a BYO account from a marketplace --credentials-line "login:pw:email:email_pw", or paste cookies from DevTools → Application → Cookies → .tiktok.com via --sessionid.', hint: '--credentials-line "..." OR <username> --sessionid ... --csrf ... --webid ...' },
7067
+ { name: 'list', description: 'List local TikTok accounts; --tag filters to one folder', hint: '[--tag <folder>]' },
6978
7068
  { name: 'info', description: 'Show one account', hint: '<username>' },
6979
7069
  { name: 'rename', description: 'Update the local handle', hint: '<old> --to <new>' },
7070
+ { name: 'tag', description: 'Group an account under a folder-like tag so one agent can organize 30+ accounts; --clear removes it', hint: '<username> <folder> | <username> --clear' },
6980
7071
  { name: 'remove', description: 'Delete an account from the local vault', hint: '<username> --confirm' },
6981
7072
  { name: 'totp', description: 'Print the current TOTP code', hint: '<username>' },
6982
7073
  { name: 'login', description: 'Validate cookies and cache the session', hint: '<username>' },
6983
7074
  { name: 'session', description: 'Check cached session status', hint: '<username>' },
6984
7075
  { name: 'post', description: 'Post a video', hint: '<username> --file video.mp4 --caption "..."' },
7076
+ { name: 'schedule', description: "Schedule a video via TikTok's own scheduler (~15 min to ~10 days out) — fires even if your machine and our server are off.", hint: '<username> --at 2026-06-03T18:00Z --file v.mp4 --caption "..."' },
6985
7077
  { name: 'follow', description: 'Follow a TikTok user', hint: '<username> --user @handle' },
6986
7078
  { name: 'like', description: 'Like a video', hint: '<username> --video https://...' },
6987
7079
  { name: 'delete', description: 'Delete a video', hint: '<username> --video https://...' },
6988
7080
  { name: 'bio', description: 'Update bio (<=80 chars)', hint: '<username> --text "..."' },
6989
7081
  { name: 'name', description: 'Update display name (<=30 chars)', hint: '<username> --display "..."' },
6990
7082
  { name: 'pfp', description: 'Update avatar', hint: '<username> --file pic.png' },
7083
+ { name: 'draft', description: 'Stage a post for human approval instead of publishing — queues it locally (free).', hint: '<username> --file v.mp4 --caption "..." [--at <iso>]' },
7084
+ { name: 'drafts', description: 'List drafts awaiting approval', hint: '[<username>] [--tag <folder>]' },
7085
+ { name: 'approve', description: 'Publish a queued draft + record it in the post log', hint: '<draft-id>' },
7086
+ { name: 'reject', description: 'Discard a queued draft', hint: '<draft-id>' },
7087
+ { name: 'logs', description: 'Audit log of posts that went out (approved drafts + direct posts)', hint: '[<username>] [--tag <folder>] [--limit N]' },
7088
+ { name: 'analytics', description: 'Scrape per-post views/likes/comments, categorize into tiers vs the account’s own posts, and snapshot the time-series', hint: '<username>' },
7089
+ { name: 'review', description: 'Performance review: best/worst posts, tier mix, engagement, and trend vs the last snapshot — the self-learning surface', hint: '<username>' },
7090
+ { name: 'monitor', description: 'Unattended loop that periodically runs analytics so review stays fresh (mirrors the wallet daemon)', hint: 'tick | start --every 6h | stop | status' },
6991
7091
  ],
6992
7092
  fromHome,
6993
7093
  });
@@ -7087,14 +7187,16 @@ async function main() {
7087
7187
  const summary = sv.importAccount(platform, username, creds, {
7088
7188
  source: line ? 'marketplace-line' : 'import',
7089
7189
  country,
7190
+ tag: flags.tag,
7090
7191
  });
7091
7192
  const loginPath = sessionid ? 'cookie-injection' : 'form-login (requires CAPSOLVER_API_KEY server-side)';
7092
7193
  log(`tiktok import: ${summary.username} (${summary.id}) [login: ${loginPath}, country: ${country}]`);
7093
7194
  return print({ ...summary, has_sessionid: !!sessionid, has_password: !!password, login_path: loginPath });
7094
7195
  }
7095
7196
  case 'list': {
7096
- const accounts = sv.listAccounts(platform);
7097
- return print({ accounts, count: accounts.length });
7197
+ const tagFilter = flags.tag;
7198
+ const accounts = sv.listAccounts(platform, tagFilter);
7199
+ return print({ accounts, count: accounts.length, ...(tagFilter ? { tag: tagFilter } : {}) });
7098
7200
  }
7099
7201
  case 'info': {
7100
7202
  const username = positional[0] || flags.username;
@@ -7116,6 +7218,19 @@ async function main() {
7116
7218
  log(`tiktok rename: ${oldUsername} → ${newUsername}`);
7117
7219
  return print(summary);
7118
7220
  }
7221
+ case 'tag': {
7222
+ // Folder-like grouping so one agent can organize 30+ accounts.
7223
+ const username = positional[0] || flags.username;
7224
+ if (!username)
7225
+ err('<username> required');
7226
+ const clear = !!flags.clear;
7227
+ const newTag = clear ? null : (positional[1] || flags.tag);
7228
+ if (!clear && !newTag)
7229
+ err('provide a tag (e.g. `palmyr tiktok tag <username> brand-x`) or --clear');
7230
+ const summary = sv.tagAccount(platform, username, newTag);
7231
+ log(clear ? `tiktok tag: cleared on ${username}` : `tiktok tag: ${username} → ${newTag}`);
7232
+ return print(summary);
7233
+ }
7119
7234
  case 'remove': {
7120
7235
  const username = positional[0] || flags.username;
7121
7236
  if (!username)
@@ -7144,6 +7259,113 @@ async function main() {
7144
7259
  expires_in_seconds: secondsUntilNextCode(),
7145
7260
  });
7146
7261
  }
7262
+ case 'connect': {
7263
+ // Real-browser login. Launches the operator's own Chrome/Edge,
7264
+ // they sign in (solving any captcha/2FA themselves), and we harvest
7265
+ // the session via CDP — no headless form-driving, no captcha solver.
7266
+ // Agent-smooth: auto-provisions the account, auto-detects login
7267
+ // (no keystroke), and always terminates with structured JSON.
7268
+ const username = positional[0] || flags.username;
7269
+ if (!username)
7270
+ err('<username> required');
7271
+ // --country is optional: if omitted we infer it from the real
7272
+ // browser during login (locale/timezone). Explicit flag overrides.
7273
+ const explicitCountry = flags.country?.toLowerCase();
7274
+ let acc = sv.getAccount(platform, username);
7275
+ // Idempotent: an existing account with a fresh cached session returns
7276
+ // fast, no browser. (New accounts have no session yet.) Lets an agent
7277
+ // call `connect` defensively before a run.
7278
+ if (acc && !flags.force) {
7279
+ const existing = sv.loadSession(acc.id);
7280
+ if (existing && existing.cookies?.length) {
7281
+ const ageH = sv.sessionAgeHours(acc.id) ?? 999;
7282
+ if (ageH < 12) {
7283
+ return print({
7284
+ success: true, platform, username, connected: true, already: true,
7285
+ cookies: existing.cookies.length,
7286
+ age_hours: Number(ageH.toFixed(2)),
7287
+ captured_at: existing.captured_at,
7288
+ hint: 'Session is fresh. Pass --force to re-capture.',
7289
+ });
7290
+ }
7291
+ }
7292
+ }
7293
+ const { connectTikTok } = await import('./tiktok-connect.js');
7294
+ const timeoutSec = flags.timeout !== undefined ? Math.max(30, Number(flags.timeout)) : 300;
7295
+ let hostedLink;
7296
+ const result = await connectTikTok({
7297
+ country: explicitCountry || acc?.country,
7298
+ timeoutMs: timeoutSec * 1000,
7299
+ browserPath: flags['browser-path'],
7300
+ noSandbox: !!flags['no-sandbox'],
7301
+ qr: !!flags.qr,
7302
+ onQr: async (dataUrl) => {
7303
+ // Host the QR so the agent gets a clean link to forward. Best-effort:
7304
+ // if the server lacks the endpoint, the PNG/data-URL still work.
7305
+ try {
7306
+ const hosted = await ao.socialTiktokHostQr(dataUrl);
7307
+ hostedLink = `${ao.api.replace(/\/+$/, '')}/connect/${hosted.token}`;
7308
+ process.stderr.write(`[connect] QR link (send to a human): ${hostedLink}\n`);
7309
+ }
7310
+ catch { /* hosting optional */ }
7311
+ },
7312
+ onProgress: (m) => process.stderr.write(`[connect] ${m}\n`),
7313
+ });
7314
+ if (!result.success) {
7315
+ const details = { platform, username, reason: result.reason };
7316
+ if (result.qrPngPath)
7317
+ details.qr_png_path = result.qrPngPath;
7318
+ if (hostedLink)
7319
+ details.qr_link = hostedLink;
7320
+ if (result.reason === 'no_local_browser') {
7321
+ details.remedy =
7322
+ `No Chrome/Edge/Brave found. Install one, pass --browser-path <path>, or import cookies manually: ` +
7323
+ `open tiktok.com (logged in) → DevTools → Application → Cookies → .tiktok.com, then ` +
7324
+ `palmyr tiktok import ${username} --country us --sessionid <sessionid> --csrf <tt_csrf_token> --webid <tt_webid_v2>`;
7325
+ }
7326
+ err(`connect failed: ${result.error || result.reason}`, EXIT.GENERAL, details);
7327
+ }
7328
+ // Country precedence: existing account keeps its stored value;
7329
+ // otherwise explicit flag > detected-from-browser > env default > us.
7330
+ // Agents never have to know or pass it.
7331
+ const resolvedCountry = acc?.country ||
7332
+ explicitCountry ||
7333
+ result.detectedCountry ||
7334
+ process.env.PALMYR_DEFAULT_COUNTRY?.toLowerCase() ||
7335
+ 'us';
7336
+ const countrySource = acc?.country
7337
+ ? 'account'
7338
+ : explicitCountry
7339
+ ? 'flag'
7340
+ : result.detectedCountry
7341
+ ? 'detected'
7342
+ : 'default';
7343
+ // Auto-create the account now that the country is known (one command,
7344
+ // not import-then-connect).
7345
+ if (!acc) {
7346
+ acc = sv.importAccount(platform, username, { login: username, password: 'unknown' }, { source: 'connect', country: resolvedCountry, tag: flags.tag });
7347
+ process.stderr.write(`[connect] created local account ${username} (${acc.id}) [country: ${resolvedCountry}, ${countrySource}]\n`);
7348
+ }
7349
+ else if (flags.tag) {
7350
+ // Re-connecting an existing account with --tag (re)assigns the folder.
7351
+ sv.tagAccount(platform, username, flags.tag);
7352
+ }
7353
+ // Persist into the same encrypted session cache that post/follow/like read.
7354
+ sv.saveSession(acc.id, platform, result.cookies || []);
7355
+ sv.updateMeta(platform, username, { last_action_at: new Date().toISOString() });
7356
+ return print({
7357
+ success: true, platform, username, connected: true,
7358
+ browser: result.browser,
7359
+ country: resolvedCountry,
7360
+ country_source: countrySource,
7361
+ cookies_captured: result.cookiesCaptured,
7362
+ sessionid_present: true,
7363
+ ...(flags.tag ? { tag: flags.tag } : {}),
7364
+ ...(result.qrPngPath ? { qr_png_path: result.qrPngPath } : {}),
7365
+ ...(hostedLink ? { qr_link: hostedLink } : {}),
7366
+ next: `palmyr tiktok post ${username} --file video.mp4 --caption "..."`,
7367
+ });
7368
+ }
7147
7369
  case 'login': {
7148
7370
  const username = positional[0] || flags.username;
7149
7371
  if (!username)
@@ -7220,7 +7442,202 @@ async function main() {
7220
7442
  stale: (ageHours || 0) > 12,
7221
7443
  });
7222
7444
  }
7445
+ case 'draft': {
7446
+ // Stage a post for human approval — does NOT publish. Free, local.
7447
+ const username = positional[0] || flags.username;
7448
+ if (!username)
7449
+ err('<username> required');
7450
+ const acc = sv.getAccount(platform, username);
7451
+ if (!acc)
7452
+ err(`tiktok account "${username}" not found locally`, EXIT.NOT_FOUND);
7453
+ const caption = flags.caption || flags.body || flags.text;
7454
+ if (!caption)
7455
+ err('--caption "..." required');
7456
+ const filePath = flags.file || flags.path;
7457
+ const videoUrl = flags.url;
7458
+ if (!filePath && !videoUrl)
7459
+ err('--file <local-path> or --url <https-url> required');
7460
+ if (filePath) {
7461
+ const { existsSync, statSync } = await import('fs');
7462
+ if (!existsSync(filePath))
7463
+ err(`File not found: ${filePath}`, EXIT.NOT_FOUND);
7464
+ if (statSync(filePath).size > 100 * 1024 * 1024)
7465
+ err('Video too large (max 100 MB)', EXIT.BAD_INPUT);
7466
+ }
7467
+ const privacy = flags.privacy !== undefined ? Number(flags.privacy) : undefined;
7468
+ let schedule_at;
7469
+ const at = flags.at || flags.when;
7470
+ if (at) {
7471
+ const when = new Date(at);
7472
+ if (isNaN(when.getTime()))
7473
+ err('--at must be a valid ISO-8601 datetime', EXIT.BAD_INPUT);
7474
+ const ms = when.getTime() - Date.now();
7475
+ if (ms < 15 * 60 * 1000)
7476
+ err('--at must be at least ~15 minutes in the future', EXIT.BAD_INPUT);
7477
+ if (ms > 10 * 24 * 60 * 60 * 1000)
7478
+ err('--at must be within ~10 days', EXIT.BAD_INPUT);
7479
+ schedule_at = when.toISOString();
7480
+ }
7481
+ const absFile = filePath ? (await import('path')).resolve(filePath) : undefined;
7482
+ const draft = sd.createDraft({
7483
+ platform, account: username, caption, file: absFile, url: videoUrl,
7484
+ privacy, tag: flags.tag || acc.tag, schedule_at,
7485
+ });
7486
+ log(`tiktok draft ${draft.id} staged for ${username} (awaiting approval)`);
7487
+ return print({
7488
+ draft_id: draft.id, status: draft.status, account: username, caption,
7489
+ ...(draft.tag ? { tag: draft.tag } : {}), ...(schedule_at ? { schedule_at } : {}),
7490
+ next: `palmyr tiktok approve ${draft.id}`,
7491
+ });
7492
+ }
7493
+ case 'drafts': {
7494
+ const drafts = sd.listDrafts({ platform, account: positional[0] || flags.account, tag: flags.tag });
7495
+ return print({
7496
+ drafts: drafts.map(d => ({ id: d.id, account: d.account, caption: d.caption, privacy: d.privacy, tag: d.tag, schedule_at: d.schedule_at, created_at: d.created_at })),
7497
+ count: drafts.length,
7498
+ });
7499
+ }
7500
+ case 'reject': {
7501
+ const id = positional[0] || flags.id;
7502
+ if (!id)
7503
+ err('<draft-id> required');
7504
+ if (!sd.deleteDraft(id))
7505
+ err(`draft "${id}" not found`, EXIT.NOT_FOUND);
7506
+ log(`tiktok reject ${id}`);
7507
+ return print({ rejected: true, draft_id: id });
7508
+ }
7509
+ case 'logs': {
7510
+ const entries = sd.readPostLog({
7511
+ platform, account: positional[0] || flags.account,
7512
+ tag: flags.tag, limit: flags.limit ? Number(flags.limit) : 20,
7513
+ });
7514
+ return print({ posts: entries, count: entries.length });
7515
+ }
7516
+ case 'approve': {
7517
+ // Publish a queued draft from the human-in-the-loop flow + log it.
7518
+ const id = positional[0] || flags.id;
7519
+ if (!id)
7520
+ err('<draft-id> required');
7521
+ const draft = sd.getDraft(id);
7522
+ if (!draft)
7523
+ err(`draft "${id}" not found`, EXIT.NOT_FOUND);
7524
+ const acc = sv.getAccount(platform, draft.account);
7525
+ if (!acc)
7526
+ err(`account "${draft.account}" for draft ${id} not found locally`, EXIT.NOT_FOUND);
7527
+ const sess = sv.loadSession(acc.id);
7528
+ if (!sess || !sess.cookies || sess.cookies.length === 0) {
7529
+ err(`No cached session for ${draft.account}. Run 'tiktok connect ${draft.account}' first.`, EXIT.NOT_FOUND);
7530
+ }
7531
+ const psid = sv.getProxySessionId(platform, draft.account);
7532
+ const country = sv.getCountry(platform, draft.account);
7533
+ const media = {};
7534
+ if (draft.file) {
7535
+ const { readFileSync, existsSync, statSync } = await import('fs');
7536
+ if (!existsSync(draft.file))
7537
+ err(`Draft video no longer exists at ${draft.file}`, EXIT.NOT_FOUND);
7538
+ if (statSync(draft.file).size > 100 * 1024 * 1024)
7539
+ err('Video too large (max 100 MB)', EXIT.BAD_INPUT);
7540
+ media.video_base64 = `data:video/mp4;base64,${readFileSync(draft.file).toString('base64')}`;
7541
+ }
7542
+ else {
7543
+ media.video_url = draft.url;
7544
+ }
7545
+ let data;
7546
+ try {
7547
+ data = await ao.socialTiktokPost(acc.id, sess.cookies, draft.caption, media, { privacy: draft.privacy, schedule_at: draft.schedule_at }, psid, country);
7548
+ }
7549
+ catch (e) {
7550
+ err(`approve failed: ${e.message}`, EXIT.GENERAL);
7551
+ }
7552
+ if (!data?.success) {
7553
+ // Keep the draft so it can be retried (e.g. after re-connecting a stale session).
7554
+ err(`approve failed: ${data?.error || 'unknown'}${data?.error_code ? ` [${data.error_code}]` : ''}`, EXIT.GENERAL);
7555
+ }
7556
+ sv.updateMeta(platform, draft.account, { last_action_at: new Date().toISOString() });
7557
+ const entry = sd.appendPostLog({ platform, account: draft.account, caption: draft.caption, source: 'draft', status: draft.schedule_at ? 'scheduled' : 'posted', url: data?.data?.video_url, tag: draft.tag, draft_id: id, result: data?.data });
7558
+ sd.deleteDraft(id);
7559
+ log(`tiktok approve ${id} → ${entry.status} for ${draft.account}`);
7560
+ return print({ approved: true, draft_id: id, account: draft.account, status: entry.status, ...(data?.data || {}) });
7561
+ }
7562
+ case 'analytics': {
7563
+ const username = positional[0] || flags.username;
7564
+ if (!username)
7565
+ err('<username> required');
7566
+ const acc = sv.getAccount(platform, username);
7567
+ if (!acc)
7568
+ err(`tiktok account "${username}" not found locally`, EXIT.NOT_FOUND);
7569
+ const sess = sv.loadSession(acc.id);
7570
+ if (!sess || !sess.cookies || sess.cookies.length === 0) {
7571
+ err(`No cached session for ${username}. Run 'tiktok connect ${username}' first.`, EXIT.NOT_FOUND);
7572
+ }
7573
+ const psid = sv.getProxySessionId(platform, username);
7574
+ const country = sv.getCountry(platform, username);
7575
+ let data;
7576
+ try {
7577
+ data = await ao.socialTiktokAnalytics(acc.id, sess.cookies, psid, country);
7578
+ }
7579
+ catch (e) {
7580
+ err(`analytics failed: ${e.message}`, EXIT.GENERAL);
7581
+ }
7582
+ if (!data?.success) {
7583
+ err(`analytics failed: ${data?.error || 'unknown'}${data?.error_code ? ` [${data.error_code}]` : ''}`, EXIT.GENERAL);
7584
+ }
7585
+ // Categorize (relative tiers) + snapshot to the local time-series so
7586
+ // `review` can show trends. `tiktok monitor` calls this same op.
7587
+ const snap = sa.appendSnapshot(username, data?.data?.posts || []);
7588
+ return print({ platform, username, scraped_at: data?.data?.scraped_at, summary: snap.summary, posts: snap.posts });
7589
+ }
7590
+ case 'review': {
7591
+ const username = positional[0] || flags.username;
7592
+ if (!username)
7593
+ err('<username> required');
7594
+ return print(sa.review(username));
7595
+ }
7596
+ case 'monitor': {
7597
+ // Unattended self-learning loop: periodically run `analytics` (scrape
7598
+ // + categorize + snapshot) for the monitored accounts. Mirrors the
7599
+ // wallet daemon — tick / start / stop / status.
7600
+ const sm = await import('./social-monitor.js');
7601
+ const sub = positional[0] || 'status';
7602
+ const cliPath = process.argv[1];
7603
+ const resolveAccounts = () => {
7604
+ const flagAcc = flags.account;
7605
+ if (flagAcc)
7606
+ return flagAcc.split(',').map((s) => s.trim()).filter(Boolean);
7607
+ return sv.listAccounts(platform).map((a) => a.username);
7608
+ };
7609
+ if (sub === 'tick') {
7610
+ const accounts = resolveAccounts();
7611
+ if (!accounts.length)
7612
+ err('No TikTok accounts to monitor — connect one first.', EXIT.NOT_FOUND);
7613
+ return print({ ticked: accounts.length, results: sm.monitorTick(cliPath, accounts) });
7614
+ }
7615
+ if (sub === 'start') {
7616
+ const accounts = resolveAccounts();
7617
+ if (!accounts.length)
7618
+ err('No TikTok accounts to monitor — connect one first.', EXIT.NOT_FOUND);
7619
+ const intervalSeconds = sm.parseInterval(flags.every || flags.interval, 21600);
7620
+ const { pid } = sm.startMonitor(cliPath, { intervalSeconds, accounts });
7621
+ return print({ started: true, pid, every_seconds: intervalSeconds, accounts });
7622
+ }
7623
+ if (sub === 'stop') {
7624
+ const r = await sm.stopMonitor();
7625
+ return print({ stopped: r.wasRunning, pid: r.pid });
7626
+ }
7627
+ if (sub === 'status') {
7628
+ return print(sm.monitorStatus());
7629
+ }
7630
+ if (sub === '_run') {
7631
+ const intervalSeconds = sm.parseInterval(flags.interval, 21600);
7632
+ const accounts = resolveAccounts();
7633
+ await sm.runMonitorLoop(cliPath, { intervalSeconds, accounts });
7634
+ return;
7635
+ }
7636
+ err(`Unknown monitor action: ${sub}. Try: tick, start, stop, status`, EXIT.BAD_INPUT);
7637
+ return;
7638
+ }
7223
7639
  case 'post':
7640
+ case 'schedule':
7224
7641
  case 'follow':
7225
7642
  case 'like':
7226
7643
  case 'delete':
@@ -7241,7 +7658,7 @@ async function main() {
7241
7658
  const country = sv.getCountry(platform, username);
7242
7659
  let data;
7243
7660
  try {
7244
- if (subcommand === 'post') {
7661
+ if (subcommand === 'post' || subcommand === 'schedule') {
7245
7662
  const caption = flags.caption || flags.body || flags.text;
7246
7663
  if (!caption)
7247
7664
  err('--caption "..." required');
@@ -7264,7 +7681,28 @@ async function main() {
7264
7681
  media.video_url = videoUrl;
7265
7682
  }
7266
7683
  const privacy = flags.privacy !== undefined ? Number(flags.privacy) : undefined;
7267
- data = await ao.socialTiktokPost(acc.id, sess.cookies, caption, media, { privacy }, psid, country);
7684
+ // `schedule` is `post` with a future time drives TikTok's own
7685
+ // scheduler. Validate the window client-side for fast feedback;
7686
+ // the server re-checks and renders it into the account timezone.
7687
+ let schedule_at;
7688
+ if (subcommand === 'schedule') {
7689
+ const at = flags.at || flags.when;
7690
+ if (!at)
7691
+ err('--at <iso8601> required (e.g. --at 2026-06-03T18:00:00Z)');
7692
+ const when = new Date(at);
7693
+ if (isNaN(when.getTime()))
7694
+ err('--at must be a valid ISO-8601 datetime (e.g. 2026-06-03T18:00:00Z)', EXIT.BAD_INPUT);
7695
+ const ms = when.getTime() - Date.now();
7696
+ if (ms < 15 * 60 * 1000)
7697
+ err('--at must be at least ~15 minutes in the future (TikTok minimum)', EXIT.BAD_INPUT);
7698
+ if (ms > 10 * 24 * 60 * 60 * 1000)
7699
+ err('--at must be within ~10 days (TikTok maximum)', EXIT.BAD_INPUT);
7700
+ schedule_at = when.toISOString();
7701
+ }
7702
+ data = await ao.socialTiktokPost(acc.id, sess.cookies, caption, media, { privacy, schedule_at }, psid, country);
7703
+ if (data?.success) {
7704
+ sd.appendPostLog({ platform, account: username, caption, source: 'direct', status: schedule_at ? 'scheduled' : 'posted', url: data?.data?.video_url, tag: acc.tag, result: data?.data });
7705
+ }
7268
7706
  }
7269
7707
  else if (subcommand === 'follow') {
7270
7708
  const target = flags.user || flags.target;
@@ -7328,7 +7766,7 @@ async function main() {
7328
7766
  return print({ success: true, platform, username, op: subcommand, ...(data?.data || {}) });
7329
7767
  }
7330
7768
  default:
7331
- err(`Unknown tiktok command: ${subcommand}. Try: import, list, info, rename, remove, totp, login, session, post, follow, like, delete, bio, name, pfp`);
7769
+ err(`Unknown tiktok command: ${subcommand}. Try: connect, import, list, info, rename, tag, remove, totp, login, session, post, schedule, draft, drafts, approve, reject, logs, analytics, review, monitor, follow, like, delete, bio, name, pfp`);
7332
7770
  }
7333
7771
  break;
7334
7772
  }