@palmyr/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +731 -0
  2. package/dist/admin-auth.d.ts +1 -0
  3. package/dist/admin-auth.js +52 -0
  4. package/dist/admin-auth.js.map +1 -0
  5. package/dist/app.d.ts +182 -0
  6. package/dist/app.js +218 -0
  7. package/dist/app.js.map +1 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +3495 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/compute-ssh.d.ts +246 -0
  12. package/dist/compute-ssh.js +577 -0
  13. package/dist/compute-ssh.js.map +1 -0
  14. package/dist/config.d.ts +46 -0
  15. package/dist/config.js +183 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/credential-store.d.ts +4 -0
  18. package/dist/credential-store.js +180 -0
  19. package/dist/credential-store.js.map +1 -0
  20. package/dist/mascot-data.d.ts +1 -0
  21. package/dist/mascot-data.js +14 -0
  22. package/dist/mascot-data.js.map +1 -0
  23. package/dist/pay.d.ts +60 -0
  24. package/dist/pay.js +483 -0
  25. package/dist/pay.js.map +1 -0
  26. package/dist/sdk.d.ts +259 -0
  27. package/dist/sdk.js +944 -0
  28. package/dist/sdk.js.map +1 -0
  29. package/dist/social-queue.d.ts +125 -0
  30. package/dist/social-queue.js +340 -0
  31. package/dist/social-queue.js.map +1 -0
  32. package/dist/social-vault.d.ts +118 -0
  33. package/dist/social-vault.js +268 -0
  34. package/dist/social-vault.js.map +1 -0
  35. package/dist/social-worker.d.ts +43 -0
  36. package/dist/social-worker.js +155 -0
  37. package/dist/social-worker.js.map +1 -0
  38. package/dist/totp.d.ts +2 -0
  39. package/dist/totp.js +46 -0
  40. package/dist/totp.js.map +1 -0
  41. package/dist/ui.d.ts +77 -0
  42. package/dist/ui.js +441 -0
  43. package/dist/ui.js.map +1 -0
  44. package/dist/vault.d.ts +65 -0
  45. package/dist/vault.js +455 -0
  46. package/dist/vault.js.map +1 -0
  47. package/package.json +75 -0
package/dist/cli.js ADDED
@@ -0,0 +1,3495 @@
1
+ #!/usr/bin/env node
2
+ // Load .env from CWD if present so users only maintain one config file for both
3
+ // the server and the CLI. Process env (set in shell) still wins over .env.
4
+ import 'dotenv/config';
5
+ // Silence the noisy `bigint: Failed to load bindings, pure JS will be used`
6
+ // warning from bigint-buffer (transitive dep of @solana/web3.js). The pure JS
7
+ // fallback is fine for CLI one-shot use — the warning is cosmetic noise.
8
+ const __origWarn = console.warn;
9
+ console.warn = (...args) => {
10
+ const msg = typeof args[0] === 'string' ? args[0] : '';
11
+ if (msg.startsWith('bigint: Failed to load bindings'))
12
+ return;
13
+ __origWarn.apply(console, args);
14
+ };
15
+ import React from 'react';
16
+ import { render as inkRender } from 'ink';
17
+ import { ConfigScreen, Dashboard, DoctorScreen, DomainCheckScreen, DomainPricingScreen, ErrorScreen, HealthScreen, MenuScreen, PricingScreen, RecordsScreen, SetupScreen, StatusScreen, SuccessScreen, WalletCreateScreen, WalletStatusScreen, WalletListScreen } from './app.js';
18
+ import { Palmyr } from './sdk.js';
19
+ import { loadConfig, saveConfig, ensureDirs, log, addPhone, addDomain, addNote } from './config.js';
20
+ import { theme as t, Spinner, setAgentMode as setUiAgentMode } from './ui.js';
21
+ import { existsSync, readFileSync } from 'fs';
22
+ import { homedir } from 'os';
23
+ import { fileURLToPath } from 'url';
24
+ import { dirname, extname, join } from 'path';
25
+ // Alias for backwards compat in help text
26
+ const c = { ...t, cyan: t.info, green: t.success, red: t.error, yellow: t.warn, white: t.text, gray: t.muted, orange: t.accent };
27
+ // Read version from package.json so the binary and the published version
28
+ // can never drift. dist/cli.js sits next to ../package.json after build.
29
+ const VERSION = (() => {
30
+ try {
31
+ const here = dirname(fileURLToPath(import.meta.url));
32
+ return JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version;
33
+ }
34
+ catch {
35
+ return '0.0.0';
36
+ }
37
+ })();
38
+ // ─── Exit codes ───
39
+ //
40
+ // These are part of the agent-facing CLI contract — agents branch on $? to
41
+ // distinguish "wrong flag" from "no funds" from "API unreachable". Treat
42
+ // them as semver-stable. New error categories get new codes; never repurpose
43
+ // an existing one.
44
+ const EXIT = {
45
+ OK: 0,
46
+ GENERAL: 1,
47
+ BAD_INPUT: 2,
48
+ AUTH_FAIL: 3,
49
+ NOT_FOUND: 4,
50
+ NETWORK: 5,
51
+ PAYMENT: 6,
52
+ SECURITY: 7,
53
+ };
54
+ const EXIT_CODE_DOCS = [
55
+ { code: 0, name: 'OK', description: 'Success' },
56
+ { code: 1, name: 'GENERAL', description: 'Unspecified failure' },
57
+ { code: 2, name: 'BAD_INPUT', description: 'Missing or invalid flag/argument' },
58
+ { code: 3, name: 'AUTH_FAIL', description: 'Authentication failed (bad token/session)' },
59
+ { code: 4, name: 'NOT_FOUND', description: 'Wallet/resource not found' },
60
+ { code: 5, name: 'NETWORK', description: 'API unreachable or transient network error' },
61
+ { code: 6, name: 'PAYMENT', description: 'x402 payment verification or settlement failed' },
62
+ { code: 7, name: 'SECURITY', description: 'Vault tamper / security check failed — do not retry' },
63
+ ];
64
+ function render(node) {
65
+ return inkRender(node);
66
+ }
67
+ // ─── Parse args ───
68
+ // Boolean flags that never take a value (prevents flag <next> from eating the next positional)
69
+ // `json` and `no-color` are agent-mode toggles; the rest are command-specific.
70
+ // Boolean flags never consume the next argv token — important so `palmyr
71
+ // wallet list --json` doesn't try to swallow whatever comes after.
72
+ const BOOLEAN_FLAGS = new Set([
73
+ 'help', 'version', 'managed', 'quiet', 'confirm', 'json', 'no-color',
74
+ // compute deploy/ssh flags
75
+ 'wait', 'generate-ssh-key', 'generate', 'progress',
76
+ ]);
77
+ function parse(argv) {
78
+ const flags = {};
79
+ const positional = [];
80
+ let command = '';
81
+ let subcommand = '';
82
+ // After we hit a bare `--`, every remaining argv element is a positional —
83
+ // even if it starts with a dash. Lets `palmyr compute exec my-vps --
84
+ // systemctl status --no-pager openclaw` pass `--no-pager` through to the
85
+ // remote shell instead of being swallowed as a CLI flag.
86
+ let inPositionalRun = false;
87
+ for (let i = 2; i < argv.length; i++) {
88
+ const arg = argv[i];
89
+ if (inPositionalRun) {
90
+ positional.push(arg);
91
+ continue;
92
+ }
93
+ if (arg === '--') {
94
+ inPositionalRun = true;
95
+ continue;
96
+ }
97
+ if (!command && !arg.startsWith('-')) {
98
+ command = arg;
99
+ continue;
100
+ }
101
+ if (command && !subcommand && !arg.startsWith('-')) {
102
+ subcommand = arg;
103
+ continue;
104
+ }
105
+ if (arg.startsWith('--')) {
106
+ // Handle --key=value syntax
107
+ const eqIdx = arg.indexOf('=');
108
+ if (eqIdx !== -1) {
109
+ flags[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
110
+ continue;
111
+ }
112
+ // Handle --no-prefix as boolean false
113
+ const key = arg.slice(2);
114
+ if (key.startsWith('no-')) {
115
+ flags[key.slice(3)] = false;
116
+ continue;
117
+ }
118
+ // Known boolean flags — never consume the next arg
119
+ if (BOOLEAN_FLAGS.has(key)) {
120
+ flags[key] = true;
121
+ continue;
122
+ }
123
+ // Otherwise: next arg is the value (if it exists and isn't a flag)
124
+ const next = argv[i + 1];
125
+ if (next && !next.startsWith('-')) {
126
+ flags[key] = next;
127
+ i++;
128
+ }
129
+ else
130
+ flags[key] = true;
131
+ }
132
+ else {
133
+ positional.push(arg);
134
+ }
135
+ }
136
+ return { command, subcommand, positional, flags };
137
+ }
138
+ // Global agent-mode flag — set early in main() based on TTY detection + the
139
+ // --json flag. When true, every output path emits structured JSON (stdout for
140
+ // data, stderr for errors) and skips Ink rendering and ANSI decoration. This
141
+ // is the contract agents rely on: pipe stdout into jq, check $? against the
142
+ // EXIT table, parse stderr for the {error, exitCode} object on failure.
143
+ let AGENT_MODE = !process.stdout.isTTY;
144
+ function err(msg, code = EXIT.BAD_INPUT) {
145
+ if (AGENT_MODE) {
146
+ process.stderr.write(JSON.stringify({ error: msg, exitCode: code }) + '\n');
147
+ process.exit(code);
148
+ }
149
+ render(React.createElement(ErrorScreen, {
150
+ version: VERSION,
151
+ title: 'Command error',
152
+ message: msg,
153
+ footerLeft: 'Fix the command and retry',
154
+ }));
155
+ process.exit(code);
156
+ }
157
+ /** Show per-subcommand help with flag descriptions */
158
+ function subcommandHelp(command, subcommand, options) {
159
+ if (AGENT_MODE) {
160
+ print({ command, subcommand, options });
161
+ return;
162
+ }
163
+ console.log(`\n ${t.accent}palmyr ${command} ${subcommand}${t.reset}\n`);
164
+ for (const opt of options) {
165
+ const flagStr = ` ${t.info}${opt.flag.padEnd(24)}${t.reset}`;
166
+ const hintStr = opt.hint ? ` ${t.muted}${opt.hint}${t.reset}` : '';
167
+ console.log(`${flagStr}${opt.desc}${hintStr}`);
168
+ }
169
+ console.log();
170
+ }
171
+ // ─── Subcommand help definitions ───
172
+ const WALLET_HELP = {
173
+ create: [
174
+ { flag: '--name <name>', desc: 'Wallet name', hint: 'default: "My Wallet"' },
175
+ { flag: '--managed', desc: 'Create managed wallet with human oversight via passkey' },
176
+ { flag: '--chains <list>', desc: 'Supported chains (comma-separated)', hint: 'default: solana,evm' },
177
+ ],
178
+ import: [
179
+ { flag: '--mnemonic <words>', desc: 'BIP-39 mnemonic phrase (required)' },
180
+ { flag: '--name <name>', desc: 'Wallet name', hint: 'default: "Imported Wallet"' },
181
+ { flag: '--managed', desc: 'Import as managed wallet' },
182
+ ],
183
+ 'sign-message': [
184
+ { flag: '<WALLET_ID>', desc: 'Wallet ID (positional or --id)' },
185
+ { flag: '--chain <chain>', desc: 'Chain to sign on (required)', hint: 'solana | evm' },
186
+ { flag: '--msg <message>', desc: 'Message to sign (required)' },
187
+ ],
188
+ 'api-key': [
189
+ { flag: '<WALLET_ID>', desc: 'Wallet ID (positional or --id)' },
190
+ { flag: '--name <name>', desc: 'API key name', hint: 'default: "cli-agent"' },
191
+ ],
192
+ 'request-approval': [
193
+ { flag: '<WALLET_ID>', desc: 'Wallet ID (positional or --id)' },
194
+ { flag: '--action <type>', desc: 'Approval action', hint: 'default: "limits"' },
195
+ { flag: '--daily <usdc>', desc: 'Requested daily USDC limit' },
196
+ { flag: '--tx <usdc>', desc: 'Requested per-tx USDC limit' },
197
+ ],
198
+ export: [
199
+ { flag: '<WALLET_ID>', desc: 'Wallet ID (positional or --id)' },
200
+ { flag: '--confirm', desc: 'Confirm you understand the risk of exposing the mnemonic' },
201
+ ],
202
+ use: [
203
+ { flag: '<WALLET_ID>', desc: 'Wallet ID to use for x402 payments (positional or --id)' },
204
+ { flag: '--chain <chain>', desc: 'Which chain to pay on', hint: 'solana (default) | base' },
205
+ ],
206
+ };
207
+ /**
208
+ * Render a per-command menu (no subcommand given). On a TTY → Ink MenuScreen
209
+ * with the Palmyr aesthetic. In agent mode → flat JSON listing the available
210
+ * subcommands so an agent can drive discovery (e.g. `palmyr phone --json`
211
+ * → `{"command":"phone","subcommands":[{"name":"search",...}, ...]}`).
212
+ */
213
+ function showMenu(opts) {
214
+ if (AGENT_MODE) {
215
+ print({ command: opts.command, subcommands: opts.commands });
216
+ return;
217
+ }
218
+ render(React.createElement(MenuScreen, {
219
+ version: VERSION,
220
+ title: opts.title,
221
+ subtitle: opts.subtitle,
222
+ footerLeft: opts.footerLeft,
223
+ commands: opts.commands,
224
+ interactive: opts.fromHome,
225
+ onBack: opts.fromHome ? () => {
226
+ process.env.PALMYR_FROM_HOME = '0';
227
+ process.argv = [process.argv[0], process.argv[1]];
228
+ void main();
229
+ } : undefined,
230
+ }));
231
+ }
232
+ function print(obj) {
233
+ const json = JSON.stringify(obj, null, 2);
234
+ // Plain JSON in agent mode (stdout is piped, --json is set, or PALMYR_JSON
235
+ // env is on). On a real TTY without --json, color the keys so humans get a
236
+ // little visual aid — but the structure is still valid JSON either way.
237
+ if (AGENT_MODE) {
238
+ console.log(json);
239
+ }
240
+ else {
241
+ const colored = json
242
+ .replace(/"([^"]+)":/g, `${t.info}"$1"${t.reset}:`)
243
+ .replace(/: "([^"]+)"/g, `: ${t.success}"$1"${t.reset}`)
244
+ .replace(/: (\d+)/g, `: ${t.warn}$1${t.reset}`)
245
+ .replace(/: (true|false)/g, `: ${t.accent}$1${t.reset}`)
246
+ .replace(/: (null)/g, `: ${t.muted}$1${t.reset}`);
247
+ console.log(colored);
248
+ }
249
+ }
250
+ /**
251
+ * Compact formatter for chat-run step outputs in the summary line. Picks the
252
+ * most-useful 2-4 fields per step (id, address, status flags) without dumping
253
+ * the entire JSON. Falls back to the full object for unknown shapes.
254
+ */
255
+ /**
256
+ * Trim long upstream errors (HTML pages, multi-paragraph stack traces) down
257
+ * to a one-liner the terminal can display without scrolling. Strips HTML
258
+ * tags, collapses whitespace, and clips to ~200 chars with a length hint.
259
+ */
260
+ function truncateError(msg, max = 200) {
261
+ if (!msg)
262
+ return '(no error message)';
263
+ const stripped = msg.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
264
+ if (stripped.length <= max)
265
+ return stripped;
266
+ return `${stripped.slice(0, max)}… [+${stripped.length - max} chars; rerun with --verbose for full body]`;
267
+ }
268
+ function formatStepOutput(output) {
269
+ if (output == null)
270
+ return '—';
271
+ if (typeof output !== 'object')
272
+ return String(output);
273
+ // Common shapes we want a clean one-liner for.
274
+ if (output.inbox?.address) {
275
+ const i = output.inbox;
276
+ const flags = [];
277
+ if (output.dnsApplied === true)
278
+ flags.push('dns ✓');
279
+ if (output.mailgunRegistered === true)
280
+ flags.push(`mailgun=${output.mailgunStatus ?? 'pending'}`);
281
+ if (output.sendingStatus)
282
+ flags.push(`send: ${output.sendingStatus.split(/ [—-] /)[0]}`);
283
+ return `${i.address} (id ${i.id})${flags.length ? ' ' + flags.join(' ') : ''}`;
284
+ }
285
+ if (Array.isArray(output.inboxes))
286
+ return `${output.inboxes.length} inbox(es)`;
287
+ if (Array.isArray(output.numbers))
288
+ return `${output.numbers.length} number(s)`;
289
+ if (Array.isArray(output.servers))
290
+ return `${output.servers.length} server(s)`;
291
+ if (Array.isArray(output.domains))
292
+ return `${output.domains.length} domain(s)`;
293
+ if (Array.isArray(output.calls))
294
+ return `${output.calls.length} call(s)`;
295
+ if (Array.isArray(output.sshKeys))
296
+ return `${output.sshKeys.length} ssh key(s)`;
297
+ // Pull the most-useful keys for unknown single-object shapes.
298
+ const keys = ['id', 'address', 'phoneNumber', 'callControlId', 'serverId', 'ipv4', 'domain', 'status', 'message'];
299
+ const parts = [];
300
+ for (const k of keys) {
301
+ const v = output[k];
302
+ if (v !== undefined && v !== null)
303
+ parts.push(`${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`);
304
+ if (parts.length >= 4)
305
+ break;
306
+ }
307
+ return parts.length ? parts.join(' ') : JSON.stringify(output).slice(0, 200);
308
+ }
309
+ // Single source of truth for the top-level command catalog. Used by both the
310
+ // Ink help screen and the JSON path so agents and humans see the same surface.
311
+ const TOP_LEVEL_COMMANDS = [
312
+ { name: 'phone', description: 'search · buy · sms · call' },
313
+ { name: 'email', description: 'create · read · send · threads' },
314
+ { name: 'compute', description: 'plans · deploy · list · delete' },
315
+ { name: 'domain', description: 'check · pricing · buy · dns' },
316
+ { name: 'wallet', description: 'create · import · list · export · sign · api-key' },
317
+ { name: 'setup', description: 'Configure wallets + chain preference' },
318
+ { name: 'status', description: 'Show config, wallets, and API health' },
319
+ { name: 'config', description: 'Show current configuration' },
320
+ { name: 'doctor', description: 'Verify system health (cred store, vault, API)' },
321
+ { name: 'pricing', description: 'All service prices' },
322
+ { name: 'health', description: 'API status + version check' },
323
+ ];
324
+ // ─── Help ───
325
+ function help() {
326
+ if (AGENT_MODE) {
327
+ print({
328
+ version: VERSION,
329
+ commands: TOP_LEVEL_COMMANDS,
330
+ flags: {
331
+ global: [
332
+ { flag: '--json', desc: 'Force machine-parseable JSON output (auto-on when stdout isn\'t a TTY)' },
333
+ { flag: '--quiet', desc: 'Suppress decorative log lines' },
334
+ { flag: '--token <api-key>', desc: 'Bearer token for authenticated calls' },
335
+ { flag: '--passphrase <pass>', desc: 'Wallet passphrase (or PALMYR_WALLET_PASSPHRASE env)' },
336
+ ],
337
+ },
338
+ exitCodes: EXIT_CODE_DOCS,
339
+ });
340
+ return;
341
+ }
342
+ render(React.createElement(MenuScreen, {
343
+ version: VERSION,
344
+ title: 'help',
345
+ subtitle: 'Command surface',
346
+ footerLeft: 'Structured JSON output for all commands',
347
+ commands: TOP_LEVEL_COMMANDS,
348
+ }));
349
+ }
350
+ // ─── Commands ───
351
+ async function main() {
352
+ const { command, subcommand, positional, flags } = parse(process.argv);
353
+ const fromHome = process.env.PALMYR_FROM_HOME === '1';
354
+ // Agent-mode detection: piped stdout (no TTY) or explicit --json. Once set,
355
+ // it drives everything — Ink screens flip to JSON output, Spinner/decorators
356
+ // self-suppress (see ui.ts:setAgentMode), and err() stringifies to stderr.
357
+ // Honor PALMYR_JSON=1 too so agents can opt in via env var when their
358
+ // runtime allocates a TTY they can't easily suppress.
359
+ AGENT_MODE = !process.stdout.isTTY || !!flags.json || process.env.PALMYR_JSON === '1';
360
+ setUiAgentMode(AGENT_MODE);
361
+ if (flags.version) {
362
+ if (AGENT_MODE)
363
+ print({ version: VERSION });
364
+ else
365
+ console.log(VERSION);
366
+ return;
367
+ }
368
+ if (flags.help && !command) {
369
+ help();
370
+ return;
371
+ }
372
+ // No command — show welcome dashboard (agents get a JSON listing of the
373
+ // top-level command surface so they can drive discovery programmatically).
374
+ if (!command) {
375
+ const cfg = loadConfig();
376
+ let apiOk = false;
377
+ try {
378
+ const h = await new Palmyr(cfg.api).health();
379
+ apiOk = h.status === 'healthy';
380
+ }
381
+ catch { }
382
+ if (AGENT_MODE) {
383
+ print({
384
+ version: VERSION,
385
+ chain: cfg.defaultChain,
386
+ wallets: cfg.wallets || {},
387
+ apiOk,
388
+ commands: TOP_LEVEL_COMMANDS,
389
+ });
390
+ return;
391
+ }
392
+ render(React.createElement(Dashboard, {
393
+ version: VERSION,
394
+ chain: cfg.defaultChain,
395
+ wallets: cfg.wallets,
396
+ apiOk,
397
+ }));
398
+ return;
399
+ }
400
+ // Always ensure ~/.palmyr/ exists on any command
401
+ ensureDirs();
402
+ const config = loadConfig();
403
+ const startTime = Date.now();
404
+ // No first-time banner — agent-first CLI should never pollute output.
405
+ const url = process.env.PALMYR_API || config.api;
406
+ const token = flags.token || config.apiKey || process.env.PALMYR_TOKEN || process.env.PALMYR_API_KEY;
407
+ const passphrase = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE;
408
+ const ao = new Palmyr(url, true, token, passphrase);
409
+ try {
410
+ switch (command) {
411
+ case 'setup': {
412
+ ensureDirs();
413
+ const keyfile = flags.keyfile
414
+ || process.env.PALMYR_KEYFILE
415
+ || (() => {
416
+ const defaultPath = homedir() + '/.config/solana/id.json';
417
+ if (existsSync(defaultPath))
418
+ return defaultPath;
419
+ return '';
420
+ })();
421
+ const chain = (flags.chain || 'solana');
422
+ if (!keyfile) {
423
+ err('No keyfile. Pass --keyfile /path/to/keypair.json --chain solana', EXIT.BAD_INPUT);
424
+ }
425
+ if (!existsSync(keyfile.replace('~', homedir()))) {
426
+ err(`Keyfile not found: ${keyfile}`, EXIT.NOT_FOUND);
427
+ }
428
+ const { addWalletToConfig, getConfiguredChains } = await import('./config.js');
429
+ addWalletToConfig(chain, keyfile);
430
+ const chains = getConfiguredChains();
431
+ if (AGENT_MODE) {
432
+ print({ ok: true, api: url, keyfile, chains, addedChain: chain });
433
+ }
434
+ else {
435
+ render(React.createElement(SetupScreen, {
436
+ version: VERSION,
437
+ api: url,
438
+ keyfile,
439
+ chains,
440
+ addedChain: chain,
441
+ }));
442
+ }
443
+ log(`setup: keyfile=${keyfile} chain=${chain}`);
444
+ break;
445
+ }
446
+ case 'status': {
447
+ const wallets = config.wallets || {};
448
+ let apiOk = false;
449
+ try {
450
+ const h = await ao.health();
451
+ apiOk = h.status === 'healthy';
452
+ }
453
+ catch { }
454
+ if (AGENT_MODE) {
455
+ print({
456
+ api: config.api,
457
+ apiOk,
458
+ wallets,
459
+ defaultChain: config.defaultChain || 'solana',
460
+ });
461
+ }
462
+ else {
463
+ render(React.createElement(StatusScreen, {
464
+ version: VERSION,
465
+ api: config.api,
466
+ apiOk,
467
+ wallets,
468
+ defaultChain: config.defaultChain || 'solana',
469
+ interactive: fromHome,
470
+ onBack: fromHome ? () => {
471
+ process.env.PALMYR_FROM_HOME = '0';
472
+ process.argv = [process.argv[0], process.argv[1]];
473
+ void main();
474
+ } : undefined,
475
+ }));
476
+ }
477
+ break;
478
+ }
479
+ case 'note': {
480
+ const text = positional.join(' ') || subcommand || '';
481
+ if (!text)
482
+ err('Usage: palmyr note "your note here"');
483
+ addNote(text);
484
+ if (AGENT_MODE) {
485
+ print({ ok: true, note: text, path: '~/.palmyr/memory/notes.md' });
486
+ }
487
+ else {
488
+ render(React.createElement(SuccessScreen, { version: VERSION, title: 'note saved', subtitle: text, details: [{ label: 'Path', value: '~/.palmyr/memory/notes.md' }], footerLeft: 'Note saved' }));
489
+ }
490
+ break;
491
+ }
492
+ case 'phone': {
493
+ if (!subcommand || flags.help) {
494
+ showMenu({
495
+ command: 'phone',
496
+ title: 'phone',
497
+ subtitle: 'Voice and messaging',
498
+ footerLeft: 'Phone operations',
499
+ commands: [
500
+ { name: 'search', description: 'Search available numbers', hint: '--country US' },
501
+ { name: 'buy', description: 'Buy a phone number', hint: '--country US' },
502
+ { name: 'sms', description: 'Send an SMS', hint: '--id ID --to +1... --body "hi"' },
503
+ { name: 'call', description: 'Place a voice call', hint: '--id ID --to +1... --tts "hello"' },
504
+ ],
505
+ fromHome,
506
+ });
507
+ break;
508
+ }
509
+ switch (subcommand) {
510
+ case 'search': {
511
+ const country = flags.country || 'US';
512
+ const data = await ao.phoneSearch(country, flags.limit ? parseInt(flags.limit) : undefined);
513
+ return print(data);
514
+ render(React.createElement(RecordsScreen, {
515
+ version: VERSION,
516
+ title: 'phone search',
517
+ subtitle: `Available numbers · ${country}`,
518
+ footerLeft: `${(data.numbers || []).length} result(s)`,
519
+ records: (data.numbers || []).map((n) => ({
520
+ primary: String(n.phoneNumber || 'unknown'),
521
+ secondary: [n.region, n.type].filter(Boolean).join(' · '),
522
+ })),
523
+ interactive: fromHome,
524
+ onBack: fromHome ? () => {
525
+ process.env.PALMYR_FROM_HOME = '0';
526
+ process.argv = [process.argv[0], process.argv[1]];
527
+ void main();
528
+ } : undefined,
529
+ }));
530
+ break;
531
+ }
532
+ case 'buy': {
533
+ const country = flags.country;
534
+ if (!country)
535
+ err('--country required');
536
+ const spin = new Spinner();
537
+ spin.start('Provisioning phone number...');
538
+ const data = await ao.phoneBuy(country, flags.area);
539
+ spin.stop('Phone number provisioned', true);
540
+ return print(data);
541
+ const number = data.phoneNumber || data.phone_number || 'provisioned';
542
+ render(React.createElement(SuccessScreen, {
543
+ version: VERSION,
544
+ title: 'Phone provisioned',
545
+ subtitle: number,
546
+ footerLeft: 'Number ready to use',
547
+ details: [
548
+ { label: 'ID', value: String(data.id || '') },
549
+ { label: 'Country', value: country },
550
+ ],
551
+ }));
552
+ addPhone({ id: data.id, number, country, createdAt: new Date().toISOString() });
553
+ log(`phone buy: ${data.phoneNumber || data.phone_number || 'unknown'} (${country})`);
554
+ break;
555
+ }
556
+ case 'sms': {
557
+ const id = flags.id;
558
+ const to = flags.to;
559
+ const body = flags.body;
560
+ if (!id || !to || !body)
561
+ err('--id, --to, --body required');
562
+ const data = await ao.phoneSms(id, to, body);
563
+ return print(data);
564
+ render(React.createElement(SuccessScreen, { version: VERSION, title: 'SMS sent', subtitle: to, details: [{ label: 'To', value: to }], footerLeft: 'Message delivered' }));
565
+ break;
566
+ }
567
+ case 'call': {
568
+ const id = flags.id;
569
+ const to = flags.to;
570
+ if (!id || !to)
571
+ err('--id, --to required');
572
+ const data = await ao.phoneCall(id, to, flags.tts);
573
+ return print(data);
574
+ render(React.createElement(SuccessScreen, { version: VERSION, title: 'calling', subtitle: to, details: [{ label: 'To', value: to }, { label: 'Call ID', value: data.callControlId || data.id || '' }], footerLeft: 'Call initiated' }));
575
+ break;
576
+ }
577
+ default: err(`Unknown phone command: ${subcommand}. Try: search, buy, sms, call`);
578
+ }
579
+ break;
580
+ }
581
+ case 'email': {
582
+ if (!subcommand || flags.help) {
583
+ showMenu({
584
+ command: 'email',
585
+ title: 'email',
586
+ subtitle: 'Inbox operations',
587
+ footerLeft: 'Email operations',
588
+ commands: [
589
+ { name: 'create', description: 'Create an inbox', hint: '--name agent [--domain example.com]' },
590
+ { name: 'list', description: 'List inboxes owned by your wallet' },
591
+ { name: 'status', description: 'Domain verification status (Mailgun)', hint: '<domain>' },
592
+ { name: 'register', description: 'Register / re-register a wallet-owned domain with Mailgun', hint: '<domain>' },
593
+ { name: 'read', description: 'Read inbox messages', hint: '--id INBOX_ID' },
594
+ { name: 'send', description: 'Send an email', hint: '--id ID --to x@y.com --subject ... --body ...' },
595
+ { name: 'threads', description: 'List threads', hint: '--id INBOX_ID' },
596
+ ],
597
+ fromHome,
598
+ });
599
+ break;
600
+ }
601
+ switch (subcommand) {
602
+ case 'create': {
603
+ const name = flags.name || positional[0];
604
+ const wallet = flags.wallet;
605
+ const domain = flags.domain;
606
+ if (!name)
607
+ err('--name required (e.g. palmyr email create --name hello [--domain example.com])');
608
+ const spin = new Spinner();
609
+ spin.start('Creating inbox...');
610
+ const data = await ao.emailCreate(name, wallet, domain);
611
+ spin.stop('Inbox created', true);
612
+ return print(data);
613
+ }
614
+ case 'list': {
615
+ const data = await ao.emailListInboxes();
616
+ return print(data);
617
+ }
618
+ case 'status': {
619
+ const domain = flags.domain || positional[0];
620
+ if (!domain)
621
+ err('domain required: palmyr email status <domain>');
622
+ const data = await ao.emailDomainStatus(domain);
623
+ return print(data);
624
+ }
625
+ case 'register': {
626
+ const domain = flags.domain || positional[0];
627
+ if (!domain)
628
+ err('domain required: palmyr email register <domain>');
629
+ const data = await ao.emailRegisterDomain(domain);
630
+ return print(data);
631
+ }
632
+ case 'read': {
633
+ const id = flags.id || positional[0];
634
+ if (!id)
635
+ err('--id INBOX_ID required');
636
+ const data = await ao.emailRead(id);
637
+ return print(data);
638
+ render(React.createElement(RecordsScreen, {
639
+ version: VERSION,
640
+ title: 'email read',
641
+ subtitle: `Inbox ${data.inbox || id}`,
642
+ footerLeft: `${(data.messages || []).length} message(s)`,
643
+ records: (data.messages || []).map((m) => ({
644
+ primary: String(m.subject || '(no subject)'),
645
+ secondary: `${m.direction === 'inbound' ? '←' : '→'} ${m.from || ''}`.trim(),
646
+ status: String(m.timestamp || ''),
647
+ })),
648
+ interactive: fromHome,
649
+ onBack: fromHome ? () => {
650
+ process.env.PALMYR_FROM_HOME = '0';
651
+ process.argv = [process.argv[0], process.argv[1]];
652
+ void main();
653
+ } : undefined,
654
+ }));
655
+ break;
656
+ }
657
+ case 'send': {
658
+ const id = flags.id;
659
+ const to = flags.to;
660
+ const subject = flags.subject;
661
+ const body = flags.body;
662
+ if (!id || !to || !subject || !body)
663
+ err('--id, --to, --subject, --body required');
664
+ const data = await ao.emailSend(id, to, subject, body);
665
+ return print(data);
666
+ render(React.createElement(SuccessScreen, { version: VERSION, title: 'email sent', subtitle: to, details: [{ label: 'To', value: to }], footerLeft: 'Email delivered' }));
667
+ break;
668
+ }
669
+ case 'threads': {
670
+ const id = flags.id || positional[0];
671
+ if (!id)
672
+ err('--id INBOX_ID required');
673
+ const data = await ao.emailThreads(id);
674
+ return print(data);
675
+ render(React.createElement(RecordsScreen, {
676
+ version: VERSION,
677
+ title: 'email threads',
678
+ subtitle: 'Conversation threads',
679
+ footerLeft: `${(data.threads || []).length} thread(s)`,
680
+ records: (data.threads || []).map((t) => ({
681
+ primary: String(t.subject || '(no subject)'),
682
+ secondary: `${t.message_count || 0} msg(s)`,
683
+ })),
684
+ }));
685
+ break;
686
+ }
687
+ default: err(`Unknown email command: ${subcommand}. Try: create, read, send, threads`);
688
+ }
689
+ break;
690
+ }
691
+ case 'compute': {
692
+ if (!subcommand || flags.help) {
693
+ showMenu({
694
+ command: 'compute',
695
+ title: 'compute',
696
+ subtitle: 'Server operations',
697
+ footerLeft: 'Compute operations',
698
+ commands: [
699
+ { name: 'plans', description: 'List VPS plans (live from Hetzner)', hint: '[--location fsn1]' },
700
+ { name: 'locations', description: 'List Hetzner datacenters + per-location server-type availability' },
701
+ { name: 'install-recipes', description: 'List available agent install recipes (hermes, openclaw, ...)' },
702
+ { name: 'ssh-key', description: 'Manage Hetzner SSH keys', hint: 'add <pubkey-file> | list | delete <id>' },
703
+ { name: 'deploy', description: 'Deploy a VPS (golden path: auto-key, wait, verified SSH)', hint: '--type cx23 [--install hermes] [--location fsn1] [--no-wait]' },
704
+ { name: 'wait', description: 'Block until status=running, port 22 open, SSH verified, installs done', hint: '<name|id> [--install hermes] [--key <path>] [--wait-timeout <sec>]' },
705
+ { name: 'ssh', description: 'SSH into a deployed VPS by name or id', hint: '<name|id>' },
706
+ { name: 'exec', description: 'Run a single command on a freshly-deployed VPS (pre-handoff)', hint: '<name|id> -- <command> [args...]' },
707
+ { name: 'rename', description: 'Rename a deployed VPS (metadata-only, no reboot)', hint: '<name|id> <new-name>' },
708
+ { name: 'reset-password', description: 'Rotate the root password (Hetzner-side)', hint: '<name|id>' },
709
+ { name: 'console', description: 'Get a noVNC console URL (break-glass)', hint: '<name|id>' },
710
+ { name: 'reboot', description: 'Reboot a server', hint: '<name|id>' },
711
+ { name: 'setup-ssh', description: 'Inject your SSH key into a deployed VPS post-hoc', hint: '<id> --pubkey-file ~/.ssh/id_ed25519.pub' },
712
+ { name: 'list', description: 'List servers' },
713
+ { name: 'delete', description: 'Delete a server', hint: '--id SERVER_ID' },
714
+ ],
715
+ fromHome,
716
+ });
717
+ break;
718
+ }
719
+ switch (subcommand) {
720
+ case 'plans': {
721
+ // --location filters to types deployable in that location. Each
722
+ // plan's response also carries `availableLocations[]` so callers
723
+ // without a preference can see where each type runs.
724
+ const location = flags.location;
725
+ const data = await ao.computePlans(location ? { location } : {});
726
+ return print(data);
727
+ }
728
+ case 'locations': {
729
+ // Free discovery — list Hetzner locations + per-location server
730
+ // type availability. Useful when the default location is
731
+ // capacity-constrained or doesn't carry the type you want.
732
+ const data = await ao.computeLocations();
733
+ return print(data);
734
+ }
735
+ case 'ssh-key': {
736
+ // Subcommand layout: `compute ssh-key add <pubkey-file>` | `list` | `delete <id>`
737
+ // We piggy-back on the parser's `positional` array — `add` consumes
738
+ // positional[0] as the file path, `delete` consumes it as the ID.
739
+ const op = positional[0];
740
+ const arg = positional[1];
741
+ if (!op || op === 'list') {
742
+ const data = await ao.computeSshKeyList();
743
+ return print(data);
744
+ }
745
+ if (op === 'add') {
746
+ const pubkeyFile = arg || flags.file || flags['pubkey-file'];
747
+ if (!pubkeyFile)
748
+ err('Usage: palmyr compute ssh-key add <pubkey-file> [--name "label"]', EXIT.BAD_INPUT);
749
+ const fullPath = pubkeyFile.replace('~', homedir());
750
+ if (!existsSync(fullPath))
751
+ err(`Public key file not found: ${pubkeyFile}`, EXIT.NOT_FOUND);
752
+ const publicKey = readFileSync(fullPath, 'utf8').trim();
753
+ const name = flags.name || flags.label ||
754
+ (publicKey.split(/\s+/)[2] || `key-${Date.now()}`);
755
+ const data = await ao.computeSshKeyAdd(name, publicKey);
756
+ return print(data);
757
+ }
758
+ if (op === 'delete' || op === 'remove' || op === 'rm') {
759
+ const id = arg || flags.id;
760
+ if (!id)
761
+ err('Usage: palmyr compute ssh-key delete <id>', EXIT.BAD_INPUT);
762
+ const data = await ao.computeSshKeyDelete(id);
763
+ return print(data);
764
+ }
765
+ err(`Unknown ssh-key subcommand: ${op}. Try: add, list, delete`, EXIT.BAD_INPUT);
766
+ break;
767
+ }
768
+ case 'deploy': {
769
+ const csshMod = await import('./compute-ssh.js');
770
+ const name = flags.name || 'agent-' + Date.now();
771
+ const type = flags.type || 'cx23';
772
+ // SSH-key resolution priority (most explicit wins):
773
+ // 1. --generate-ssh-key → fresh keypair, saved locally, pubkey inline
774
+ // 2. --pubkey-file <path> → read file, send pubkey inline
775
+ // 3. --pubkey "ssh-..." → send pubkey inline as-is
776
+ // 4. --ssh-key <id> → numeric Hetzner ID, sent as sshKeyIds[]
777
+ // 1–3 all use cloud-init inline; 4 uses Hetzner's pre-uploaded key
778
+ // mechanism. They're mutually exclusive — the user who passes
779
+ // multiple gets a clear error rather than silent precedence games.
780
+ //
781
+ // GOLDEN PATH: with no key flag at all, we auto-generate one. The
782
+ // alternative ("deploy returns and you can't SSH") was the agent's
783
+ // top complaint. `--no-generate-ssh-key` opts out (e.g. user wants
784
+ // to attach a key after the fact via setup-ssh).
785
+ let wantGenerate = flags['generate-ssh-key'] === true || flags.generate === true;
786
+ const pubkeyInline = flags.pubkey || flags.publicKey;
787
+ const pubkeyFile = flags['pubkey-file'] || flags['ssh-key-file'];
788
+ const sshKeyIdRaw = flags['ssh-key'];
789
+ const explicitNoGenerate = flags['generate-ssh-key'] === false || flags.generate === false;
790
+ const keySources = [wantGenerate, !!pubkeyInline, !!pubkeyFile, !!sshKeyIdRaw].filter(Boolean).length;
791
+ if (keySources > 1) {
792
+ err('Pass only one of: --generate-ssh-key, --pubkey, --pubkey-file, --ssh-key <id>', EXIT.BAD_INPUT);
793
+ }
794
+ if (keySources === 0 && !explicitNoGenerate) {
795
+ wantGenerate = true;
796
+ }
797
+ let sshPublicKey;
798
+ let sshKeyIds;
799
+ let generatedKeyMeta;
800
+ if (sshKeyIdRaw) {
801
+ const n = Number(sshKeyIdRaw);
802
+ if (!Number.isFinite(n) || n <= 0)
803
+ err(`--ssh-key must be a numeric Hetzner key ID (got "${sshKeyIdRaw}"). Run \`palmyr compute ssh-key list\` to find it, or \`compute ssh-key add <pubkey-file>\` to upload one.`, EXIT.BAD_INPUT);
804
+ sshKeyIds = [n];
805
+ }
806
+ else if (pubkeyFile) {
807
+ const fullPath = pubkeyFile.replace('~', homedir());
808
+ if (!existsSync(fullPath))
809
+ err(`Public key file not found: ${pubkeyFile}`, EXIT.NOT_FOUND);
810
+ sshPublicKey = readFileSync(fullPath, 'utf8').trim();
811
+ }
812
+ else if (pubkeyInline) {
813
+ sshPublicKey = pubkeyInline.trim();
814
+ }
815
+ else if (wantGenerate) {
816
+ // Generated keys are namespaced by server NAME (we don't have an
817
+ // ID yet at this point). The directory gets renamed to use the
818
+ // ID once the deploy returns, so cached lookups by either work.
819
+ try {
820
+ const kp = csshMod.generateKeypair(name);
821
+ sshPublicKey = kp.publicKey;
822
+ generatedKeyMeta = { privateKeyPath: kp.privateKeyPath, publicKeyPath: kp.publicKeyPath };
823
+ }
824
+ catch (e) {
825
+ err(`--generate-ssh-key failed: ${e.message}`, EXIT.GENERAL);
826
+ }
827
+ }
828
+ // Resolve the install list. `--install hermes` or `--install hermes,openclaw`
829
+ // overrides the default. `--no-install` (or `--install ""`) skips
830
+ // cloud-init entirely (vanilla Ubuntu, password auth on). With no
831
+ // flag at all, the server defaults to OpenClaw — same behavior the
832
+ // CLI has shipped since v0.5.
833
+ const installRaw = flags.install;
834
+ let installRequested;
835
+ if (installRaw === false) {
836
+ // `--no-install` → empty array (vanilla Ubuntu).
837
+ installRequested = [];
838
+ }
839
+ else if (typeof installRaw === 'string') {
840
+ installRequested = installRaw.split(',').map(s => s.trim()).filter(Boolean);
841
+ } // else: leave undefined → server keeps the legacy default.
842
+ // --location overrides the server's default datacenter. Server
843
+ // pre-validates type+location compatibility BEFORE x402 settles,
844
+ // so a typo or mismatch fails as 400 with a clear hint instead
845
+ // of burning $6 on Hetzner's 422.
846
+ const location = flags.location;
847
+ const spin = new Spinner();
848
+ spin.start('Deploying VPS...');
849
+ let data;
850
+ try {
851
+ data = await ao.computeDeploy(name, type, {
852
+ ...(sshPublicKey ? { sshPublicKey } : {}),
853
+ ...(sshKeyIds ? { sshKeyIds } : {}),
854
+ ...(installRequested !== undefined ? { install: installRequested } : {}),
855
+ ...(location ? { location } : {}),
856
+ });
857
+ }
858
+ catch (e) {
859
+ spin.stop('VPS deploy failed', false);
860
+ // Specific server-side validation errors map to BAD_INPUT (2)
861
+ // so shell scripts can branch — they're user-fixable, not
862
+ // transient or payment-related.
863
+ const msg = String(e?.message || '');
864
+ if (/install recipe/i.test(msg)) {
865
+ err(`${e.message} Run \`palmyr compute install-recipes --json\` to list available recipes.`, EXIT.BAD_INPUT);
866
+ }
867
+ if (/Type not available in location|Invalid location/i.test(msg)) {
868
+ err(`${e.message} Run \`palmyr compute locations --json\` to see what's deployable where.`, EXIT.BAD_INPUT);
869
+ }
870
+ if (/Invalid server name/i.test(msg)) {
871
+ err(`${e.message}`, EXIT.BAD_INPUT);
872
+ }
873
+ throw e;
874
+ }
875
+ spin.stop('VPS deployed', true);
876
+ // Golden-path default: --wait is ON unless the user explicitly opts
877
+ // out (`--no-wait`). The deploy contract is "return when SSH
878
+ // works", and a plain `compute deploy` without --wait was a frequent
879
+ // foot-gun (looks successful, isn't yet usable). Users who want
880
+ // fire-and-forget deploys should pass --no-wait explicitly.
881
+ const wantWait = flags.wait !== false;
882
+ // The marker file gate (gate 4) only runs when the deploy actually
883
+ // requested an install. Use whatever the SERVER echoed back —
884
+ // `data.installs` reflects the resolved list (including any legacy
885
+ // default), independent of what the CLI inferred.
886
+ const expectedInstalls = Array.isArray(data?.installs) ? data.installs : [];
887
+ // Bigger default budget when an install is in flight. Hermes pulls
888
+ // Python 3.11 + a couple hundred MB of pip packages on a fresh box;
889
+ // 240s is too tight, 600s is comfortable.
890
+ const defaultTimeout = expectedInstalls.length > 0 ? 600 : 240;
891
+ const waitTimeoutSec = flags['wait-timeout']
892
+ ? Math.max(30, Math.min(900, parseInt(String(flags['wait-timeout']), 10)))
893
+ : defaultTimeout;
894
+ // Resolve the local key path for the SSH credential probe. Only
895
+ // available when the user supplied a key on disk (--pubkey-file or
896
+ // --generate-ssh-key) OR explicitly told us where the matching
897
+ // private key lives (--key-path) when using --ssh-key <id>.
898
+ const explicitKeyPath = flags['key-path'] || flags['private-key'];
899
+ const localKeyPath = generatedKeyMeta?.privateKeyPath
900
+ || (pubkeyFile ? pubkeyFile.replace(/\.pub$/, '').replace('~', homedir()) : undefined)
901
+ || (explicitKeyPath ? explicitKeyPath.replace('~', homedir()) : undefined);
902
+ // Progress events to stderr — default ON in agent mode so a
903
+ // long deploy isn't a 10-minute silence. Pass --no-progress to
904
+ // opt out. Stdout still gets one final JSON object, so jq
905
+ // pipelines on stdout aren't disturbed either way.
906
+ //
907
+ // We only emit when --wait is in effect; without --wait the
908
+ // deploy returns immediately and stdout already has everything,
909
+ // so stderr noise would be redundant.
910
+ const wantProgress = flags.progress !== false;
911
+ const emitProgress = (event) => {
912
+ if (AGENT_MODE && wantProgress) {
913
+ process.stderr.write(JSON.stringify({ event: 'progress', ...event }) + '\n');
914
+ }
915
+ };
916
+ // Emit a `created` ack right after the deploy returns from the
917
+ // API, before the readiness chain starts. Agents watching
918
+ // stderr now know the server got provisioned within seconds —
919
+ // any subsequent silence is the install running, not us hung.
920
+ if (AGENT_MODE && wantProgress && data?.ipv4) {
921
+ process.stderr.write(JSON.stringify({
922
+ event: 'created',
923
+ id: data.id,
924
+ name: data.name,
925
+ ipv4: data.ipv4,
926
+ installs: expectedInstalls,
927
+ waitTimeoutSec,
928
+ }) + '\n');
929
+ }
930
+ // Persist a local cache entry IMMEDIATELY — before the readiness
931
+ // chain. Issue #85: if --wait hangs/times out, a follow-up
932
+ // `compute wait <id>` or `compute ssh <id>` would otherwise find
933
+ // nothing in cache and silently skip the SSH + install gates.
934
+ // Saving here means the cache always has the server's IP, name,
935
+ // and key path, even when the wait portion of the deploy fails.
936
+ try {
937
+ csshMod.saveDeployedServer({
938
+ id: String(data.id || ''),
939
+ name: String(data.name || name),
940
+ ipv4: data.ipv4 ?? null,
941
+ serverType: String(data.serverType || type),
942
+ sshPrivateKeyPath: localKeyPath && existsSync(localKeyPath) ? localKeyPath : undefined,
943
+ sshKeyIds,
944
+ deployedAt: new Date().toISOString(),
945
+ });
946
+ }
947
+ catch { }
948
+ let finalData = data;
949
+ let readiness = undefined;
950
+ if (wantWait && data?.id) {
951
+ const spin2 = new Spinner();
952
+ spin2.start('Waiting: status=running…');
953
+ const result = await csshMod.waitForReady({
954
+ getStatus: async () => {
955
+ const s = await ao.computeGet(String(data.id));
956
+ return { status: s.status || 'unknown', ipv4: s.ipv4 ?? null };
957
+ },
958
+ keyPath: localKeyPath && existsSync(localKeyPath) ? localKeyPath : undefined,
959
+ timeoutMs: waitTimeoutSec * 1000,
960
+ expectedInstalls,
961
+ onProgress: ev => {
962
+ spin2.update(`Waiting: ${ev.message}`);
963
+ emitProgress(ev);
964
+ },
965
+ });
966
+ readiness = {
967
+ ready: result.ready,
968
+ checks: result.checks,
969
+ elapsedMs: result.elapsedMs,
970
+ ...(result.skipReasons ? { skipReasons: result.skipReasons } : {}),
971
+ ...(result.reason ? { reason: result.reason } : {}),
972
+ ...(result.installStatus ? { installStatus: result.installStatus } : {}),
973
+ ...(result.diagnostics ? { diagnostics: result.diagnostics } : {}),
974
+ };
975
+ if (result.ready) {
976
+ const passed = ['status=running', 'port22=open'];
977
+ if (result.checks.ssh === 'pass')
978
+ passed.push('ssh=verified');
979
+ if (result.checks.installs === 'pass')
980
+ passed.push(`installs=${expectedInstalls.join('+')}`);
981
+ spin2.stop(`Server ready in ${(result.elapsedMs / 1000).toFixed(1)}s (${passed.join(', ')})`, true);
982
+ }
983
+ else {
984
+ spin2.stop(`Wait incomplete: ${result.reason}`, false);
985
+ }
986
+ finalData = {
987
+ ...data,
988
+ status: result.status ?? data.status,
989
+ ipv4: result.ip ?? data.ipv4,
990
+ };
991
+ }
992
+ // Refresh the cache entry if --wait found a different IP than
993
+ // the create call returned (Hetzner sometimes assigns the v4
994
+ // post-provisioning). Only fires when wait actually ran;
995
+ // otherwise the pre-wait save above is already authoritative.
996
+ if (wantWait && finalData.ipv4 !== data.ipv4) {
997
+ try {
998
+ csshMod.saveDeployedServer({
999
+ id: String(finalData.id || data.id || ''),
1000
+ name: String(finalData.name || data.name || name),
1001
+ ipv4: (finalData.ipv4 || data.ipv4) ?? null,
1002
+ serverType: String(finalData.serverType || data.serverType || type),
1003
+ sshPrivateKeyPath: localKeyPath && existsSync(localKeyPath) ? localKeyPath : undefined,
1004
+ sshKeyIds,
1005
+ deployedAt: new Date().toISOString(),
1006
+ });
1007
+ }
1008
+ catch { }
1009
+ }
1010
+ // Surface where the generated key landed in the response so users
1011
+ // (especially agents in non-TTY runs) know what to ssh -i. When we
1012
+ // know a working key, also include a top-level `sshCommand` —
1013
+ // that's the literal "usable SSH command" the deploy contract
1014
+ // promises when --wait succeeds.
1015
+ const ip = finalData.ipv4 || data.ipv4;
1016
+ if (generatedKeyMeta) {
1017
+ finalData = {
1018
+ ...finalData,
1019
+ generatedKey: {
1020
+ privateKeyPath: generatedKeyMeta.privateKeyPath,
1021
+ publicKeyPath: generatedKeyMeta.publicKeyPath,
1022
+ hint: `ssh -i "${generatedKeyMeta.privateKeyPath}" root@${ip || '<ip>'}`,
1023
+ },
1024
+ };
1025
+ }
1026
+ if (localKeyPath && ip) {
1027
+ finalData.sshCommand = csshMod.buildSshCommand(ip, localKeyPath);
1028
+ }
1029
+ if (readiness)
1030
+ finalData.readiness = readiness;
1031
+ return print(finalData);
1032
+ }
1033
+ case 'wait': {
1034
+ // `compute wait <name|id> [--key <path>] [--wait-timeout <sec>] [--install <name>]`
1035
+ // — run the readiness chain against an existing server. Useful when
1036
+ // the user deployed without --wait, or the deploy --wait timed out
1037
+ // and they want to retry without redeploying. Pass --install to
1038
+ // also gate on the install marker file (gate 4).
1039
+ const csshMod = await import('./compute-ssh.js');
1040
+ const target = positional[0] || flags.id || flags.name;
1041
+ if (!target)
1042
+ err('Usage: palmyr compute wait <name|id> [--key <path>] [--wait-timeout <sec>] [--install hermes,...]', EXIT.BAD_INPUT);
1043
+ const cached = csshMod.findCachedServer(target);
1044
+ // Resolve the server id — cache first (to skip a paid round-trip
1045
+ // when possible), but accept a numeric arg as the id directly.
1046
+ const serverId = cached?.id || (/^\d+$/.test(target) ? target : null);
1047
+ if (!serverId)
1048
+ err(`Server "${target}" not in local cache. Pass the numeric id as the first arg, or run 'palmyr compute list' to refresh.`, EXIT.NOT_FOUND);
1049
+ const explicitKeyPath = flags.key || flags['key-path'] || flags['private-key'];
1050
+ const keyPath = (explicitKeyPath ? explicitKeyPath.replace('~', homedir()) : cached?.sshPrivateKeyPath);
1051
+ const installRaw = flags.install;
1052
+ const expectedInstalls = typeof installRaw === 'string'
1053
+ ? installRaw.split(',').map(s => s.trim()).filter(Boolean)
1054
+ : [];
1055
+ const defaultTimeout = expectedInstalls.length > 0 ? 600 : 240;
1056
+ const waitTimeoutSec = flags['wait-timeout']
1057
+ ? Math.max(30, Math.min(900, parseInt(String(flags['wait-timeout']), 10)))
1058
+ : defaultTimeout;
1059
+ const wantProgressWait = flags.progress !== false;
1060
+ const spin = new Spinner();
1061
+ spin.start('Probing readiness…');
1062
+ const result = await csshMod.waitForReady({
1063
+ getStatus: async () => {
1064
+ const s = await ao.computeGet(serverId);
1065
+ return { status: s.status || 'unknown', ipv4: s.ipv4 ?? null };
1066
+ },
1067
+ keyPath: keyPath && existsSync(keyPath) ? keyPath : undefined,
1068
+ timeoutMs: waitTimeoutSec * 1000,
1069
+ expectedInstalls,
1070
+ onProgress: ev => {
1071
+ spin.update(`Probing: ${ev.message}`);
1072
+ if (AGENT_MODE && wantProgressWait) {
1073
+ process.stderr.write(JSON.stringify({ event: 'progress', ...ev }) + '\n');
1074
+ }
1075
+ },
1076
+ });
1077
+ spin.stop(result.ready ? `Ready in ${(result.elapsedMs / 1000).toFixed(1)}s` : `Not ready: ${result.reason}`, result.ready);
1078
+ const out = {
1079
+ id: serverId,
1080
+ ready: result.ready,
1081
+ status: result.status,
1082
+ ipv4: result.ip,
1083
+ checks: result.checks,
1084
+ elapsedMs: result.elapsedMs,
1085
+ ...(result.skipReasons ? { skipReasons: result.skipReasons } : {}),
1086
+ ...(result.reason ? { reason: result.reason } : {}),
1087
+ ...(result.installStatus ? { installStatus: result.installStatus } : {}),
1088
+ ...(result.diagnostics ? { diagnostics: result.diagnostics } : {}),
1089
+ };
1090
+ if (keyPath && result.ip)
1091
+ out.sshCommand = csshMod.buildSshCommand(result.ip, keyPath);
1092
+ // Exit with NOT_FOUND if any gate failed so shell scripts can
1093
+ // branch on $?. Stdout still gets the full report so callers
1094
+ // capturing JSON can inspect which check tripped.
1095
+ if (!result.ready) {
1096
+ print(out);
1097
+ process.exit(EXIT.NOT_FOUND);
1098
+ }
1099
+ return print(out);
1100
+ }
1101
+ case 'install-recipes':
1102
+ case 'recipes': {
1103
+ // Free discovery endpoint — list available agent install recipes.
1104
+ // Agents call this to know what they can pass to --install.
1105
+ const data = await ao.computeInstallRecipes();
1106
+ return print(data);
1107
+ }
1108
+ case 'ssh': {
1109
+ const csshMod = await import('./compute-ssh.js');
1110
+ const target = positional[0] || flags.id || flags.name;
1111
+ if (!target)
1112
+ err('Usage: palmyr compute ssh <name|id>', EXIT.BAD_INPUT);
1113
+ // Local cache first — free, instant. Server-side fallback is
1114
+ // available but not auto-triggered: it would cost 0.01 USDC and
1115
+ // we'd rather make the user opt in than charge them silently.
1116
+ const cached = csshMod.findCachedServer(target);
1117
+ if (!cached?.ipv4) {
1118
+ err(`Server "${target}" not in local cache. ` +
1119
+ `Either run 'palmyr compute list --json' first, ` +
1120
+ `or use the explicit IP: ssh root@<ip>.`, EXIT.NOT_FOUND);
1121
+ }
1122
+ const keyPath = cached.sshPrivateKeyPath || flags.key || flags.identity;
1123
+ if (AGENT_MODE) {
1124
+ return print({
1125
+ id: cached.id,
1126
+ name: cached.name,
1127
+ ipv4: cached.ipv4,
1128
+ command: csshMod.buildSshCommand(cached.ipv4, keyPath),
1129
+ privateKeyPath: keyPath,
1130
+ });
1131
+ }
1132
+ // TTY mode: hand the terminal over to ssh and exit with its code.
1133
+ const code = csshMod.spawnInteractiveSsh(cached.ipv4, keyPath);
1134
+ process.exit(code);
1135
+ }
1136
+ case 'setup-ssh': {
1137
+ const id = flags.id || positional[0];
1138
+ if (!id)
1139
+ err('--id SERVER_ID required (or pass it as the first positional arg)', EXIT.BAD_INPUT);
1140
+ let pubkey = flags.pubkey || flags.publicKey;
1141
+ const pubkeyFile = flags['pubkey-file'] || flags['ssh-key-file'];
1142
+ if (!pubkey && pubkeyFile) {
1143
+ try {
1144
+ pubkey = readFileSync(pubkeyFile.replace('~', homedir()), 'utf8').trim();
1145
+ }
1146
+ catch (e) {
1147
+ err(`Could not read --pubkey-file ${pubkeyFile}: ${e.message}`, EXIT.NOT_FOUND);
1148
+ }
1149
+ }
1150
+ if (!pubkey)
1151
+ err('--pubkey "ssh-ed25519 AAAA..." (or --pubkey-file ~/.ssh/id_ed25519.pub) required', EXIT.BAD_INPUT);
1152
+ const data = await ao.computeSetupSsh(id, pubkey);
1153
+ return print(data);
1154
+ }
1155
+ case 'list': {
1156
+ const data = await ao.computeList();
1157
+ return print(data);
1158
+ }
1159
+ case 'delete': {
1160
+ const id = flags.id || positional[0];
1161
+ if (!id)
1162
+ err('--id SERVER_ID required');
1163
+ const data = await ao.computeDelete(id);
1164
+ try {
1165
+ const csshMod = await import('./compute-ssh.js');
1166
+ csshMod.removeCachedServer(id);
1167
+ }
1168
+ catch { }
1169
+ return print(data);
1170
+ }
1171
+ case 'rename': {
1172
+ // `compute rename <name|id> <new-name>` — wraps PUT /servers/:id.
1173
+ // We resolve the source from the local cache (so the user can
1174
+ // refer to a friendly name) but if it's a numeric Hetzner id we
1175
+ // accept that directly. The server validates the new name
1176
+ // pre-payment so an invalid one bounces as 400 without charging.
1177
+ const csshMod = await import('./compute-ssh.js');
1178
+ const target = positional[0] || flags.id || flags.name;
1179
+ const newName = positional[1] || flags.to || flags['new-name'];
1180
+ if (!target || !newName) {
1181
+ err('Usage: palmyr compute rename <name|id> <new-name>', EXIT.BAD_INPUT);
1182
+ }
1183
+ const cached = csshMod.findCachedServer(target);
1184
+ const serverId = cached?.id || (/^\d+$/.test(target) ? target : null);
1185
+ if (!serverId)
1186
+ err(`Server "${target}" not in local cache. Pass numeric Hetzner id or run 'palmyr compute list' first.`, EXIT.NOT_FOUND);
1187
+ let data;
1188
+ try {
1189
+ data = await ao.computeRename(serverId, newName);
1190
+ }
1191
+ catch (e) {
1192
+ const msg = String(e?.message || '');
1193
+ if (/Invalid server name/i.test(msg)) {
1194
+ err(msg, EXIT.BAD_INPUT);
1195
+ }
1196
+ throw e;
1197
+ }
1198
+ // Preserve the rest of the cache entry (ipv4, key path, sshKeyIds,
1199
+ // deployedAt) — only the name changes. Use the OLD cached entry
1200
+ // as the base, drop both the old name and the old id-keyed entry,
1201
+ // then write the renamed one.
1202
+ try {
1203
+ if (cached) {
1204
+ csshMod.removeCachedServer(cached.id);
1205
+ csshMod.saveDeployedServer({ ...cached, name: data.name || newName });
1206
+ }
1207
+ }
1208
+ catch { }
1209
+ return print(data);
1210
+ }
1211
+ case 'exec': {
1212
+ // Usage: palmyr compute exec <name|id> -- <command> [args...]
1213
+ // Or: palmyr compute exec <name|id> --command "..." --arg "..." --arg "..."
1214
+ // The double-dash form is the natural one for shells that already
1215
+ // know how to split argv; the explicit form lets agents that build
1216
+ // arrays JSON-encode args without shell-splitting.
1217
+ const csshMod = await import('./compute-ssh.js');
1218
+ const target = positional[0] || flags.id || flags.name;
1219
+ if (!target)
1220
+ err('Usage: palmyr compute exec <name|id> -- <command> [args...]', EXIT.BAD_INPUT);
1221
+ const cached = csshMod.findCachedServer(target);
1222
+ const serverId = cached?.id || (/^\d+$/.test(target) ? target : null);
1223
+ if (!serverId)
1224
+ err(`Server "${target}" not in local cache. Pass numeric id, or run 'palmyr compute list' first.`, EXIT.NOT_FOUND);
1225
+ // Pull command + args from the remaining argv after the target.
1226
+ // Bare `--` is a conventional separator; argv after it is treated
1227
+ // as remote-shell argv.
1228
+ let command;
1229
+ let args = [];
1230
+ const rest = positional.slice(1);
1231
+ if (rest.length > 0) {
1232
+ command = rest[0];
1233
+ args = rest.slice(1);
1234
+ }
1235
+ else if (flags.command) {
1236
+ command = String(flags.command);
1237
+ const argFlag = flags.arg;
1238
+ args = Array.isArray(argFlag) ? argFlag.map(String) : argFlag ? [String(argFlag)] : [];
1239
+ }
1240
+ if (!command)
1241
+ err('No command. Try: palmyr compute exec my-vps -- systemctl status openclaw', EXIT.BAD_INPUT);
1242
+ const timeoutSec = flags.timeout ? Math.max(1, Math.min(120, parseInt(String(flags.timeout), 10))) : undefined;
1243
+ const data = await ao.computeExec(serverId, command, args, timeoutSec ? { timeoutSec } : {});
1244
+ return print(data);
1245
+ }
1246
+ case 'reset-password':
1247
+ case 'reset_password': {
1248
+ const csshMod = await import('./compute-ssh.js');
1249
+ const target = positional[0] || flags.id || flags.name;
1250
+ if (!target)
1251
+ err('Usage: palmyr compute reset-password <name|id>', EXIT.BAD_INPUT);
1252
+ const cached = csshMod.findCachedServer(target);
1253
+ const serverId = cached?.id || (/^\d+$/.test(target) ? target : null);
1254
+ if (!serverId)
1255
+ err(`Server "${target}" not in local cache.`, EXIT.NOT_FOUND);
1256
+ const data = await ao.computeAction(serverId, 'reset_password');
1257
+ return print(data);
1258
+ }
1259
+ case 'console':
1260
+ case 'request-console': {
1261
+ const csshMod = await import('./compute-ssh.js');
1262
+ const target = positional[0] || flags.id || flags.name;
1263
+ if (!target)
1264
+ err('Usage: palmyr compute console <name|id>', EXIT.BAD_INPUT);
1265
+ const cached = csshMod.findCachedServer(target);
1266
+ const serverId = cached?.id || (/^\d+$/.test(target) ? target : null);
1267
+ if (!serverId)
1268
+ err(`Server "${target}" not in local cache.`, EXIT.NOT_FOUND);
1269
+ const data = await ao.computeAction(serverId, 'request_console');
1270
+ return print(data);
1271
+ }
1272
+ case 'reboot':
1273
+ case 'poweroff':
1274
+ case 'poweron':
1275
+ case 'reset':
1276
+ case 'rebuild': {
1277
+ const csshMod = await import('./compute-ssh.js');
1278
+ const target = positional[0] || flags.id || flags.name;
1279
+ if (!target)
1280
+ err(`Usage: palmyr compute ${subcommand} <name|id>`, EXIT.BAD_INPUT);
1281
+ const cached = csshMod.findCachedServer(target);
1282
+ const serverId = cached?.id || (/^\d+$/.test(target) ? target : null);
1283
+ if (!serverId)
1284
+ err(`Server "${target}" not in local cache.`, EXIT.NOT_FOUND);
1285
+ const opts = subcommand === 'rebuild' && flags.image ? { image: String(flags.image) } : {};
1286
+ const data = await ao.computeAction(serverId, subcommand, opts);
1287
+ return print(data);
1288
+ }
1289
+ default: err(`Unknown compute command: ${subcommand}. Try: plans, locations, install-recipes, ssh-key, deploy, wait, ssh, exec, rename, reset-password, console, reboot, poweroff, poweron, reset, rebuild, setup-ssh, list, delete`);
1290
+ }
1291
+ break;
1292
+ }
1293
+ case 'domain': {
1294
+ if (!subcommand || flags.help) {
1295
+ showMenu({
1296
+ command: 'domain',
1297
+ title: 'domain',
1298
+ subtitle: 'Naming and DNS',
1299
+ footerLeft: 'Domain operations',
1300
+ commands: [
1301
+ { name: 'check', description: 'Check availability', hint: '--name example.dev' },
1302
+ { name: 'pricing', description: 'Get TLD pricing', hint: '--name example' },
1303
+ { name: 'buy', description: 'Register a domain', hint: '--name example.dev' },
1304
+ { name: 'list', description: 'List domains owned by your wallet', hint: '' },
1305
+ { name: 'dns', description: 'Get DNS records', hint: '--name example.dev' },
1306
+ { name: 'transfer-ownership', description: 'Transfer domain to another wallet', hint: '--name example.dev --to <wallet>' },
1307
+ ],
1308
+ fromHome,
1309
+ });
1310
+ break;
1311
+ }
1312
+ switch (subcommand) {
1313
+ case 'check': {
1314
+ const name = flags.name || positional[0];
1315
+ if (!name)
1316
+ err('--name domain.com required');
1317
+ const data = await ao.domainCheck(name);
1318
+ // Multi-TLD response: render a table when interactive, JSON otherwise.
1319
+ if (Array.isArray(data?.results)) {
1320
+ if (AGENT_MODE)
1321
+ return print(data);
1322
+ console.log(`\n ${t.accent}domain check${t.reset} — ${t.info}${data.query}${t.reset}\n`);
1323
+ const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
1324
+ const dLen = Math.max(...data.results.map((r) => r.domain.length), 6);
1325
+ for (const r of data.results) {
1326
+ const mark = r.available ? `${t.success}✓${t.reset}` : `${t.error}✗${t.reset}`;
1327
+ const status = r.available ? `${t.success}available${t.reset}` : `${t.muted}taken${t.reset} `;
1328
+ const price = r.available ? `${t.warn}$${r.price}${t.reset}` : `${t.muted}—${t.reset}`;
1329
+ console.log(` ${mark} ${pad(r.domain, dLen + 2)} ${status} ${price}`);
1330
+ }
1331
+ console.log('');
1332
+ return;
1333
+ }
1334
+ return print(data);
1335
+ render(React.createElement(DomainCheckScreen, {
1336
+ version: VERSION,
1337
+ domain: name,
1338
+ available: !!data.available,
1339
+ interactive: fromHome,
1340
+ onBack: fromHome ? () => {
1341
+ process.env.PALMYR_FROM_HOME = '0';
1342
+ process.argv = [process.argv[0], process.argv[1]];
1343
+ void main();
1344
+ } : undefined,
1345
+ }));
1346
+ break;
1347
+ }
1348
+ case 'pricing': {
1349
+ const name = flags.name || positional[0];
1350
+ if (!name)
1351
+ err('--name domain required');
1352
+ const data = await ao.domainPricing(name);
1353
+ return print(data);
1354
+ const items = Object.entries(data.tlds || data.pricing || data).map(([tld, price]) => ({
1355
+ tld,
1356
+ price: String(price),
1357
+ }));
1358
+ render(React.createElement(DomainPricingScreen, {
1359
+ version: VERSION,
1360
+ query: name,
1361
+ items,
1362
+ interactive: fromHome,
1363
+ onBack: fromHome ? () => {
1364
+ process.env.PALMYR_FROM_HOME = '0';
1365
+ process.argv = [process.argv[0], process.argv[1]];
1366
+ void main();
1367
+ } : undefined,
1368
+ }));
1369
+ break;
1370
+ }
1371
+ case 'buy': {
1372
+ const name = flags.name || positional[0];
1373
+ if (!name)
1374
+ err('--name domain.dev required');
1375
+ const spin = new Spinner();
1376
+ spin.start('Registering domain...');
1377
+ const data = await ao.domainBuy(name);
1378
+ spin.stop('Domain registered', true);
1379
+ return print(data);
1380
+ const domain = data.domain || name;
1381
+ render(React.createElement(SuccessScreen, {
1382
+ version: VERSION,
1383
+ title: 'Domain registered',
1384
+ subtitle: domain,
1385
+ footerLeft: 'Domain secured',
1386
+ details: [
1387
+ { label: 'Domain', value: domain },
1388
+ ],
1389
+ }));
1390
+ addDomain({ domain, createdAt: new Date().toISOString() });
1391
+ log(`domain buy: ${data.domain || name}`);
1392
+ break;
1393
+ }
1394
+ case 'list': {
1395
+ const data = await ao.domainList();
1396
+ if (AGENT_MODE)
1397
+ return print(data);
1398
+ const domains = data?.domains || [];
1399
+ console.log(`\n ${t.accent}your domains${t.reset} — ${t.muted}${data.owner}${t.reset}\n`);
1400
+ if (domains.length === 0) {
1401
+ console.log(` ${t.muted}No domains yet. Try: palmyr domain buy --name example.xyz${t.reset}\n`);
1402
+ return;
1403
+ }
1404
+ const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
1405
+ const dLen = Math.max(...domains.map((r) => r.domain.length), 6);
1406
+ for (const r of domains) {
1407
+ const exp = (r.expires_at || '').slice(0, 10);
1408
+ const statusColor = r.status === 'active' ? t.success : r.status === 'pending' ? t.warn : t.error;
1409
+ console.log(` ${pad(r.domain, dLen + 2)} ${statusColor}${pad(r.status, 8)}${t.reset} ${t.muted}expires ${exp}${t.reset}`);
1410
+ }
1411
+ console.log(`\n ${t.muted}${domains.length} domain(s)${t.reset}\n`);
1412
+ return;
1413
+ }
1414
+ case 'transfer-ownership': {
1415
+ const name = flags.name || positional[0];
1416
+ const to = flags.to || positional[1];
1417
+ if (!name)
1418
+ err('--name domain.dev required');
1419
+ if (!to)
1420
+ err('--to <wallet> required');
1421
+ const data = await ao.domainTransferOwnership(name, to);
1422
+ return print(data);
1423
+ }
1424
+ case 'dns': {
1425
+ const name = flags.name || positional[0];
1426
+ if (!name)
1427
+ err('--name domain.dev required');
1428
+ const data = await ao.domainDns(name);
1429
+ return print(data);
1430
+ render(React.createElement(RecordsScreen, {
1431
+ version: VERSION,
1432
+ title: 'domain dns',
1433
+ subtitle: name,
1434
+ footerLeft: `${(data.records || []).length} record(s)`,
1435
+ records: (data.records || []).map((r) => ({
1436
+ primary: String(r.type || 'record'),
1437
+ secondary: `${r.name || '@'} → ${r.value || ''}`,
1438
+ })),
1439
+ }));
1440
+ break;
1441
+ }
1442
+ default: err(`Unknown domain command: ${subcommand}. Try: check, pricing, buy, list, dns, transfer-ownership`);
1443
+ }
1444
+ break;
1445
+ }
1446
+ case 'wallet': {
1447
+ if (!subcommand || (flags.help && !WALLET_HELP[subcommand])) {
1448
+ showMenu({
1449
+ command: 'wallet',
1450
+ title: 'wallet',
1451
+ subtitle: 'Non-custodial HD wallet',
1452
+ footerLeft: 'Solana + Base wallet operations',
1453
+ commands: [
1454
+ { name: 'create', description: 'Create a new wallet', hint: '[--managed]' },
1455
+ { name: 'import', description: 'Import from mnemonic', hint: '--mnemonic "..."' },
1456
+ { name: 'list', description: 'List all wallets' },
1457
+ { name: 'info', description: 'Wallet details', hint: 'WALLET_ID' },
1458
+ { name: 'addresses', description: 'Show all chain addresses', hint: 'WALLET_ID' },
1459
+ { name: 'sign-message', description: 'Sign a message', hint: 'WALLET_ID --chain evm --msg "hello"' },
1460
+ { name: 'export', description: 'Export mnemonic for backup', hint: 'WALLET_ID --confirm' },
1461
+ { name: 'api-key', description: 'Create agent API key', hint: 'WALLET_ID --name my-agent' },
1462
+ { name: 'config', description: 'Get agent config', hint: 'WALLET_ID' },
1463
+ { name: 'use', description: 'Set default pay wallet', hint: 'WALLET_ID' },
1464
+ { name: 'request-approval', description: 'Request human approval (managed)', hint: 'WALLET_ID --action limits --daily 100' },
1465
+ ],
1466
+ fromHome,
1467
+ });
1468
+ break;
1469
+ }
1470
+ // Per-subcommand --help
1471
+ if (flags.help && subcommand && WALLET_HELP[subcommand]) {
1472
+ subcommandHelp('wallet', subcommand, WALLET_HELP[subcommand]);
1473
+ break;
1474
+ }
1475
+ switch (subcommand) {
1476
+ case 'create': {
1477
+ const isManaged = !!flags.managed;
1478
+ // Accept --name (primary) or --label (alias)
1479
+ const name = flags.name || flags.label || 'My Wallet';
1480
+ const mode = isManaged ? 'managed' : 'unmanaged';
1481
+ // Create locally — no server needed for the key material
1482
+ const { createLocalWallet } = await import('./vault.js');
1483
+ const w = createLocalWallet(name, mode);
1484
+ // Store session secret in OS credential store
1485
+ const { storeSecret } = await import('./credential-store.js');
1486
+ storeSecret(w.id, w.sessionSecret);
1487
+ log(`wallet create: ${w.id} (${mode})`);
1488
+ // For managed wallets, register metadata with the server to get a setup link
1489
+ let setupLink;
1490
+ if (isManaged) {
1491
+ try {
1492
+ const headers = { 'Content-Type': 'application/json' };
1493
+ const res = await fetch(ao.api + '/wallet/register-managed', {
1494
+ method: 'POST',
1495
+ headers,
1496
+ body: JSON.stringify({
1497
+ walletId: w.id,
1498
+ name: w.name,
1499
+ solanaAddress: w.solanaAddress,
1500
+ evmAddress: w.evmAddress,
1501
+ }),
1502
+ });
1503
+ const data = await res.json();
1504
+ if (!res.ok || data.error) {
1505
+ throw new Error(data.error || `HTTP ${res.status}`);
1506
+ }
1507
+ setupLink = ao.api + data.setupLink;
1508
+ }
1509
+ catch (e) {
1510
+ err(`Failed to register managed wallet with server: ${e.message}`, EXIT.NETWORK);
1511
+ }
1512
+ }
1513
+ // TTY → nice TUI. Piped → JSON with setupLink included.
1514
+ if (!AGENT_MODE) {
1515
+ render(React.createElement(WalletCreateScreen, {
1516
+ version: VERSION,
1517
+ id: w.id,
1518
+ name: w.name,
1519
+ mode: w.mode,
1520
+ solana: w.solanaAddress,
1521
+ base: w.evmAddress,
1522
+ }));
1523
+ if (setupLink) {
1524
+ console.log(`\n ${t.accent}Setup link${t.reset} — send to the human who will manage this wallet:`);
1525
+ console.log(` ${t.info}${setupLink}${t.reset}\n`);
1526
+ console.log(` ${t.muted}They'll register a passkey and set spending limits. Takes 30 seconds.${t.reset}\n`);
1527
+ }
1528
+ }
1529
+ else {
1530
+ print({ ...w, setupLink });
1531
+ }
1532
+ break;
1533
+ }
1534
+ case 'import': {
1535
+ const mnemonic = flags.mnemonic;
1536
+ if (!mnemonic)
1537
+ err('--mnemonic "your twelve words..." required');
1538
+ const name = flags.name || flags.label || 'Imported Wallet';
1539
+ const mode = flags.managed ? 'managed' : 'unmanaged';
1540
+ const { importLocalWallet } = await import('./vault.js');
1541
+ const w = importLocalWallet(name, mnemonic, mode);
1542
+ // Store session secret
1543
+ const { storeSecret } = await import('./credential-store.js');
1544
+ storeSecret(w.id, w.sessionSecret);
1545
+ log(`wallet import: ${w.id}`);
1546
+ if (!AGENT_MODE) {
1547
+ render(React.createElement(WalletCreateScreen, {
1548
+ version: VERSION,
1549
+ id: w.id,
1550
+ name: w.name,
1551
+ mode: w.mode,
1552
+ solana: w.solanaAddress,
1553
+ base: w.evmAddress,
1554
+ }));
1555
+ }
1556
+ else {
1557
+ print(w);
1558
+ }
1559
+ break;
1560
+ }
1561
+ case 'list': {
1562
+ // List from local vault — no server needed
1563
+ const { listVaultWallets } = await import('./vault.js');
1564
+ const wallets = listVaultWallets();
1565
+ if (!AGENT_MODE) {
1566
+ render(React.createElement(WalletListScreen, {
1567
+ version: VERSION,
1568
+ wallets: wallets.map((w) => ({
1569
+ id: w.id,
1570
+ name: w.name,
1571
+ mode: w.mode,
1572
+ solana: w.solanaAddress,
1573
+ base: w.evmAddress,
1574
+ })),
1575
+ }));
1576
+ }
1577
+ else {
1578
+ print({ wallets });
1579
+ }
1580
+ break;
1581
+ }
1582
+ case 'info': {
1583
+ const walletId = positional[0] || flags.id;
1584
+ if (!walletId)
1585
+ err('Wallet ID required');
1586
+ // Read from local vault directly
1587
+ const { listVaultWallets } = await import('./vault.js');
1588
+ const wallets = listVaultWallets();
1589
+ const w = wallets.find(x => x.id === walletId || x.name === walletId);
1590
+ if (!w)
1591
+ err(`Wallet "${walletId}" not found`, EXIT.NOT_FOUND);
1592
+ if (!AGENT_MODE) {
1593
+ render(React.createElement(WalletStatusScreen, {
1594
+ version: VERSION,
1595
+ id: w.id,
1596
+ name: w.name,
1597
+ mode: w.mode,
1598
+ solana: w.solanaAddress,
1599
+ base: w.evmAddress,
1600
+ }));
1601
+ }
1602
+ else {
1603
+ print(w);
1604
+ }
1605
+ break;
1606
+ }
1607
+ case 'addresses': {
1608
+ const walletId = positional[0] || flags.id;
1609
+ if (!walletId)
1610
+ err('Wallet ID required');
1611
+ // Read from local vault — same source as `wallet list` / `wallet info`.
1612
+ // The previous server-call path 401'd for unauthenticated users
1613
+ // even though all the data is already on the local disk.
1614
+ const { listVaultWallets } = await import('./vault.js');
1615
+ const wallets = listVaultWallets();
1616
+ const w = wallets.find(x => x.id === walletId || x.name === walletId);
1617
+ if (!w)
1618
+ err(`Wallet "${walletId}" not found`, EXIT.NOT_FOUND);
1619
+ const addresses = [
1620
+ ...(w.solanaAddress ? [{ chainId: 'solana', address: w.solanaAddress }] : []),
1621
+ ...(w.evmAddress ? [{ chainId: 'base', address: w.evmAddress }] : []),
1622
+ ];
1623
+ if (!AGENT_MODE) {
1624
+ render(React.createElement(SuccessScreen, {
1625
+ version: VERSION,
1626
+ title: 'Wallet addresses',
1627
+ subtitle: walletId,
1628
+ footerLeft: `${addresses.length} chain(s)`,
1629
+ details: addresses.map(a => ({ label: a.chainId + ':', value: a.address })),
1630
+ }));
1631
+ }
1632
+ else {
1633
+ print({ id: w.id, name: w.name, addresses });
1634
+ }
1635
+ break;
1636
+ }
1637
+ case 'sign-message': {
1638
+ const walletId = positional[0] || flags.id;
1639
+ if (!walletId)
1640
+ err('Wallet ID required');
1641
+ const chain = flags.chain;
1642
+ const msg = flags.msg || flags.message;
1643
+ if (!chain || !msg)
1644
+ err('--chain and --msg required');
1645
+ // Sign locally — no server needed
1646
+ const { signMessageLocal } = await import('./vault.js');
1647
+ const data = signMessageLocal(walletId, chain, msg);
1648
+ return print({ success: true, ...data });
1649
+ render(React.createElement(SuccessScreen, {
1650
+ version: VERSION,
1651
+ title: 'Message signed',
1652
+ subtitle: chain,
1653
+ footerLeft: 'Signature ready',
1654
+ details: [
1655
+ { label: 'Signature', value: String(data.signature || '') },
1656
+ ...(data.recoveryId !== undefined ? [{ label: 'Recovery ID', value: String(data.recoveryId) }] : []),
1657
+ ],
1658
+ }));
1659
+ break;
1660
+ }
1661
+ case 'api-key': {
1662
+ const walletId = positional[0] || flags.id;
1663
+ if (!walletId)
1664
+ err('Wallet ID required');
1665
+ const name = flags.name || 'cli-agent';
1666
+ // Retrieve session secret from OS credential store
1667
+ const { retrieveSecret } = await import('./credential-store.js');
1668
+ const sessionSecret = retrieveSecret(walletId);
1669
+ if (!sessionSecret)
1670
+ err('No session secret found. Was this wallet created on this machine?');
1671
+ const data = await ao.walletApiKey(walletId, name, sessionSecret);
1672
+ return print(data);
1673
+ render(React.createElement(SuccessScreen, {
1674
+ version: VERSION,
1675
+ title: 'API key created',
1676
+ subtitle: 'Save this token — it will not be shown again',
1677
+ footerLeft: 'Agent API key',
1678
+ details: [
1679
+ { label: 'Token', value: String(data.apiKey?.token || '') },
1680
+ { label: 'Key ID', value: String(data.apiKey?.id || '') },
1681
+ { label: 'Name', value: String(data.apiKey?.name || name) },
1682
+ ],
1683
+ }));
1684
+ break;
1685
+ }
1686
+ case 'config': {
1687
+ const walletId = positional[0] || flags.id;
1688
+ if (!walletId)
1689
+ err('Wallet ID required');
1690
+ const { retrieveSecret } = await import('./credential-store.js');
1691
+ const sessionSecret = retrieveSecret(walletId);
1692
+ if (!sessionSecret)
1693
+ err('No session secret found. Was this wallet created on this machine?');
1694
+ const data = await ao.walletConfig(walletId, sessionSecret);
1695
+ return print(data);
1696
+ print(data.config || data);
1697
+ break;
1698
+ }
1699
+ case 'use': {
1700
+ const walletId = positional[0] || flags.id;
1701
+ if (!walletId)
1702
+ err('Wallet ID required');
1703
+ const chain = flags.chain?.toLowerCase();
1704
+ if (chain && chain !== 'solana' && chain !== 'base') {
1705
+ err(`--chain must be 'solana' or 'base', got: ${chain}`);
1706
+ }
1707
+ const cfg = loadConfig();
1708
+ cfg.defaultPayWalletId = walletId;
1709
+ if (chain)
1710
+ cfg.defaultPayChain = chain;
1711
+ saveConfig(cfg);
1712
+ print({ success: true, defaultPayWalletId: walletId, defaultPayChain: cfg.defaultPayChain || 'solana' });
1713
+ break;
1714
+ }
1715
+ case 'request-approval': {
1716
+ const walletId = positional[0] || flags.id;
1717
+ if (!walletId)
1718
+ err('Wallet ID required');
1719
+ const action = flags.action || 'limits';
1720
+ const params = {};
1721
+ if (flags.daily)
1722
+ params.daily_usdc = Number(flags.daily);
1723
+ if (flags['per-tx'] || flags.tx)
1724
+ params.per_tx_usdc = Number(flags['per-tx'] || flags.tx);
1725
+ const data = await ao.walletRequestApproval(walletId, action, params);
1726
+ return print(data);
1727
+ render(React.createElement(SuccessScreen, {
1728
+ version: VERSION,
1729
+ title: 'Approval requested',
1730
+ subtitle: action,
1731
+ footerLeft: 'Send link to human for approval',
1732
+ details: [
1733
+ { label: 'Approval URL', value: `${ao.api}${data.approvalPath}` },
1734
+ { label: 'Action', value: action },
1735
+ ],
1736
+ }));
1737
+ break;
1738
+ }
1739
+ case 'export': {
1740
+ const walletId = positional[0] || flags.id;
1741
+ if (!walletId)
1742
+ err('Wallet ID required');
1743
+ if (!flags.confirm) {
1744
+ err('This will display your mnemonic in plaintext. ' +
1745
+ 'Anyone who sees it can steal your funds.\n\n' +
1746
+ ' Re-run with --confirm to proceed:\n' +
1747
+ ` palmyr wallet export ${walletId} --confirm`);
1748
+ }
1749
+ // Decrypt via vault's single decryption path (session secret from OS cred store)
1750
+ const { exportMnemonic } = await import('./vault.js');
1751
+ let mnemonic;
1752
+ try {
1753
+ mnemonic = exportMnemonic(walletId);
1754
+ }
1755
+ catch (e) {
1756
+ // Preserve SECURITY exit code for integrity failures
1757
+ const code = e.message?.includes('SECURITY') ? EXIT.SECURITY : EXIT.GENERAL;
1758
+ err(e.message, code);
1759
+ }
1760
+ const warning = 'Keep this phrase secret. Anyone with these 12 words can take your funds. Write it down offline; never share, screenshot, or paste it.';
1761
+ if (!AGENT_MODE) {
1762
+ console.log(`\n ${t.warn}⚠ MNEMONIC — KEEP SECRET${t.reset}\n`);
1763
+ console.log(` ${mnemonic}\n`);
1764
+ console.log(` ${t.muted}${warning}${t.reset}\n`);
1765
+ }
1766
+ else {
1767
+ print({ mnemonic: mnemonic, walletId, warning });
1768
+ }
1769
+ break;
1770
+ }
1771
+ default: err(`Unknown wallet command: ${subcommand}. Try: create, import, list, info, export, addresses, sign-message, api-key, config, use, request-approval`);
1772
+ }
1773
+ break;
1774
+ }
1775
+ case 'chat': {
1776
+ if (!subcommand || flags.help) {
1777
+ showMenu({
1778
+ command: 'chat',
1779
+ title: 'chat',
1780
+ subtitle: 'i402 (intent layer for x402): tell Palmyr what you want, pay USDC, get the outcome',
1781
+ footerLeft: 'Powered by the i402 protocol — see spec/i402.md',
1782
+ commands: [
1783
+ { name: 'run', description: 'Generate a plan (and optionally execute it)', hint: '"launch a sneaker brand" --budget 50' },
1784
+ { name: 'resume', description: 'Continue an existing session with a follow-up intent', hint: '<session_id> "now post 3 videos"' },
1785
+ { name: 'status', description: 'Inspect a session: history, current status', hint: '<session_id>' },
1786
+ { name: 'cancel', description: 'Halt execution and refund remaining escrow', hint: '<session_id>' },
1787
+ { name: 'sessions', description: 'List your active sessions' },
1788
+ { name: 'capabilities', description: 'List the canonical capability classes' },
1789
+ { name: 'providers', description: 'List registered providers, optionally filtered', hint: '[--capability web_search]' },
1790
+ ],
1791
+ fromHome,
1792
+ });
1793
+ break;
1794
+ }
1795
+ switch (subcommand) {
1796
+ case 'run': {
1797
+ const intent = (positional.join(' ') || flags.intent || '').trim();
1798
+ if (!intent)
1799
+ err('pass the intent as a positional arg or --intent "..."');
1800
+ const budget = flags.budget ? parseFloat(flags.budget) : NaN;
1801
+ if (!isFinite(budget) || budget <= 0)
1802
+ err('--budget <USDC> is required and must be positive');
1803
+ const quality = flags.quality || 'best';
1804
+ if (!['fast', 'cheap', 'best'].includes(quality))
1805
+ err('--quality must be fast | cheap | best');
1806
+ const autoExecute = flags.execute === true || flags['auto-execute'] === true;
1807
+ const autoApprove = flags['auto-approve-under'] ? parseFloat(flags['auto-approve-under']) : undefined;
1808
+ const spin = new Spinner();
1809
+ spin.start('Generating i402 plan...');
1810
+ const plan = await ao.chat(intent, {
1811
+ budgetUsdc: budget,
1812
+ quality: quality,
1813
+ autoApproveUnderUsdc: autoApprove,
1814
+ approve: autoExecute,
1815
+ });
1816
+ spin.stop('Plan generated', true);
1817
+ if (plan.status === 'clarification_needed') {
1818
+ if (AGENT_MODE) {
1819
+ print({
1820
+ status: 'clarification_needed',
1821
+ sessionId: plan.session_id,
1822
+ questions: plan.questions || [],
1823
+ });
1824
+ }
1825
+ else {
1826
+ render(React.createElement(RecordsScreen, {
1827
+ version: VERSION,
1828
+ title: 'i402 — clarification needed',
1829
+ subtitle: `session ${plan.session_id}`,
1830
+ records: (plan.questions || []).map((q) => ({
1831
+ primary: q.text,
1832
+ secondary: `id: ${q.id}`,
1833
+ })),
1834
+ footerLeft: 'Re-run `palmyr chat resume <session_id> "<your answer>"`',
1835
+ }));
1836
+ }
1837
+ break;
1838
+ }
1839
+ // Agent mode: emit the plan as a single JSON object, then NDJSON
1840
+ // events during execution so an agent can `for await` over stdout.
1841
+ // TTY mode: keep the colored progress output below.
1842
+ if (AGENT_MODE) {
1843
+ print({
1844
+ event: 'plan',
1845
+ planId: plan.plan_id,
1846
+ sessionId: plan.session_id,
1847
+ intent: plan.intent,
1848
+ steps: plan.steps,
1849
+ totals: plan.totals,
1850
+ status: plan.status,
1851
+ });
1852
+ }
1853
+ else {
1854
+ console.log(`\n${c.cyan}Plan${c.white}: ${plan.intent?.interpreted ?? plan.intent?.original}`);
1855
+ console.log(` ${plan.steps?.length ?? 0} steps · $${plan.totals?.total_cost_usdc?.toFixed(2) ?? '?'} · ~${plan.totals?.eta_seconds ?? '?'}s · session ${plan.session_id}`);
1856
+ for (const s of plan.steps || []) {
1857
+ console.log(` ${s.step_id} ${s.capability} → ${s.provider} $${s.cost_usdc?.toFixed(2)} ${s.description ?? ''}`);
1858
+ }
1859
+ console.log('');
1860
+ }
1861
+ if (!autoExecute && plan.status !== 'approved') {
1862
+ if (AGENT_MODE) {
1863
+ print({
1864
+ event: 'awaiting_approval',
1865
+ sessionId: plan.session_id,
1866
+ planId: plan.plan_id,
1867
+ resumeCommand: `palmyr chat resume ${plan.session_id} --approve --plan-id ${plan.plan_id}`,
1868
+ });
1869
+ }
1870
+ else {
1871
+ console.log(`${c.yellow}Plan not auto-approved.${c.white} To execute:`);
1872
+ console.log(` ${c.cyan}palmyr chat resume ${plan.session_id} --approve --plan-id ${plan.plan_id}${c.white}`);
1873
+ }
1874
+ break;
1875
+ }
1876
+ if (autoExecute || plan.status === 'approved') {
1877
+ if (!AGENT_MODE)
1878
+ console.log(`${c.cyan}Executing plan${c.white} (streaming)...\n`);
1879
+ let spent = 0;
1880
+ const stepOutputs = {};
1881
+ for await (const event of ao.chatExecute(plan)) {
1882
+ if (AGENT_MODE) {
1883
+ // NDJSON: one event per line. Agents can stream-parse.
1884
+ process.stdout.write(JSON.stringify(event) + '\n');
1885
+ if (event.type === 'step_result' || event.type === 'session_refresh_done') {
1886
+ spent += Number(event.costChargedUsdc ?? 0);
1887
+ }
1888
+ if (event.type === 'step_result' && event.output && typeof event.output === 'object') {
1889
+ stepOutputs[event.stepId] = event.output;
1890
+ }
1891
+ continue;
1892
+ }
1893
+ switch (event.type) {
1894
+ case 'session':
1895
+ case 'plan':
1896
+ // Already displayed
1897
+ break;
1898
+ case 'step_start':
1899
+ // Price is shown cumulatively on step_result — no need to
1900
+ // print it twice per step.
1901
+ console.log(` ${c.gray}→${c.white} ${event.stepId} ${event.capability} via ${event.provider}`);
1902
+ break;
1903
+ case 'session_refresh_started':
1904
+ process.stdout.write(` ${c.gray}↻ refreshing ${event.platform} session for @${event.handle}…${c.white}`);
1905
+ break;
1906
+ case 'session_refresh_done':
1907
+ spent += Number(event.costChargedUsdc ?? 0);
1908
+ console.log(` ${c.green}done${c.white} ${c.gray}(+$${event.costChargedUsdc?.toFixed(3)})${c.white}`);
1909
+ break;
1910
+ case 'step_result':
1911
+ spent += Number(event.costChargedUsdc ?? 0);
1912
+ if (event.output && typeof event.output === 'object')
1913
+ stepOutputs[event.stepId] = event.output;
1914
+ console.log(` ${c.green}✓${c.white} ${event.stepId} done in ${event.latencyMs}ms ${c.gray}spent: $${spent.toFixed(2)}${c.white}`);
1915
+ break;
1916
+ case 'step_error': {
1917
+ // Long upstream errors (HTML pages, JSON dumps) are noise
1918
+ // in the terminal — truncate and point at --verbose.
1919
+ const errMsg = truncateError(String(event.error ?? ''));
1920
+ const tag = event.fatal ? '(FATAL)' : `retry → ${event.retryWith ?? 'none'}`;
1921
+ console.log(` ${c.red}✗${c.white} ${event.stepId} ${tag}: ${errMsg}`);
1922
+ break;
1923
+ }
1924
+ case 'clarification_needed':
1925
+ console.log(` ${c.yellow}?${c.white} clarification: ${JSON.stringify(event.questions)}`);
1926
+ break;
1927
+ case 'summary':
1928
+ console.log(`\n${c.cyan}Summary${c.white}: status=${event.status} spent=$${event.spentUsdc?.toFixed(2)} ${c.gray}session=${plan.session_id}${c.white}`);
1929
+ if (Object.keys(stepOutputs).length > 0) {
1930
+ console.log(`\n${c.cyan}Outputs:${c.white}`);
1931
+ for (const [stepId, out] of Object.entries(stepOutputs)) {
1932
+ console.log(` ${c.gray}${stepId}${c.white} ${formatStepOutput(out)}`);
1933
+ }
1934
+ }
1935
+ for (const a of event.artifacts || []) {
1936
+ console.log(` ${c.gray}artifact:${c.white} ${a.type} — ${a.name ?? a.resourceRef}`);
1937
+ }
1938
+ break;
1939
+ }
1940
+ }
1941
+ }
1942
+ break;
1943
+ }
1944
+ case 'resume': {
1945
+ const sessionId = positional[0];
1946
+ const intentParts = positional.slice(1);
1947
+ const intent = intentParts.join(' ').trim() || flags.intent || '';
1948
+ const planId = flags['plan-id'];
1949
+ if (!sessionId)
1950
+ err('session_id required: palmyr chat resume <session_id> "follow-up intent"');
1951
+ // If intent is provided, generate a new plan in this session
1952
+ if (intent) {
1953
+ const budget = flags.budget ? parseFloat(flags.budget) : 20;
1954
+ const autoExecute = flags.execute === true || flags['auto-execute'] === true;
1955
+ const plan = await ao.chat(intent, {
1956
+ sessionId,
1957
+ budgetUsdc: budget,
1958
+ quality: flags.quality || 'best',
1959
+ approve: autoExecute,
1960
+ });
1961
+ if (AGENT_MODE) {
1962
+ print({
1963
+ event: 'plan',
1964
+ sessionId,
1965
+ planId: plan.plan_id,
1966
+ totalCostUsdc: plan.totals?.total_cost_usdc,
1967
+ steps: plan.steps,
1968
+ });
1969
+ }
1970
+ else {
1971
+ console.log(`${c.cyan}New plan in session${c.white} ${sessionId}`);
1972
+ console.log(` plan_id: ${plan.plan_id} cost: $${plan.totals?.total_cost_usdc?.toFixed(2) ?? '?'}`);
1973
+ }
1974
+ if (!autoExecute)
1975
+ break;
1976
+ for await (const event of ao.chatExecute(plan)) {
1977
+ if (AGENT_MODE) {
1978
+ process.stdout.write(JSON.stringify(event) + '\n');
1979
+ continue;
1980
+ }
1981
+ if (event.type === 'step_result')
1982
+ console.log(` ${c.green}✓${c.white} ${event.stepId} $${event.costChargedUsdc?.toFixed(2)}`);
1983
+ if (event.type === 'step_error')
1984
+ console.log(` ${c.red}✗${c.white} ${event.stepId} ${event.error}`);
1985
+ if (event.type === 'summary')
1986
+ console.log(`${c.cyan}done${c.white}: ${event.status} spent=$${event.spentUsdc?.toFixed(2)}`);
1987
+ }
1988
+ break;
1989
+ }
1990
+ // No intent → we'd need to re-fetch the plan from the server.
1991
+ // Not wired in this minimal CLI: generate a new plan with `chat run`
1992
+ // or pass a follow-up intent to `chat resume <session> "..."`.
1993
+ err('pass a follow-up intent to continue the session; direct re-execution of a stored plan by id is not yet wired');
1994
+ break;
1995
+ }
1996
+ case 'status': {
1997
+ const sessionId = positional[0];
1998
+ if (!sessionId)
1999
+ err('session_id required');
2000
+ const data = await ao.chatGetSession(sessionId);
2001
+ return print(data);
2002
+ }
2003
+ case 'cancel': {
2004
+ const sessionId = positional[0];
2005
+ if (!sessionId)
2006
+ err('session_id required');
2007
+ const data = await ao.chatCancel(sessionId);
2008
+ return print(data);
2009
+ }
2010
+ case 'sessions': {
2011
+ const data = await ao.chatListSessions();
2012
+ return print(data);
2013
+ }
2014
+ case 'capabilities': {
2015
+ const data = await ao.chatListCapabilities();
2016
+ return print(data);
2017
+ }
2018
+ case 'providers': {
2019
+ const capability = flags.capability;
2020
+ const data = await ao.chatListProviders(capability);
2021
+ return print(data);
2022
+ }
2023
+ default: err(`Unknown chat command: ${subcommand}. Try: run, resume, status, cancel, sessions, capabilities, providers`);
2024
+ }
2025
+ break;
2026
+ }
2027
+ case 'twitter': {
2028
+ const sv = await import('./social-vault.js');
2029
+ const platform = 'twitter';
2030
+ if (!subcommand) {
2031
+ showMenu({
2032
+ command: 'twitter',
2033
+ title: 'twitter',
2034
+ subtitle: 'Automated X account management',
2035
+ footerLeft: 'Phase 1: local vault + BYO import works today. Server-dependent commands stub out.',
2036
+ commands: [
2037
+ { name: 'import', description: 'Save a BYO account to the local vault', hint: '--username --password --totp-seed' },
2038
+ { name: 'list', description: 'List all local X accounts' },
2039
+ { name: 'info', description: 'Show one account', hint: '<username>' },
2040
+ { name: 'rename', description: 'Update the local record when the handle changes', hint: '<old> --to <new>' },
2041
+ { name: 'remove', description: 'Delete an account from the local vault', hint: '<username> --confirm' },
2042
+ { name: 'totp', description: 'Print the current TOTP code for an account', hint: '<username>' },
2043
+ { name: 'buy', description: 'Purchase an aged account (requires server supplier config)', hint: '--age 1y --country US' },
2044
+ { name: 'login', description: 'Force a fresh server-side session (requires browser runtime)', hint: '<username>' },
2045
+ { name: 'post', description: 'Post a tweet (requires server browser runtime)', hint: '<username> --body "..."' },
2046
+ { name: 'status', description: 'Check if the account is alive / shadow-banned', hint: '<username>' },
2047
+ ],
2048
+ fromHome,
2049
+ });
2050
+ return;
2051
+ }
2052
+ switch (subcommand) {
2053
+ case 'import': {
2054
+ // Option 1: --credentials-line "login:password:email:email_pw:2fa:ct0:auth_token"
2055
+ // Option 2: individual --username --password --email etc flags
2056
+ const line = flags['credentials-line'];
2057
+ let login;
2058
+ let password;
2059
+ let email;
2060
+ let emailPassword;
2061
+ let totpSeed;
2062
+ let ct0;
2063
+ let authToken;
2064
+ let username = flags.username || positional[0];
2065
+ if (line) {
2066
+ // AccsMarket common formats:
2067
+ // login:password:email:email_pw (4 fields)
2068
+ // login:password:email:email_pw:2fa (5 fields)
2069
+ // login:password:email:email_pw:2fa:ct0:auth_token (7 fields)
2070
+ const parts = line.split(':');
2071
+ if (parts.length < 4)
2072
+ err(`--credentials-line must have at least 4 colon-separated fields, got ${parts.length}`);
2073
+ login = parts[0];
2074
+ password = parts[1];
2075
+ email = parts[2];
2076
+ emailPassword = parts[3];
2077
+ if (parts[4])
2078
+ totpSeed = parts[4];
2079
+ if (parts[5])
2080
+ ct0 = parts[5];
2081
+ if (parts[6])
2082
+ authToken = parts[6];
2083
+ // If no explicit --username, use the login field as the account handle.
2084
+ if (!username)
2085
+ username = login;
2086
+ }
2087
+ else {
2088
+ password = flags.password;
2089
+ login = flags.login;
2090
+ email = flags.email;
2091
+ emailPassword = flags['email-password'] || flags.emailpw;
2092
+ totpSeed = flags['totp-seed'] || flags.totp;
2093
+ ct0 = flags.ct0;
2094
+ authToken = flags['auth-token'] || flags.authtoken;
2095
+ }
2096
+ if (!username)
2097
+ err('--username (or --credentials-line) required');
2098
+ if (!password)
2099
+ err('--password (or --credentials-line) required');
2100
+ const recovery = flags['recovery-codes'];
2101
+ const profileUrl = flags['profile-url'];
2102
+ const creds = {
2103
+ login: login || email || undefined,
2104
+ password: password,
2105
+ email: email || login || undefined,
2106
+ email_password: emailPassword,
2107
+ totp_seed: totpSeed,
2108
+ recovery_codes: recovery ? recovery.split(',').map(s => s.trim()) : undefined,
2109
+ profile_url: profileUrl,
2110
+ auth_token: authToken,
2111
+ ct0,
2112
+ };
2113
+ const summary = sv.importAccount(platform, username, creds, { source: line ? 'accsmarket-line' : 'import' });
2114
+ log(`twitter import: ${summary.username} (${summary.id})${authToken ? ' [cookies included — cookie login path]' : ' [form login path]'}`);
2115
+ return print({ ...summary, has_cookies: !!authToken });
2116
+ }
2117
+ case 'list': {
2118
+ const accounts = sv.listAccounts(platform);
2119
+ return print({ accounts, count: accounts.length });
2120
+ }
2121
+ case 'info': {
2122
+ const username = positional[0] || flags.username;
2123
+ if (!username)
2124
+ err('<username> required');
2125
+ const acc = sv.getAccount(platform, username);
2126
+ if (!acc)
2127
+ err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
2128
+ return print(acc);
2129
+ }
2130
+ case 'rename': {
2131
+ const oldUsername = positional[0] || flags.username;
2132
+ const newUsername = flags.to;
2133
+ if (!oldUsername)
2134
+ err('<old-username> required');
2135
+ if (!newUsername)
2136
+ err('--to <new-username> required');
2137
+ const summary = sv.renameAccount(platform, oldUsername, newUsername);
2138
+ log(`twitter rename: ${oldUsername} → ${newUsername}`);
2139
+ return print(summary);
2140
+ }
2141
+ case 'remove': {
2142
+ const username = positional[0] || flags.username;
2143
+ if (!username)
2144
+ err('<username> required');
2145
+ if (!flags.confirm) {
2146
+ err(`This deletes the local copy of "${username}". The X account itself is NOT deleted.\n\n` +
2147
+ ` Re-run with --confirm to proceed:\n` +
2148
+ ` palmyr twitter remove ${username} --confirm`);
2149
+ }
2150
+ sv.removeAccount(platform, username);
2151
+ log(`twitter remove: ${username}`);
2152
+ return print({ success: true, platform, username });
2153
+ }
2154
+ case 'totp': {
2155
+ const username = positional[0] || flags.username;
2156
+ if (!username)
2157
+ err('<username> required');
2158
+ const creds = sv.unlockCredentials(platform, username);
2159
+ if (!creds.totp_seed)
2160
+ err(`twitter account "${username}" has no TOTP seed configured`, EXIT.NOT_FOUND);
2161
+ const { code, secondsUntilNextCode } = await import('./totp.js');
2162
+ return print({
2163
+ platform,
2164
+ username,
2165
+ code: code(creds.totp_seed),
2166
+ expires_in_seconds: secondsUntilNextCode(),
2167
+ });
2168
+ }
2169
+ case 'login': {
2170
+ const username = positional[0] || flags.username;
2171
+ if (!username)
2172
+ err('<username> required');
2173
+ const acc = sv.getAccount(platform, username);
2174
+ if (!acc)
2175
+ err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
2176
+ // Decrypt credentials locally — they transit the network only over TLS
2177
+ // during this one request, and are never persisted server-side.
2178
+ const creds = sv.unlockCredentials(platform, username);
2179
+ if (!creds.login)
2180
+ err('Account has no login field. Re-import with --login <email-or-handle>.', EXIT.BAD_INPUT);
2181
+ const cookiePath = !!(creds.auth_token && creds.ct0);
2182
+ const psid = sv.getProxySessionId(platform, username);
2183
+ let data;
2184
+ try {
2185
+ // Uses the SDK so x402 payment is auto-signed from the configured wallet
2186
+ data = await ao.socialTwitterLogin(acc.id, creds.login, creds.password, creds.totp_seed, cookiePath ? { auth_token: creds.auth_token, ct0: creds.ct0 } : undefined, psid);
2187
+ }
2188
+ catch (e) {
2189
+ err(`Login failed: ${e.message}`, EXIT.GENERAL);
2190
+ }
2191
+ if (!data || !data.success) {
2192
+ err(`Login failed: ${data?.error || 'unknown error'}` +
2193
+ (data?.error_code ? ` [${data.error_code}]` : ''), EXIT.GENERAL);
2194
+ }
2195
+ sv.saveSession(acc.id, platform, data.cookies || []);
2196
+ sv.updateMeta(platform, username, { last_action_at: new Date().toISOString() });
2197
+ return print({
2198
+ success: true,
2199
+ platform,
2200
+ username,
2201
+ cookies_captured: (data.cookies || []).length,
2202
+ captured_at: data.captured_at,
2203
+ });
2204
+ }
2205
+ case 'session': {
2206
+ const username = positional[0] || flags.username;
2207
+ if (!username)
2208
+ err('<username> required');
2209
+ const acc = sv.getAccount(platform, username);
2210
+ if (!acc)
2211
+ err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
2212
+ const sess = sv.loadSession(acc.id);
2213
+ if (!sess) {
2214
+ return print({
2215
+ platform,
2216
+ username,
2217
+ cached: false,
2218
+ hint: `No cached session. Run: node cli/dist/cli.js twitter login ${username}`,
2219
+ });
2220
+ }
2221
+ const ageHours = sv.sessionAgeHours(acc.id);
2222
+ return print({
2223
+ platform,
2224
+ username,
2225
+ cached: true,
2226
+ cookies: sess.cookies.length,
2227
+ captured_at: sess.captured_at,
2228
+ age_hours: Number((ageHours || 0).toFixed(2)),
2229
+ stale: (ageHours || 0) > 12,
2230
+ });
2231
+ }
2232
+ case 'list-tweets': {
2233
+ const username = positional[0] || flags.username;
2234
+ if (!username)
2235
+ err('<username> required');
2236
+ const rawLimit = flags.limit;
2237
+ let limit;
2238
+ if (rawLimit !== undefined) {
2239
+ const n = Number(rawLimit);
2240
+ if (!Number.isFinite(n) || n <= 0)
2241
+ err('--limit must be a positive integer', EXIT.BAD_INPUT);
2242
+ limit = Math.floor(n);
2243
+ }
2244
+ const acc = sv.getAccount(platform, username);
2245
+ if (!acc)
2246
+ err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
2247
+ const sess = sv.loadSession(acc.id);
2248
+ if (!sess || !sess.cookies || sess.cookies.length === 0) {
2249
+ err(`No cached session for ${username}. Run 'twitter login ${username}' first.`, EXIT.NOT_FOUND);
2250
+ }
2251
+ const psid = sv.getProxySessionId(platform, username);
2252
+ let data;
2253
+ try {
2254
+ data = await ao.socialTwitterListMyTweets(acc.id, sess.cookies, limit, psid);
2255
+ }
2256
+ catch (e) {
2257
+ err(`list-tweets failed: ${e.message}`, EXIT.GENERAL);
2258
+ }
2259
+ if (!data?.success) {
2260
+ err(`list-tweets failed: ${data?.error || 'unknown'}` +
2261
+ (data?.error_code ? ` [${data.error_code}]` : ''), EXIT.GENERAL);
2262
+ }
2263
+ sv.updateMeta(platform, username, { last_action_at: new Date().toISOString() });
2264
+ return print({ success: true, platform, username, ...(data?.data || {}) });
2265
+ }
2266
+ case 'register': {
2267
+ // Register an X account with the Palmyr server. Server tests the
2268
+ // login, encrypts credentials at rest, and from then on can refresh
2269
+ // cookies on this wallet's behalf — foundation for server-side
2270
+ // scheduling. If the account already exists in the local vault,
2271
+ // we pull its credentials by default so the user doesn't have to
2272
+ // re-type. Explicit flags override.
2273
+ const username = positional[0] || flags.username;
2274
+ if (!username)
2275
+ err('<username> required');
2276
+ let login = flags.login || undefined;
2277
+ let password = flags.password || undefined;
2278
+ let totpSeed = flags['totp-seed'] || undefined;
2279
+ let email = flags.email || undefined;
2280
+ let emailPassword = flags['email-password'] || undefined;
2281
+ let authToken = flags['auth-token'] || undefined;
2282
+ let ct0 = flags.ct0 || undefined;
2283
+ const country = flags.country || undefined;
2284
+ const localAcc = sv.getAccount(platform, username);
2285
+ if (localAcc && !password) {
2286
+ const localCreds = sv.unlockCredentials(platform, username);
2287
+ login = login || localCreds.login;
2288
+ password = password || localCreds.password;
2289
+ totpSeed = totpSeed || localCreds.totp_seed;
2290
+ email = email || localCreds.email;
2291
+ emailPassword = emailPassword || localCreds.email_password;
2292
+ authToken = authToken || localCreds.auth_token;
2293
+ ct0 = ct0 || localCreds.ct0;
2294
+ }
2295
+ if (!password) {
2296
+ err('--password required (or import the account locally first via `palmyr twitter import`)');
2297
+ }
2298
+ let data;
2299
+ try {
2300
+ data = await ao.socialTwitterRegister(username, password, {
2301
+ login, email, email_password: emailPassword,
2302
+ totp_seed: totpSeed, auth_token: authToken, ct0, country,
2303
+ });
2304
+ }
2305
+ catch (e) {
2306
+ err(`Register failed: ${e.message}`, EXIT.GENERAL);
2307
+ }
2308
+ if (!data?.success) {
2309
+ err(`Register failed: ${data?.error || 'unknown'}` +
2310
+ (data?.login_error_code ? ` [${data.login_error_code}]` : ''), EXIT.GENERAL);
2311
+ }
2312
+ return print({
2313
+ success: true,
2314
+ platform, username,
2315
+ account_id: data.id,
2316
+ cookies_captured: data.cookies_captured,
2317
+ hint: 'Server holds encrypted credentials. Use `palmyr twitter schedule` (next PR) to schedule fire-and-forget posts.',
2318
+ });
2319
+ }
2320
+ case 'unregister': {
2321
+ const usernameOrId = positional[0] || flags.username || flags.id;
2322
+ if (!usernameOrId)
2323
+ err('<username-or-id> required');
2324
+ // 32-char hex == account_id. Otherwise treat as username and look up.
2325
+ let accountId;
2326
+ if (/^[a-f0-9]{32}$/i.test(usernameOrId)) {
2327
+ accountId = usernameOrId;
2328
+ }
2329
+ else {
2330
+ let registered;
2331
+ try {
2332
+ registered = await ao.socialTwitterListRegistered();
2333
+ }
2334
+ catch (e) {
2335
+ err(`Failed to list registered accounts: ${e.message}`, EXIT.GENERAL);
2336
+ }
2337
+ const match = (registered?.accounts || []).find((a) => a.username === usernameOrId);
2338
+ if (!match) {
2339
+ err(`No registered account with username "${usernameOrId}". Run \`palmyr twitter registered\` to list.`, EXIT.NOT_FOUND);
2340
+ }
2341
+ accountId = match.id;
2342
+ }
2343
+ let data;
2344
+ try {
2345
+ data = await ao.socialTwitterUnregister(accountId);
2346
+ }
2347
+ catch (e) {
2348
+ err(`Unregister failed: ${e.message}`, EXIT.GENERAL);
2349
+ }
2350
+ if (!data?.success) {
2351
+ err(`Unregister failed: ${data?.error || 'unknown'}`, EXIT.GENERAL);
2352
+ }
2353
+ return print({ success: true, platform, account_id: accountId, hint: 'Server-side credentials wiped. Account no longer schedulable until re-registered.' });
2354
+ }
2355
+ case 'registered': {
2356
+ let data;
2357
+ try {
2358
+ data = await ao.socialTwitterListRegistered();
2359
+ }
2360
+ catch (e) {
2361
+ err(`List registered failed: ${e.message}`, EXIT.GENERAL);
2362
+ }
2363
+ if (!data?.success) {
2364
+ err(`List registered failed: ${data?.error || 'unknown'}`, EXIT.GENERAL);
2365
+ }
2366
+ return print({ success: true, platform, accounts: data.accounts || [] });
2367
+ }
2368
+ case 'schedule': {
2369
+ // Server-backed: pays $0.001 (text) / $0.005 (thread or media)
2370
+ // upfront via x402, schedule fires from the server at --at time.
2371
+ // Account must be registered first via `palmyr twitter register`.
2372
+ const username = positional[0] || flags.username;
2373
+ if (!username)
2374
+ err('<username> required (must be registered via `palmyr twitter register` first)');
2375
+ const postAt = flags.at;
2376
+ if (!postAt)
2377
+ err('--at "ISO 8601" required (e.g. --at "2026-05-15T14:00:00Z")');
2378
+ if (Number.isNaN(Date.parse(postAt)))
2379
+ err(`--at "${postAt}" is not a valid ISO 8601 date`);
2380
+ const text = flags.body || flags.text;
2381
+ const textsRaw = flags.texts;
2382
+ const fileTextsPath = flags.file || flags.path;
2383
+ let texts;
2384
+ if (fileTextsPath) {
2385
+ // Strip UTF-8 BOM that PS 5.1's `Set-Content -Encoding utf8` prepends.
2386
+ try {
2387
+ texts = JSON.parse(readFileSync(fileTextsPath, 'utf8').replace(/^/, ''));
2388
+ }
2389
+ catch (e) {
2390
+ err(`--file ${fileTextsPath}: ${e.message}`);
2391
+ }
2392
+ }
2393
+ else if (textsRaw) {
2394
+ try {
2395
+ texts = JSON.parse(textsRaw);
2396
+ }
2397
+ catch (e) {
2398
+ err(`--texts must be a JSON array of strings: ${e.message}`);
2399
+ }
2400
+ }
2401
+ if (!text && (!Array.isArray(texts) || texts.length === 0)) {
2402
+ err('Either --body "..." or --texts \'["..."]\' required');
2403
+ }
2404
+ const communityId = flags.community || flags['community-id'] || undefined;
2405
+ // Look up registered account_id by username. One round-trip per call;
2406
+ // could be cached locally later if it becomes a hotspot.
2407
+ let registered;
2408
+ try {
2409
+ registered = await ao.socialTwitterListRegistered();
2410
+ }
2411
+ catch (e) {
2412
+ err(`Failed to list registered accounts: ${e.message}`, EXIT.GENERAL);
2413
+ }
2414
+ const match = (registered?.accounts || []).find((a) => a.username === username);
2415
+ if (!match) {
2416
+ err(`Account "${username}" is not registered server-side. Register first: ` +
2417
+ `palmyr twitter register ${username}`, EXIT.NOT_FOUND);
2418
+ }
2419
+ const accountId = match.id;
2420
+ let data;
2421
+ try {
2422
+ if (Array.isArray(texts) && texts.length > 0) {
2423
+ // Thread schedule.
2424
+ data = await ao.socialScheduledThread(accountId, texts, postAt, communityId);
2425
+ }
2426
+ else if (flags.image || flags.video || flags['media-json']) {
2427
+ // Media schedule — base64-encode local files for upload.
2428
+ const media = [];
2429
+ if (flags.image) {
2430
+ for (const fp of flags.image.split(',').map((p) => p.trim()).filter(Boolean)) {
2431
+ let buf;
2432
+ try {
2433
+ buf = readFileSync(fp);
2434
+ }
2435
+ catch (e) {
2436
+ err(`--image ${fp}: ${e.message}`);
2437
+ continue;
2438
+ }
2439
+ const ext = extname(fp).slice(1).toLowerCase() || 'png';
2440
+ media.push({ image_base64: `data:image/${ext};base64,${buf.toString('base64')}` });
2441
+ }
2442
+ }
2443
+ if (flags.video) {
2444
+ const fp = flags.video;
2445
+ let buf;
2446
+ try {
2447
+ buf = readFileSync(fp);
2448
+ }
2449
+ catch (e) {
2450
+ err(`--video ${fp}: ${e.message}`);
2451
+ }
2452
+ const ext = extname(fp).slice(1).toLowerCase() || 'mp4';
2453
+ media.push({ video_base64: `data:video/${ext};base64,${buf.toString('base64')}` });
2454
+ }
2455
+ if (flags['media-json']) {
2456
+ let parsed;
2457
+ try {
2458
+ parsed = JSON.parse(flags['media-json']);
2459
+ }
2460
+ catch (e) {
2461
+ err(`--media-json: ${e.message}`);
2462
+ }
2463
+ if (!Array.isArray(parsed))
2464
+ err('--media-json must be a JSON array');
2465
+ media.push(...parsed);
2466
+ }
2467
+ data = await ao.socialScheduledMedia(accountId, text, media, postAt, communityId);
2468
+ }
2469
+ else {
2470
+ // Text-only schedule.
2471
+ data = await ao.socialScheduledPost(accountId, text, postAt, communityId);
2472
+ }
2473
+ }
2474
+ catch (e) {
2475
+ err(`Schedule failed: ${e.message}`, EXIT.GENERAL);
2476
+ }
2477
+ if (!data?.success) {
2478
+ err(`Schedule failed: ${data?.error || 'unknown'}`, EXIT.GENERAL);
2479
+ }
2480
+ return print({
2481
+ success: true,
2482
+ platform, username,
2483
+ schedule_id: data.id,
2484
+ post_at: postAt,
2485
+ hint: 'Server fires this at post_at automatically — no daemon needed. Cancel via `palmyr twitter cancel <id>`.',
2486
+ });
2487
+ }
2488
+ case 'queue': {
2489
+ // Server-backed: lists scheduled posts for the caller's wallet.
2490
+ const status = flags.status;
2491
+ const fromIso = flags.from || undefined;
2492
+ const toIso = flags.to || undefined;
2493
+ const accountIdFilter = flags['account-id'] || undefined;
2494
+ const limit = flags.limit ? Number(flags.limit) : undefined;
2495
+ let data;
2496
+ try {
2497
+ data = await ao.socialScheduledList({
2498
+ accountId: accountIdFilter, status, from: fromIso, to: toIso, limit,
2499
+ });
2500
+ }
2501
+ catch (e) {
2502
+ err(`Queue list failed: ${e.message}`, EXIT.GENERAL);
2503
+ }
2504
+ if (!data?.success) {
2505
+ err(`Queue list failed: ${data?.error || 'unknown'}`, EXIT.GENERAL);
2506
+ }
2507
+ return print({ success: true, items: data.items || [] });
2508
+ }
2509
+ case 'cancel': {
2510
+ // Server-backed: cancels a pending scheduled post.
2511
+ const id = positional[0] || flags.id;
2512
+ if (!id)
2513
+ err('<schedule-id> required (from `palmyr twitter queue`)');
2514
+ let data;
2515
+ try {
2516
+ data = await ao.socialScheduledCancel(id);
2517
+ }
2518
+ catch (e) {
2519
+ err(`Cancel failed: ${e.message}`, EXIT.GENERAL);
2520
+ }
2521
+ if (!data?.success) {
2522
+ err(`Cancel failed: ${data?.error || 'unknown'}`, EXIT.GENERAL);
2523
+ }
2524
+ return print({ success: true, cancelled: data.cancelled, status: data.status, id });
2525
+ }
2526
+ case 'username': {
2527
+ const username = positional[0] || flags.username;
2528
+ const rawNewUsername = flags.to;
2529
+ if (!username)
2530
+ err('<username> required');
2531
+ if (!rawNewUsername)
2532
+ err('--to <new-handle> required');
2533
+ // Pre-flight validate so we don't pay for preventable input errors.
2534
+ const newUsername = rawNewUsername.replace(/^@/, '').trim();
2535
+ if (!/^[A-Za-z0-9_]{4,15}$/.test(newUsername)) {
2536
+ err(`Invalid username "${rawNewUsername}". X requires 4-15 chars, letters/numbers/underscores only. ` +
2537
+ `You have NOT been charged.`, EXIT.BAD_INPUT);
2538
+ }
2539
+ const acc = sv.getAccount(platform, username);
2540
+ if (!acc)
2541
+ err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
2542
+ const sess = sv.loadSession(acc.id);
2543
+ if (!sess || !sess.cookies || sess.cookies.length === 0) {
2544
+ err(`No cached session. Run 'twitter login ${username}' first.`, EXIT.NOT_FOUND);
2545
+ }
2546
+ // Unlock password locally — transits to server only in this call.
2547
+ const creds = sv.unlockCredentials(platform, username);
2548
+ if (!creds.password)
2549
+ err('Account has no password in vault — cannot authenticate username change.');
2550
+ const psid = sv.getProxySessionId(platform, username);
2551
+ let data;
2552
+ try {
2553
+ data = await ao.socialTwitterUsername(acc.id, sess.cookies, newUsername, creds.password, psid);
2554
+ }
2555
+ catch (e) {
2556
+ err(`Username change failed: ${e.message}`, EXIT.GENERAL);
2557
+ }
2558
+ if (!data?.success) {
2559
+ err(`Username change failed: ${data?.error || 'unknown'}` +
2560
+ (data?.error_code ? ` [${data.error_code}]` : ''), EXIT.GENERAL);
2561
+ }
2562
+ // Sync local record to the new handle.
2563
+ const renamed = sv.renameAccount(platform, username, newUsername.replace(/^@/, ''));
2564
+ sv.updateMeta(platform, renamed.username, { last_action_at: new Date().toISOString() });
2565
+ return print({ success: true, platform, old_username: username, new_username: renamed.username });
2566
+ }
2567
+ case 'post':
2568
+ case 'thread':
2569
+ case 'reply':
2570
+ case 'like':
2571
+ case 'retweet':
2572
+ case 'follow':
2573
+ case 'unfollow':
2574
+ case 'delete':
2575
+ case 'bio':
2576
+ case 'name':
2577
+ case 'location':
2578
+ case 'website':
2579
+ case 'pfp':
2580
+ case 'banner': {
2581
+ const username = positional[0] || flags.username;
2582
+ if (!username)
2583
+ err(`<username> required`);
2584
+ const acc = sv.getAccount(platform, username);
2585
+ if (!acc)
2586
+ err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
2587
+ const sess = sv.loadSession(acc.id);
2588
+ if (!sess || !sess.cookies || sess.cookies.length === 0) {
2589
+ err(`No cached session for ${username}. Run 'twitter login ${username}' first.`, EXIT.NOT_FOUND);
2590
+ }
2591
+ const psid = sv.getProxySessionId(platform, username);
2592
+ let data;
2593
+ try {
2594
+ if (subcommand === 'post') {
2595
+ const text = flags.body || flags.text;
2596
+ if (!text)
2597
+ err('--body "..." required');
2598
+ const communityId = flags.community || flags['community-id'] || undefined;
2599
+ // Build optional media array. CLI supports the common cases:
2600
+ // --image path[,path,path,path] for 1-4 local image files,
2601
+ // --video path for a single local video file,
2602
+ // --media-json '[{...}]' as the full-power escape hatch
2603
+ // (mix image_url / image_base64 / video_url / video_base64).
2604
+ const media = [];
2605
+ if (flags.image) {
2606
+ for (const fp of flags.image.split(',').map((p) => p.trim()).filter(Boolean)) {
2607
+ let buf;
2608
+ try {
2609
+ buf = readFileSync(fp);
2610
+ }
2611
+ catch (e) {
2612
+ err(`--image ${fp}: ${e.message}`);
2613
+ continue;
2614
+ }
2615
+ const ext = extname(fp).slice(1).toLowerCase() || 'png';
2616
+ media.push({ image_base64: `data:image/${ext};base64,${buf.toString('base64')}` });
2617
+ }
2618
+ }
2619
+ if (flags.video) {
2620
+ const fp = flags.video;
2621
+ let buf;
2622
+ try {
2623
+ buf = readFileSync(fp);
2624
+ }
2625
+ catch (e) {
2626
+ err(`--video ${fp}: ${e.message}`);
2627
+ }
2628
+ const ext = extname(fp).slice(1).toLowerCase() || 'mp4';
2629
+ media.push({ video_base64: `data:video/${ext};base64,${buf.toString('base64')}` });
2630
+ }
2631
+ if (flags['media-json']) {
2632
+ let parsed;
2633
+ try {
2634
+ parsed = JSON.parse(flags['media-json']);
2635
+ }
2636
+ catch (e) {
2637
+ err(`--media-json: ${e.message}`);
2638
+ }
2639
+ if (!Array.isArray(parsed))
2640
+ err('--media-json must be a JSON array of media objects');
2641
+ media.push(...parsed);
2642
+ }
2643
+ if (media.length > 0) {
2644
+ data = await ao.socialTwitterPostWithMedia(acc.id, sess.cookies, text, media, psid, communityId);
2645
+ }
2646
+ else {
2647
+ data = await ao.socialTwitterPost(acc.id, sess.cookies, text, psid, communityId);
2648
+ }
2649
+ }
2650
+ else if (subcommand === 'thread') {
2651
+ // --texts accepts a JSON-encoded array of strings, OR --file points
2652
+ // to a JSON file with the same shape. Each tweet ≤280 chars; 1-25
2653
+ // tweets per thread. Single-tweet "threads" delegate to a normal post.
2654
+ const textsRaw = flags.texts || flags.body;
2655
+ const filePath = flags.file || flags.path;
2656
+ let texts;
2657
+ if (filePath) {
2658
+ // Strip UTF-8 BOM that PS 5.1's `Set-Content -Encoding utf8` prepends.
2659
+ try {
2660
+ texts = JSON.parse(readFileSync(filePath, 'utf8').replace(/^/, ''));
2661
+ }
2662
+ catch (e) {
2663
+ err(`--file ${filePath}: ${e.message}`);
2664
+ }
2665
+ }
2666
+ else if (textsRaw) {
2667
+ try {
2668
+ texts = JSON.parse(textsRaw);
2669
+ }
2670
+ catch (e) {
2671
+ err(`--texts must be a JSON array of strings: ${e.message}`);
2672
+ }
2673
+ }
2674
+ if (!Array.isArray(texts) || texts.length === 0) {
2675
+ err('--texts \'["tweet 1","tweet 2",...]\' or --file <path> required');
2676
+ }
2677
+ const communityIdT = flags.community || flags['community-id'] || undefined;
2678
+ data = await ao.socialTwitterPostThread(acc.id, sess.cookies, texts, psid, communityIdT);
2679
+ }
2680
+ else if (subcommand === 'reply') {
2681
+ const tweetUrl = flags.to || flags.tweet;
2682
+ const text = flags.body || flags.text;
2683
+ if (!tweetUrl)
2684
+ err('--to <tweet-url> required');
2685
+ if (!text)
2686
+ err('--body "..." required');
2687
+ data = await ao.socialTwitterReply(acc.id, sess.cookies, tweetUrl, text, psid);
2688
+ }
2689
+ else if (subcommand === 'like') {
2690
+ const tweetUrl = flags.tweet || flags.url;
2691
+ if (!tweetUrl)
2692
+ err('--tweet <tweet-url> required');
2693
+ data = await ao.socialTwitterLike(acc.id, sess.cookies, tweetUrl, psid);
2694
+ }
2695
+ else if (subcommand === 'retweet') {
2696
+ const tweetUrl = flags.tweet || flags.url;
2697
+ if (!tweetUrl)
2698
+ err('--tweet <tweet-url> required');
2699
+ data = await ao.socialTwitterRetweet(acc.id, sess.cookies, tweetUrl, psid);
2700
+ }
2701
+ else if (subcommand === 'follow') {
2702
+ const target = flags.user || flags.target;
2703
+ if (!target)
2704
+ err('--user <@handle> required');
2705
+ data = await ao.socialTwitterFollow(acc.id, sess.cookies, target, psid);
2706
+ }
2707
+ else if (subcommand === 'unfollow') {
2708
+ const target = flags.user || flags.target;
2709
+ if (!target)
2710
+ err('--user <@handle> required');
2711
+ data = await ao.socialTwitterUnfollow(acc.id, sess.cookies, target, psid);
2712
+ }
2713
+ else if (subcommand === 'delete') {
2714
+ const tweetUrl = flags.tweet || flags.url;
2715
+ if (!tweetUrl)
2716
+ err('--tweet <tweet-url> required');
2717
+ data = await ao.socialTwitterDelete(acc.id, sess.cookies, tweetUrl, psid);
2718
+ }
2719
+ else if (subcommand === 'bio') {
2720
+ const text = flags.text || flags.body;
2721
+ if (text === undefined)
2722
+ err('--text "..." required (pass "" to clear)');
2723
+ data = await ao.socialTwitterProfile(acc.id, sess.cookies, { bio: text }, psid);
2724
+ }
2725
+ else if (subcommand === 'name') {
2726
+ const text = flags.display || flags.text || flags.name;
2727
+ if (!text)
2728
+ err('--display "Display Name" required');
2729
+ data = await ao.socialTwitterProfile(acc.id, sess.cookies, { display_name: text }, psid);
2730
+ }
2731
+ else if (subcommand === 'location') {
2732
+ const text = flags.text || '';
2733
+ data = await ao.socialTwitterProfile(acc.id, sess.cookies, { location: text }, psid);
2734
+ }
2735
+ else if (subcommand === 'website') {
2736
+ const url = flags.url || flags.text || '';
2737
+ data = await ao.socialTwitterProfile(acc.id, sess.cookies, { website: url }, psid);
2738
+ }
2739
+ else {
2740
+ // pfp / banner: accept --file (local path, base64-encoded here)
2741
+ // OR --url (hosted image, server fetches).
2742
+ const filePath = flags.file || flags.path;
2743
+ const imageUrl = flags.url;
2744
+ if (!filePath && !imageUrl)
2745
+ err('--file <local-path> or --url <https-url> required');
2746
+ let image = {};
2747
+ if (filePath) {
2748
+ const { readFileSync, existsSync } = await import('fs');
2749
+ if (!existsSync(filePath))
2750
+ err(`File not found: ${filePath}`, EXIT.NOT_FOUND);
2751
+ const buf = readFileSync(filePath);
2752
+ const ext = filePath.toLowerCase().match(/\.(png|jpg|jpeg|webp|gif)$/)?.[1] || 'png';
2753
+ image.image_base64 = `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${buf.toString('base64')}`;
2754
+ }
2755
+ else {
2756
+ image.image_url = imageUrl;
2757
+ }
2758
+ if (subcommand === 'pfp') {
2759
+ data = await ao.socialTwitterAvatar(acc.id, sess.cookies, image, psid);
2760
+ }
2761
+ else {
2762
+ data = await ao.socialTwitterBanner(acc.id, sess.cookies, image, psid);
2763
+ }
2764
+ }
2765
+ }
2766
+ catch (e) {
2767
+ err(`${subcommand} failed: ${e.message}`, EXIT.GENERAL);
2768
+ }
2769
+ if (!data?.success) {
2770
+ err(`${subcommand} failed: ${data?.error || 'unknown'}` +
2771
+ (data?.error_code ? ` [${data.error_code}]` : ''), EXIT.GENERAL);
2772
+ }
2773
+ sv.updateMeta(platform, username, { last_action_at: new Date().toISOString() });
2774
+ return print({ success: true, platform, username, op: subcommand, ...(data?.data || {}) });
2775
+ }
2776
+ case 'buy': {
2777
+ // Agents just say "buy." Server picks the oldest ready account.
2778
+ let data;
2779
+ try {
2780
+ data = await ao.socialTwitterBuy();
2781
+ }
2782
+ catch (e) {
2783
+ err(`Buy failed: ${e.message}`, EXIT.GENERAL);
2784
+ }
2785
+ if (!data?.success || !data.account) {
2786
+ err(`Buy failed: ${data?.error || 'no account returned'}`, EXIT.GENERAL);
2787
+ }
2788
+ const { account } = data;
2789
+ // Auto-import into the local vault + prime the session cache so
2790
+ // the buyer can post immediately with the cookies the admin
2791
+ // pre-seasoned at pool-add time.
2792
+ const summary = sv.importAccount(platform, account.username, account.credentials, {
2793
+ source: 'pool',
2794
+ proxy_session_id: account.proxy_session_id,
2795
+ notes: 'Bought from pool',
2796
+ });
2797
+ sv.saveSession(summary.id, platform, account.cookies || []);
2798
+ sv.updateMeta(platform, summary.username, { last_action_at: new Date().toISOString() });
2799
+ return print({
2800
+ success: true,
2801
+ platform,
2802
+ username: summary.username,
2803
+ hint: `Ready to post — try: node cli/dist/cli.js twitter post ${summary.username} --body "gm"`,
2804
+ });
2805
+ }
2806
+ case 'pool-add': {
2807
+ const { buildAdminHeaders } = await import('./admin-auth.js');
2808
+ const file = flags.file || flags.batch;
2809
+ const line = flags['credentials-line'];
2810
+ const country = flags.country || undefined;
2811
+ const ageCategory = flags.age || flags['age-category'] || undefined;
2812
+ const price = flags.price !== undefined ? Number(flags.price) : undefined;
2813
+ if (typeof price !== 'number' || !Number.isFinite(price) || price <= 0) {
2814
+ err('--price <USDC> required (e.g. --price 5)');
2815
+ }
2816
+ if (!file && !line) {
2817
+ err('Either --credentials-line "..." or --file path/to/accounts.txt required');
2818
+ }
2819
+ // Collect credentials lines from flag or file.
2820
+ let lines = [];
2821
+ if (line) {
2822
+ lines = [line];
2823
+ }
2824
+ else {
2825
+ const { readFileSync, existsSync } = await import('fs');
2826
+ if (!existsSync(file))
2827
+ err(`File not found: ${file}`, EXIT.NOT_FOUND);
2828
+ lines = readFileSync(file, 'utf8')
2829
+ .split(/\r?\n/)
2830
+ .map(s => s.trim())
2831
+ .filter(s => s.length > 0 && !s.startsWith('#')); // # = comment
2832
+ }
2833
+ const results = [];
2834
+ const isInteractive = !AGENT_MODE;
2835
+ let spin = null;
2836
+ if (isInteractive && lines.length > 1) {
2837
+ spin = new Spinner();
2838
+ spin.start(`Seeding pool (0/${lines.length})`);
2839
+ }
2840
+ for (let i = 0; i < lines.length; i++) {
2841
+ const credsLine = lines[i];
2842
+ if (spin)
2843
+ spin.update(`Seeding pool (${i + 1}/${lines.length})`);
2844
+ try {
2845
+ const headers = buildAdminHeaders('POST', '/social/twitter/pool-add');
2846
+ const res = await fetch(ao.api + '/social/twitter/pool-add', {
2847
+ method: 'POST',
2848
+ headers: { 'Content-Type': 'application/json', ...headers },
2849
+ body: JSON.stringify({
2850
+ credentials_line: credsLine,
2851
+ country,
2852
+ age_category: ageCategory,
2853
+ sale_price_usdc: price,
2854
+ }),
2855
+ });
2856
+ const data = await res.json();
2857
+ if (!res.ok || !data.success) {
2858
+ results.push({ index: i, success: false, error: data.error || `HTTP ${res.status}`, username: credsLine.split(':')[0] });
2859
+ }
2860
+ else {
2861
+ results.push({ index: i, success: true, id: data.id, username: credsLine.split(':')[0], cookies_captured: data.cookies_captured });
2862
+ }
2863
+ }
2864
+ catch (e) {
2865
+ results.push({ index: i, success: false, error: e.message, username: credsLine.split(':')[0] });
2866
+ }
2867
+ }
2868
+ if (spin) {
2869
+ const ok = results.filter(r => r.success).length;
2870
+ spin.stop(`Seeded ${ok}/${lines.length} accounts`, ok === lines.length);
2871
+ }
2872
+ return print({
2873
+ total: lines.length,
2874
+ seeded: results.filter(r => r.success).length,
2875
+ failed: results.filter(r => !r.success).length,
2876
+ results,
2877
+ });
2878
+ }
2879
+ case 'pool-status': {
2880
+ const { buildAdminHeaders } = await import('./admin-auth.js');
2881
+ const headers = buildAdminHeaders('GET', '/social/twitter/pool-status');
2882
+ const res = await fetch(ao.api + '/social/twitter/pool-status', { headers });
2883
+ const data = await res.json();
2884
+ if (!res.ok)
2885
+ err(`Pool status failed: ${data.error || `HTTP ${res.status}`}`, EXIT.GENERAL);
2886
+ return print(data);
2887
+ }
2888
+ case 'status': {
2889
+ err(`twitter status: not wired yet. Phase 3 will add it.`, EXIT.GENERAL);
2890
+ }
2891
+ default:
2892
+ err(`Unknown twitter command: ${subcommand}. Try: import, list, info, rename, remove, totp, login, session, post, reply, like, retweet, follow, unfollow, delete, list-tweets, bio, name, location, website, pfp, banner, username, buy`);
2893
+ }
2894
+ break;
2895
+ }
2896
+ case 'tiktok': {
2897
+ const sv = await import('./social-vault.js');
2898
+ const platform = 'tiktok';
2899
+ if (!subcommand) {
2900
+ showMenu({
2901
+ command: 'tiktok',
2902
+ title: 'tiktok',
2903
+ subtitle: 'Automated TikTok account management',
2904
+ footerLeft: 'BYO: export sessionid from a logged-in TikTok browser, import, then post / follow / like.',
2905
+ commands: [
2906
+ { 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 ...' },
2907
+ { name: 'list', description: 'List all local TikTok accounts' },
2908
+ { name: 'info', description: 'Show one account', hint: '<username>' },
2909
+ { name: 'rename', description: 'Update the local handle', hint: '<old> --to <new>' },
2910
+ { name: 'remove', description: 'Delete an account from the local vault', hint: '<username> --confirm' },
2911
+ { name: 'totp', description: 'Print the current TOTP code', hint: '<username>' },
2912
+ { name: 'login', description: 'Validate cookies and cache the session', hint: '<username>' },
2913
+ { name: 'session', description: 'Check cached session status', hint: '<username>' },
2914
+ { name: 'post', description: 'Post a video', hint: '<username> --file video.mp4 --caption "..."' },
2915
+ { name: 'follow', description: 'Follow a TikTok user', hint: '<username> --user @handle' },
2916
+ { name: 'like', description: 'Like a video', hint: '<username> --video https://...' },
2917
+ { name: 'delete', description: 'Delete a video', hint: '<username> --video https://...' },
2918
+ { name: 'bio', description: 'Update bio (<=80 chars)', hint: '<username> --text "..."' },
2919
+ { name: 'name', description: 'Update display name (<=30 chars)', hint: '<username> --display "..."' },
2920
+ { name: 'pfp', description: 'Update avatar', hint: '<username> --file pic.png' },
2921
+ ],
2922
+ fromHome,
2923
+ });
2924
+ return;
2925
+ }
2926
+ switch (subcommand) {
2927
+ case 'import': {
2928
+ // Two formats:
2929
+ // --credentials-line "login:password:email:email_password" (from AccsMarket etc.)
2930
+ // OR explicit flags --login / --password / --email / --sessionid / --csrf / --webid
2931
+ const line = flags['credentials-line'];
2932
+ let login;
2933
+ let password;
2934
+ let email;
2935
+ let emailPassword;
2936
+ let username = flags.username || positional[0];
2937
+ if (line) {
2938
+ const parts = line.split(':');
2939
+ if (parts.length < 4)
2940
+ err(`--credentials-line must have at least 4 colon-separated fields, got ${parts.length}`);
2941
+ login = parts[0];
2942
+ password = parts[1];
2943
+ email = parts[2];
2944
+ emailPassword = parts[3];
2945
+ if (!username)
2946
+ username = login;
2947
+ }
2948
+ else {
2949
+ login = flags.login;
2950
+ password = flags.password;
2951
+ email = flags.email;
2952
+ emailPassword = flags['email-password'] || flags.emailpw;
2953
+ }
2954
+ const sessionid = flags.sessionid;
2955
+ const csrf = flags.csrf || flags['tt-csrf'];
2956
+ const webid = flags.webid || flags['tt-webid'];
2957
+ const totpSeed = flags['totp-seed'] || flags.totp;
2958
+ const profileUrl = flags['profile-url'];
2959
+ const country = flags.country?.toLowerCase();
2960
+ if (!username)
2961
+ err('<username> (or --username / --credentials-line) required');
2962
+ if (!password && !sessionid) {
2963
+ err('Provide either --sessionid <hex> for cookie-injection, or --password (via --credentials-line or --password flag) for password login.');
2964
+ }
2965
+ if (!country) {
2966
+ err('--country <iso-2> required (e.g. --country de). Drives proxy exit + browser locale; without it TikTok flags geography mismatch.');
2967
+ }
2968
+ // Pre-flight: check whether the email provider will actually work
2969
+ // with our (password-based) IMAP reader. Blocks unsupported
2970
+ // providers up front so you don't pay for marketplace accounts
2971
+ // that can't be automated.
2972
+ if (email && !sessionid && !flags['force-email']) {
2973
+ const emailDomain = email.slice(email.lastIndexOf('@') + 1).toLowerCase();
2974
+ const microsoftDomains = /^(hotmail|outlook|live|msn)\.com$/;
2975
+ const gmailDomains = /^(gmail|googlemail)\.com$/;
2976
+ const yahooDomains = /^(yahoo|ymail)\./;
2977
+ const protonDomains = /^(proton|protonmail)\./;
2978
+ if (microsoftDomains.test(emailDomain)) {
2979
+ err(`This account's email is ${emailDomain}. Microsoft disabled password IMAP for consumer accounts in late 2022, and our OAuth2 integration isn't wired yet.\n\n` +
2980
+ ` Recommendation: buy a TikTok account with a rambler.ru, mail.ru, or yandex.ru email — those work out of the box.\n` +
2981
+ ` Override (not recommended): --force-email`, EXIT.BAD_INPUT);
2982
+ }
2983
+ if (gmailDomains.test(emailDomain)) {
2984
+ const looksLikeAppPassword = typeof emailPassword === 'string' && /^[a-z]{16}$/.test(emailPassword);
2985
+ if (!looksLikeAppPassword) {
2986
+ err(`This account's email is ${emailDomain}. Gmail needs a 16-char app-password (lowercase letters) for IMAP, not the regular account password.\n\n` +
2987
+ ` Your email_password looks like a regular password. It will fail at IMAP auth.\n` +
2988
+ ` Recommendation: buy accounts with rambler.ru or mail.ru email instead.\n` +
2989
+ ` Override: --force-email`, EXIT.BAD_INPUT);
2990
+ }
2991
+ }
2992
+ if (yahooDomains.test(emailDomain)) {
2993
+ err(`This account's email is ${emailDomain}. Yahoo needs an app-password for IMAP and marketplace accounts rarely ship with one.\n\n` +
2994
+ ` Recommendation: buy accounts with rambler.ru or mail.ru email instead.\n` +
2995
+ ` Override: --force-email`, EXIT.BAD_INPUT);
2996
+ }
2997
+ if (protonDomains.test(emailDomain)) {
2998
+ err(`This account's email is ${emailDomain}. ProtonMail IMAP requires a local Bridge app — not usable server-side.\n\n` +
2999
+ ` Recommendation: buy accounts with rambler.ru or mail.ru email instead.`, EXIT.BAD_INPUT);
3000
+ }
3001
+ }
3002
+ const creds = {
3003
+ login: login || username,
3004
+ password: password || 'unknown',
3005
+ email: email || login,
3006
+ email_password: emailPassword,
3007
+ totp_seed: totpSeed,
3008
+ profile_url: profileUrl,
3009
+ tiktok_sessionid: sessionid,
3010
+ tiktok_csrf: csrf,
3011
+ tiktok_webid: webid,
3012
+ };
3013
+ const summary = sv.importAccount(platform, username, creds, {
3014
+ source: line ? 'marketplace-line' : 'import',
3015
+ country,
3016
+ });
3017
+ const loginPath = sessionid ? 'cookie-injection' : 'form-login (requires CAPSOLVER_API_KEY server-side)';
3018
+ log(`tiktok import: ${summary.username} (${summary.id}) [login: ${loginPath}, country: ${country}]`);
3019
+ return print({ ...summary, has_sessionid: !!sessionid, has_password: !!password, login_path: loginPath });
3020
+ }
3021
+ case 'list': {
3022
+ const accounts = sv.listAccounts(platform);
3023
+ return print({ accounts, count: accounts.length });
3024
+ }
3025
+ case 'info': {
3026
+ const username = positional[0] || flags.username;
3027
+ if (!username)
3028
+ err('<username> required');
3029
+ const acc = sv.getAccount(platform, username);
3030
+ if (!acc)
3031
+ err(`tiktok account "${username}" not found locally`, EXIT.NOT_FOUND);
3032
+ return print(acc);
3033
+ }
3034
+ case 'rename': {
3035
+ const oldUsername = positional[0] || flags.username;
3036
+ const newUsername = flags.to;
3037
+ if (!oldUsername)
3038
+ err('<old-username> required');
3039
+ if (!newUsername)
3040
+ err('--to <new-username> required');
3041
+ const summary = sv.renameAccount(platform, oldUsername, newUsername);
3042
+ log(`tiktok rename: ${oldUsername} → ${newUsername}`);
3043
+ return print(summary);
3044
+ }
3045
+ case 'remove': {
3046
+ const username = positional[0] || flags.username;
3047
+ if (!username)
3048
+ err('<username> required');
3049
+ if (!flags.confirm) {
3050
+ err(`This deletes the local copy of "${username}". The TikTok account itself is NOT deleted.\n\n` +
3051
+ ` Re-run with --confirm to proceed:\n` +
3052
+ ` palmyr tiktok remove ${username} --confirm`);
3053
+ }
3054
+ sv.removeAccount(platform, username);
3055
+ log(`tiktok remove: ${username}`);
3056
+ return print({ success: true, platform, username });
3057
+ }
3058
+ case 'totp': {
3059
+ const username = positional[0] || flags.username;
3060
+ if (!username)
3061
+ err('<username> required');
3062
+ const creds = sv.unlockCredentials(platform, username);
3063
+ if (!creds.totp_seed)
3064
+ err(`tiktok account "${username}" has no TOTP seed configured`, EXIT.NOT_FOUND);
3065
+ const { code, secondsUntilNextCode } = await import('./totp.js');
3066
+ return print({
3067
+ platform,
3068
+ username,
3069
+ code: code(creds.totp_seed),
3070
+ expires_in_seconds: secondsUntilNextCode(),
3071
+ });
3072
+ }
3073
+ case 'login': {
3074
+ const username = positional[0] || flags.username;
3075
+ if (!username)
3076
+ err('<username> required');
3077
+ const acc = sv.getAccount(platform, username);
3078
+ if (!acc)
3079
+ err(`tiktok account "${username}" not found locally`, EXIT.NOT_FOUND);
3080
+ const creds = sv.unlockCredentials(platform, username);
3081
+ const hasCookies = !!creds.tiktok_sessionid;
3082
+ const hasPassword = !!(creds.login && creds.password && creds.password !== 'unknown');
3083
+ if (!hasCookies && !hasPassword) {
3084
+ err('Account has no cookies and no password. Re-import with either --sessionid or --credentials-line.', EXIT.BAD_INPUT);
3085
+ }
3086
+ const psid = sv.getProxySessionId(platform, username);
3087
+ const country = sv.getCountry(platform, username);
3088
+ let data;
3089
+ try {
3090
+ data = await ao.socialTiktokLogin(acc.id, {
3091
+ sessionid: creds.tiktok_sessionid,
3092
+ ttCsrfToken: creds.tiktok_csrf,
3093
+ ttWebidV2: creds.tiktok_webid,
3094
+ login: hasCookies ? undefined : creds.login,
3095
+ password: hasCookies ? undefined : creds.password,
3096
+ // Email creds enable server-side auto-solve of TikTok's
3097
+ // "Verify it's really you" device-verification challenge.
3098
+ email: creds.email,
3099
+ emailPassword: creds.email_password,
3100
+ proxySessionId: psid,
3101
+ country,
3102
+ });
3103
+ }
3104
+ catch (e) {
3105
+ err(`Login failed: ${e.message}`, EXIT.GENERAL);
3106
+ }
3107
+ if (!data?.success) {
3108
+ err(`Login failed: ${data?.error || 'unknown error'}` +
3109
+ (data?.error_code ? ` [${data.error_code}]` : ''), EXIT.GENERAL);
3110
+ }
3111
+ sv.saveSession(acc.id, platform, data.cookies || []);
3112
+ sv.updateMeta(platform, username, { last_action_at: new Date().toISOString() });
3113
+ return print({
3114
+ success: true,
3115
+ platform,
3116
+ username,
3117
+ observed_username: data.observed_username,
3118
+ cookies_captured: (data.cookies || []).length,
3119
+ captured_at: data.captured_at,
3120
+ });
3121
+ }
3122
+ case 'session': {
3123
+ const username = positional[0] || flags.username;
3124
+ if (!username)
3125
+ err('<username> required');
3126
+ const acc = sv.getAccount(platform, username);
3127
+ if (!acc)
3128
+ err(`tiktok account "${username}" not found locally`, EXIT.NOT_FOUND);
3129
+ const sess = sv.loadSession(acc.id);
3130
+ if (!sess) {
3131
+ return print({
3132
+ platform,
3133
+ username,
3134
+ cached: false,
3135
+ hint: `No cached session. Run: palmyr tiktok login ${username}`,
3136
+ });
3137
+ }
3138
+ const ageHours = sv.sessionAgeHours(acc.id);
3139
+ return print({
3140
+ platform,
3141
+ username,
3142
+ cached: true,
3143
+ cookies: sess.cookies.length,
3144
+ captured_at: sess.captured_at,
3145
+ age_hours: Number((ageHours || 0).toFixed(2)),
3146
+ stale: (ageHours || 0) > 12,
3147
+ });
3148
+ }
3149
+ case 'post':
3150
+ case 'follow':
3151
+ case 'like':
3152
+ case 'delete':
3153
+ case 'bio':
3154
+ case 'name':
3155
+ case 'pfp': {
3156
+ const username = positional[0] || flags.username;
3157
+ if (!username)
3158
+ err(`<username> required`);
3159
+ const acc = sv.getAccount(platform, username);
3160
+ if (!acc)
3161
+ err(`tiktok account "${username}" not found locally`, EXIT.NOT_FOUND);
3162
+ const sess = sv.loadSession(acc.id);
3163
+ if (!sess || !sess.cookies || sess.cookies.length === 0) {
3164
+ err(`No cached session for ${username}. Run 'tiktok login ${username}' first.`, EXIT.NOT_FOUND);
3165
+ }
3166
+ const psid = sv.getProxySessionId(platform, username);
3167
+ const country = sv.getCountry(platform, username);
3168
+ let data;
3169
+ try {
3170
+ if (subcommand === 'post') {
3171
+ const caption = flags.caption || flags.body || flags.text;
3172
+ if (!caption)
3173
+ err('--caption "..." required');
3174
+ const filePath = flags.file || flags.path;
3175
+ const videoUrl = flags.url;
3176
+ if (!filePath && !videoUrl)
3177
+ err('--file <local-path> or --url <https-url> required');
3178
+ let media = {};
3179
+ if (filePath) {
3180
+ const { readFileSync, existsSync, statSync } = await import('fs');
3181
+ if (!existsSync(filePath))
3182
+ err(`File not found: ${filePath}`, EXIT.NOT_FOUND);
3183
+ const size = statSync(filePath).size;
3184
+ if (size > 100 * 1024 * 1024)
3185
+ err(`Video too large (${size} bytes, max 100 MB)`, EXIT.BAD_INPUT);
3186
+ const buf = readFileSync(filePath);
3187
+ media.video_base64 = `data:video/mp4;base64,${buf.toString('base64')}`;
3188
+ }
3189
+ else {
3190
+ media.video_url = videoUrl;
3191
+ }
3192
+ const privacy = flags.privacy !== undefined ? Number(flags.privacy) : undefined;
3193
+ data = await ao.socialTiktokPost(acc.id, sess.cookies, caption, media, { privacy }, psid, country);
3194
+ }
3195
+ else if (subcommand === 'follow') {
3196
+ const target = flags.user || flags.target;
3197
+ if (!target)
3198
+ err('--user <@handle> required');
3199
+ data = await ao.socialTiktokFollow(acc.id, sess.cookies, target, psid, country);
3200
+ }
3201
+ else if (subcommand === 'like') {
3202
+ const videoUrl = flags.video || flags.url;
3203
+ if (!videoUrl)
3204
+ err('--video <tiktok-url> required');
3205
+ data = await ao.socialTiktokLike(acc.id, sess.cookies, videoUrl, psid, country);
3206
+ }
3207
+ else if (subcommand === 'delete') {
3208
+ const videoUrl = flags.video || flags.url;
3209
+ if (!videoUrl)
3210
+ err('--video <tiktok-url> required');
3211
+ data = await ao.socialTiktokDelete(acc.id, sess.cookies, videoUrl, psid, country);
3212
+ }
3213
+ else if (subcommand === 'bio') {
3214
+ const text = flags.text || flags.body;
3215
+ if (text === undefined)
3216
+ err('--text "..." required (pass "" to clear)');
3217
+ data = await ao.socialTiktokProfile(acc.id, sess.cookies, { bio: text }, psid, country);
3218
+ }
3219
+ else if (subcommand === 'name') {
3220
+ const text = flags.display || flags.text || flags.name;
3221
+ if (!text)
3222
+ err('--display "Display Name" required');
3223
+ data = await ao.socialTiktokProfile(acc.id, sess.cookies, { display_name: text }, psid, country);
3224
+ }
3225
+ else {
3226
+ // pfp
3227
+ const filePath = flags.file || flags.path;
3228
+ const imageUrl = flags.url;
3229
+ if (!filePath && !imageUrl)
3230
+ err('--file <local-path> or --url <https-url> required');
3231
+ let image = {};
3232
+ if (filePath) {
3233
+ const { readFileSync, existsSync } = await import('fs');
3234
+ if (!existsSync(filePath))
3235
+ err(`File not found: ${filePath}`, EXIT.NOT_FOUND);
3236
+ const buf = readFileSync(filePath);
3237
+ const ext = filePath.toLowerCase().match(/\.(png|jpg|jpeg|webp)$/)?.[1] || 'png';
3238
+ image.image_base64 = `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${buf.toString('base64')}`;
3239
+ }
3240
+ else {
3241
+ image.image_url = imageUrl;
3242
+ }
3243
+ data = await ao.socialTiktokAvatar(acc.id, sess.cookies, image, psid, country);
3244
+ }
3245
+ }
3246
+ catch (e) {
3247
+ err(`${subcommand} failed: ${e.message}`, EXIT.GENERAL);
3248
+ }
3249
+ if (!data?.success) {
3250
+ err(`${subcommand} failed: ${data?.error || 'unknown'}` +
3251
+ (data?.error_code ? ` [${data.error_code}]` : ''), EXIT.GENERAL);
3252
+ }
3253
+ sv.updateMeta(platform, username, { last_action_at: new Date().toISOString() });
3254
+ return print({ success: true, platform, username, op: subcommand, ...(data?.data || {}) });
3255
+ }
3256
+ default:
3257
+ err(`Unknown tiktok command: ${subcommand}. Try: import, list, info, rename, remove, totp, login, session, post, follow, like, delete, bio, name, pfp`);
3258
+ }
3259
+ break;
3260
+ }
3261
+ case 'worker': {
3262
+ // The local worker daemon was deprecated in favor of server-side
3263
+ // scheduling — `palmyr twitter schedule` now POSTs to the Palmyr
3264
+ // server, which runs its own scheduler internally and fires posts
3265
+ // automatically. No daemon to manage on the user's machine.
3266
+ err('Local worker is deprecated. Server-side scheduling is automatic — ' +
3267
+ 'register an account (`palmyr twitter register <user>`), then schedule ' +
3268
+ 'posts (`palmyr twitter schedule <user> --body "..." --at "..."`). ' +
3269
+ 'The Palmyr server fires them at post_at without any client process.', EXIT.BAD_INPUT);
3270
+ break;
3271
+ }
3272
+ case 'config': {
3273
+ const cfg = loadConfig();
3274
+ const { homedir } = await import('os');
3275
+ const { join } = await import('path');
3276
+ const vaultDir = process.env.PALMYR_WALLET_PATH || join(homedir(), '.palmyr', 'wallet');
3277
+ const { isCredentialStoreAvailable } = await import('./credential-store.js');
3278
+ const configData = {
3279
+ api: cfg.api,
3280
+ defaultChain: cfg.defaultChain,
3281
+ setupDone: cfg.setupDone,
3282
+ defaultPayWalletId: cfg.defaultPayWalletId || null,
3283
+ defaultPayChain: cfg.defaultPayChain || 'solana',
3284
+ vaultPath: vaultDir,
3285
+ credentialStore: isCredentialStoreAvailable() ? 'available' : 'unavailable',
3286
+ configPath: join(homedir(), '.palmyr', 'config.json'),
3287
+ cliVersion: VERSION,
3288
+ };
3289
+ if (!AGENT_MODE) {
3290
+ render(React.createElement(ConfigScreen, { version: VERSION, config: configData }));
3291
+ }
3292
+ else {
3293
+ print(configData);
3294
+ }
3295
+ break;
3296
+ }
3297
+ case 'doctor': {
3298
+ const checks = [];
3299
+ const { homedir } = await import('os');
3300
+ const { join } = await import('path');
3301
+ const { existsSync } = await import('fs');
3302
+ // 1. Vault directory
3303
+ const vaultDir = process.env.PALMYR_WALLET_PATH || join(homedir(), '.palmyr', 'wallet');
3304
+ const vaultExists = existsSync(join(vaultDir, 'wallets'));
3305
+ checks.push({ name: 'Vault directory', status: vaultExists ? 'pass' : 'fail', detail: vaultExists ? vaultDir : 'Not found — run: palmyr wallet create' });
3306
+ // 2. Credential store
3307
+ const { isCredentialStoreAvailable } = await import('./credential-store.js');
3308
+ const credAvail = isCredentialStoreAvailable();
3309
+ checks.push({ name: 'OS credential store', status: credAvail ? 'pass' : 'fail', detail: credAvail ? `${process.platform} store available` : 'Not available — wallet keys cannot be stored securely' });
3310
+ // 3. Local wallets
3311
+ const { listVaultWallets } = await import('./vault.js');
3312
+ const wallets = listVaultWallets();
3313
+ checks.push({ name: 'Local wallets', status: wallets.length > 0 ? 'pass' : 'warn', detail: `${wallets.length} wallet(s) found` });
3314
+ // 4. Session secrets present for wallets
3315
+ const { retrieveSecret } = await import('./credential-store.js');
3316
+ let secretsOk = 0, secretsMissing = 0;
3317
+ for (const w of wallets) {
3318
+ if (retrieveSecret(w.id))
3319
+ secretsOk++;
3320
+ else
3321
+ secretsMissing++;
3322
+ }
3323
+ if (wallets.length > 0) {
3324
+ checks.push({
3325
+ name: 'Session secrets',
3326
+ status: secretsMissing === 0 ? 'pass' : 'fail',
3327
+ detail: secretsMissing === 0 ? `All ${secretsOk} wallet(s) have secrets stored` : `${secretsMissing} wallet(s) missing session secret`,
3328
+ });
3329
+ }
3330
+ // 5. API connectivity
3331
+ try {
3332
+ const health = await ao.health();
3333
+ checks.push({ name: 'API connectivity', status: health.status === 'healthy' ? 'pass' : 'warn', detail: `${ao.api} — ${health.status}` });
3334
+ if (health.version?.version && health.version.version !== VERSION) {
3335
+ checks.push({ name: 'Version match', status: 'warn', detail: `CLI ${VERSION} vs server ${health.version.version}` });
3336
+ }
3337
+ else {
3338
+ checks.push({ name: 'Version match', status: 'pass', detail: `CLI ${VERSION}` });
3339
+ }
3340
+ }
3341
+ catch {
3342
+ checks.push({ name: 'API connectivity', status: 'warn', detail: `${ao.api} — unreachable (local-only mode works fine)` });
3343
+ }
3344
+ const failCount = checks.filter(c => c.status === 'fail').length;
3345
+ if (!AGENT_MODE) {
3346
+ render(React.createElement(DoctorScreen, { version: VERSION, checks }));
3347
+ }
3348
+ else {
3349
+ print({ checks });
3350
+ }
3351
+ if (failCount > 0)
3352
+ process.exit(EXIT.GENERAL);
3353
+ break;
3354
+ }
3355
+ case 'pricing': {
3356
+ const data = await ao.pricing();
3357
+ return print(data);
3358
+ const services = Object.entries(data.services || {}).map(([name, prices]) => ({
3359
+ name,
3360
+ items: typeof prices === 'object'
3361
+ ? Object.entries(prices).map(([label, value]) => ({ label, value: String(value) }))
3362
+ : [],
3363
+ }));
3364
+ render(React.createElement(PricingScreen, {
3365
+ version: VERSION,
3366
+ services,
3367
+ interactive: fromHome,
3368
+ onBack: fromHome ? () => {
3369
+ process.env.PALMYR_FROM_HOME = '0';
3370
+ process.argv = [process.argv[0], process.argv[1]];
3371
+ void main();
3372
+ } : undefined,
3373
+ }));
3374
+ break;
3375
+ }
3376
+ case 'health': {
3377
+ const data = await ao.health();
3378
+ return print(data);
3379
+ // Version check — warn if CLI is behind the server
3380
+ const serverVersion = data.version?.version;
3381
+ if (serverVersion && serverVersion !== VERSION) {
3382
+ console.log(` ${t.warn}Update available:${t.reset} CLI ${VERSION} → server ${serverVersion}`);
3383
+ console.log(` ${t.muted}Run: npm install -g @palmyr/cli${t.reset}\n`);
3384
+ }
3385
+ render(React.createElement(HealthScreen, {
3386
+ version: VERSION,
3387
+ status: data.status || 'unknown',
3388
+ uptime: data.uptime?.human || '?',
3389
+ apiVersion: serverVersion || '?',
3390
+ interactive: fromHome,
3391
+ onBack: fromHome ? () => {
3392
+ process.env.PALMYR_FROM_HOME = '0';
3393
+ process.argv = [process.argv[0], process.argv[1]];
3394
+ void main();
3395
+ } : undefined,
3396
+ }));
3397
+ break;
3398
+ }
3399
+ default:
3400
+ if (AGENT_MODE) {
3401
+ process.stderr.write(JSON.stringify({
3402
+ error: `Unknown command: ${command}`,
3403
+ hint: 'Run palmyr --help for usage',
3404
+ exitCode: EXIT.BAD_INPUT,
3405
+ }) + '\n');
3406
+ }
3407
+ else {
3408
+ render(React.createElement(ErrorScreen, {
3409
+ version: VERSION,
3410
+ title: 'Unknown command',
3411
+ message: `Unknown command: ${command}`,
3412
+ hint: 'Run palmyr --help for usage',
3413
+ footerLeft: 'Command not found',
3414
+ }));
3415
+ }
3416
+ process.exit(EXIT.BAD_INPUT);
3417
+ }
3418
+ }
3419
+ catch (e) {
3420
+ // Sanitize error output — never expose stack traces or internal paths.
3421
+ // The same sanitized message goes to both the Ink ErrorScreen and the
3422
+ // JSON-mode stderr line, so behaviour matches across modes.
3423
+ const rawMsg = e.message || String(e);
3424
+ const safeMsg = rawMsg
3425
+ .replace(/\s*at\s+.+/g, '') // strip stack frames
3426
+ .replace(/[A-Z]:\\[^\s:]+/gi, '[path]') // strip Windows paths
3427
+ .replace(/\/[^\s:]+\.(ts|js)/g, '[path]') // strip Unix paths
3428
+ .trim();
3429
+ // Map the raw error to a stable exit code + a one-line agent-friendly hint.
3430
+ let exitCode = EXIT.GENERAL;
3431
+ let title = 'Command failed';
3432
+ let hint;
3433
+ let footerLeft = 'See palmyr --help';
3434
+ if (rawMsg.startsWith('Payment Required:') || rawMsg.includes('settlement failed') || rawMsg.includes('verification failed')) {
3435
+ exitCode = EXIT.PAYMENT;
3436
+ title = 'Payment rejected';
3437
+ hint = rawMsg.includes('settlement failed')
3438
+ ? 'On-chain tx reverted. Check wallet balance (USDC only — chain fees are paid by the server). View tx on explorer if settled partially.'
3439
+ : 'The server rejected the payment signature. Check your default pay wallet: palmyr config';
3440
+ footerLeft = 'x402 payment failed';
3441
+ }
3442
+ else if (rawMsg === 'Payment Required' || rawMsg.includes('402')) {
3443
+ exitCode = EXIT.PAYMENT;
3444
+ title = 'Payment required';
3445
+ hint = 'Set a default pay wallet: palmyr wallet use <ID>';
3446
+ footerLeft = 'Provisioning blocked until payment';
3447
+ }
3448
+ else if (rawMsg.includes('SECURITY')) {
3449
+ exitCode = EXIT.SECURITY;
3450
+ title = 'Security violation';
3451
+ hint = 'A wallet file may have been tampered with. Do not use it.';
3452
+ footerLeft = 'Operation blocked';
3453
+ }
3454
+ else if (rawMsg.includes('ECONNREFUSED') || rawMsg.includes('fetch failed')) {
3455
+ exitCode = EXIT.NETWORK;
3456
+ hint = 'Is the API running? Check: palmyr health';
3457
+ }
3458
+ else if (rawMsg.includes('Authentication') || rawMsg.includes('401') || rawMsg.includes('Unauthorized')) {
3459
+ exitCode = EXIT.AUTH_FAIL;
3460
+ hint = 'Check your API token or session';
3461
+ }
3462
+ else if (rawMsg.includes('session secret') || rawMsg.includes('credential store')) {
3463
+ exitCode = EXIT.NOT_FOUND;
3464
+ hint = 'Create a wallet first: palmyr wallet create';
3465
+ }
3466
+ else if (rawMsg.includes('not found')) {
3467
+ exitCode = EXIT.NOT_FOUND;
3468
+ const scope = rawMsg.includes('twitter account') ? 'twitter'
3469
+ : rawMsg.includes('tiktok account') ? 'tiktok'
3470
+ : 'wallet';
3471
+ hint = `Check the name with: palmyr ${scope} list`;
3472
+ }
3473
+ if (AGENT_MODE) {
3474
+ const payload = {
3475
+ error: safeMsg || 'An unexpected error occurred',
3476
+ exitCode,
3477
+ };
3478
+ if (hint)
3479
+ payload.hint = hint;
3480
+ process.stderr.write(JSON.stringify(payload) + '\n');
3481
+ }
3482
+ else {
3483
+ render(React.createElement(ErrorScreen, {
3484
+ version: VERSION,
3485
+ title,
3486
+ message: safeMsg || 'An unexpected error occurred',
3487
+ hint,
3488
+ footerLeft,
3489
+ }));
3490
+ }
3491
+ process.exit(exitCode);
3492
+ }
3493
+ }
3494
+ main();
3495
+ //# sourceMappingURL=cli.js.map