@blockrun/franklin 3.1.2 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/banner.js CHANGED
@@ -1,5 +1,39 @@
1
1
  import chalk from 'chalk';
2
- // "FRANKLIN" the AI agent with a wallet
2
+ // ─── Ben Franklin portrait ─────────────────────────────────────────────────
3
+ //
4
+ // Generated once, at build time, from the Joseph Duplessis 1785 oil painting
5
+ // of Benjamin Franklin (same source as the portrait on the US $100 bill).
6
+ // Public domain image from Wikimedia Commons:
7
+ // https://commons.wikimedia.org/wiki/File:BenFranklinDuplessis.jpg
8
+ //
9
+ // Rendered via chafa with:
10
+ // chafa --size=20x10 --symbols=block --colors=256 ben-franklin.jpg
11
+ //
12
+ // The raw ANSI escape codes are hex-encoded below so the TS source stays
13
+ // readable and diff-friendly. Each string is one row of the portrait.
14
+ // Visible dimensions: ~17 characters wide × 10 rows tall.
15
+ //
16
+ // Rendered best in a 256-color or truecolor terminal. Degrades gracefully
17
+ // (shows as block-character garbage) on ancient terminals — but those
18
+ // are long gone and we don't support them.
19
+ const BEN_PORTRAIT_ROWS = [
20
+ '\x1b[0m\x1b[38;5;232;48;5;16m▏ \x1b[48;5;232m \x1b[48;5;16m▂\x1b[48;5;232m \x1b[38;5;233m▃▃\x1b[48;5;233m \x1b[0m',
21
+ '\x1b[38;5;232;48;5;16m▏ \x1b[38;5;234m▂\x1b[38;5;95;48;5;232m▄\x1b[38;5;137;48;5;233m▅\x1b[38;5;173m▅\x1b[38;5;137;48;5;234m▃\x1b[38;5;235;48;5;233m▁ \x1b[38;5;234m▄ \x1b[0m',
22
+ '\x1b[38;5;232;48;5;16m▏▕ \x1b[38;5;234;48;5;232m▁\x1b[38;5;235;48;5;237m▎\x1b[38;5;58;48;5;179m▌\x1b[38;5;131m▄\x1b[38;5;94m▖\x1b[38;5;58;48;5;137m▗\x1b[48;5;235m▍\x1b[38;5;235;48;5;234m▆▆▄▅ \x1b[0m',
23
+ '\x1b[38;5;233;48;5;232m▏\x1b[38;5;232;48;5;16m▏\x1b[38;5;16;48;5;232m▏\x1b[38;5;233m▗\x1b[38;5;236;48;5;240m▎\x1b[38;5;235;48;5;238m▄\x1b[38;5;95;48;5;173m▎\x1b[38;5;173;48;5;179m▖\x1b[38;5;137m▁\x1b[48;5;94m▍\x1b[38;5;94;48;5;233m▋\x1b[38;5;233;48;5;235m▖\x1b[38;5;236m▄ \x1b[38;5;235;48;5;234m▌ \x1b[0m',
24
+ '\x1b[38;5;233;48;5;232m▏ \x1b[38;5;232;48;5;234m▌\x1b[38;5;240;48;5;236m▁\x1b[38;5;95;48;5;235m▁\x1b[38;5;186;48;5;137m▗ \x1b[38;5;95;48;5;173m▃\x1b[38;5;137;48;5;94m▘\x1b[38;5;58;48;5;234m▍\x1b[38;5;234;48;5;236m▘ \x1b[38;5;236;48;5;235m▏ \x1b[38;5;232;48;5;234m▄\x1b[0m',
25
+ '\x1b[38;5;233;48;5;232m▏\x1b[38;5;235m▁\x1b[38;5;95;48;5;234m▄\x1b[38;5;137;48;5;236m▆\x1b[48;5;95m \x1b[38;5;101m▁\x1b[38;5;137m▔\x1b[48;5;186m▄\x1b[48;5;95m▍\x1b[48;5;236m▃\x1b[38;5;143;48;5;235m▃\x1b[38;5;236m▏\x1b[38;5;235;48;5;236m▃\x1b[48;5;235m \x1b[38;5;234m▁\x1b[38;5;235;48;5;232m▘\x1b[38;5;232;48;5;16m▔\x1b[0m',
26
+ '\x1b[38;5;238;48;5;233m▗\x1b[38;5;8;48;5;137m▘\x1b[38;5;138m▘ \x1b[38;5;137;48;5;95m▊\x1b[38;5;95;48;5;101m▎\x1b[38;5;137;48;5;95m▎\x1b[48;5;101m▌\x1b[48;5;95m \x1b[38;5;95;48;5;101m▏\x1b[38;5;143m▔\x1b[38;5;101;48;5;236m▅\x1b[38;5;240;48;5;234m▖\x1b[38;5;235m▞\x1b[38;5;234;48;5;232m▘\x1b[38;5;232;48;5;16m▔ \x1b[0m',
27
+ '\x1b[38;5;52;48;5;95m▋\x1b[48;5;137m \x1b[38;5;95;48;5;101m▕\x1b[38;5;240;48;5;137m▌\x1b[38;5;101m▂ \x1b[48;5;95m▏\x1b[38;5;239m▖▂\x1b[38;5;237m▄\x1b[38;5;101;48;5;234m▘\x1b[38;5;234;48;5;233m▝\x1b[48;5;232m▖\x1b[48;5;16m \x1b[0m',
28
+ '\x1b[38;5;235;48;5;95m▌\x1b[38;5;95;48;5;137m▄\x1b[38;5;101m▁ \x1b[48;5;95m▌\x1b[38;5;238m▋\x1b[38;5;240;48;5;101m▃ \x1b[38;5;95;48;5;240m▎\x1b[38;5;236;48;5;239m▝\x1b[38;5;95;48;5;235m▆\x1b[38;5;240m▆\x1b[38;5;237;48;5;233m▆\x1b[48;5;234m▃\x1b[38;5;235;48;5;233m▎\x1b[38;5;233;48;5;232m▖\x1b[48;5;16m \x1b[0m',
29
+ '\x1b[38;5;234;48;5;95m▌ \x1b[38;5;95;48;5;101m▃\x1b[38;5;239;48;5;95m▗\x1b[38;5;238;48;5;234m▋\x1b[38;5;236;48;5;101m▎\x1b[38;5;101;48;5;95m▋\x1b[38;5;239m▂▎ \x1b[48;5;240m▃\x1b[38;5;8;48;5;236m▍\x1b[38;5;235;48;5;233m▋\x1b[38;5;232m▅\x1b[38;5;233;48;5;232m▖\x1b[0m',
30
+ ];
31
+ // ─── FRANKLIN text banner (gold → emerald gradient) ────────────────────────
32
+ //
33
+ // Kept from v3.1.0. The text is laid out as 6 block-letter rows. Each row
34
+ // is tinted with a color interpolated between GOLD_START and EMERALD_END,
35
+ // giving the smooth vertical gradient that's been Franklin's banner since
36
+ // v3.1.0.
3
37
  const FRANKLIN_ART = [
4
38
  ' ███████╗██████╗ █████╗ ███╗ ██╗██╗ ██╗██╗ ██╗███╗ ██╗',
5
39
  ' ██╔════╝██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝██║ ██║████╗ ██║',
@@ -8,10 +42,6 @@ const FRANKLIN_ART = [
8
42
  ' ██║ ██║ ██║██║ ██║██║ ╚████║██║ ██╗███████╗██║██║ ╚████║',
9
43
  ' ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═══╝',
10
44
  ];
11
- // Franklin brand gradient: gold → emerald
12
- // Gold (#FFD700) is the $100 bill / Benjamins color — marketing product energy
13
- // Emerald (#10B981) is the trading / money-moving color
14
- // The gradient between them tells the Franklin story in one glance
15
45
  const GOLD_START = '#FFD700';
16
46
  const EMERALD_END = '#10B981';
17
47
  function hexToRgb(hex) {
@@ -31,11 +61,97 @@ function interpolateHex(start, end, t) {
31
61
  const [r2, g2, b2] = hexToRgb(end);
32
62
  return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t);
33
63
  }
64
+ // ─── Banner layout ─────────────────────────────────────────────────────────
65
+ // Minimum terminal width to show the side-by-side portrait + text layout.
66
+ // The portrait is ~17 chars, the FRANKLIN text is ~65 chars, plus a 4-char
67
+ // gap = 86 chars. We add a small margin so ~90 cols is the threshold.
68
+ const MIN_WIDTH_FOR_PORTRAIT = 90;
69
+ /**
70
+ * Pad a line to an exact visual width, ignoring ANSI escape codes when
71
+ * measuring. Used to align the portrait's right edge before the text block.
72
+ */
73
+ function padVisible(s, targetWidth) {
74
+ // Strip ANSI color codes to measure visible length
75
+ // eslint-disable-next-line no-control-regex
76
+ const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
77
+ // Unicode block characters are width 1 (they're half-blocks, not double-width)
78
+ const current = [...visible].length;
79
+ if (current >= targetWidth)
80
+ return s;
81
+ // Append a reset + padding so background colors don't bleed into the gap
82
+ return s + '\x1b[0m' + ' '.repeat(targetWidth - current);
83
+ }
34
84
  export function printBanner(version) {
35
- // Smooth 6-row gradient: each line gets its own interpolated color
36
- const steps = FRANKLIN_ART.length;
37
- for (let i = 0; i < steps; i++) {
38
- const t = i / (steps - 1);
85
+ const termWidth = process.stdout.columns ?? 80;
86
+ const useSideBySide = termWidth >= MIN_WIDTH_FOR_PORTRAIT;
87
+ if (useSideBySide) {
88
+ printSideBySide(version);
89
+ }
90
+ else {
91
+ printTextOnly(version);
92
+ }
93
+ }
94
+ /**
95
+ * Full layout: Ben Franklin portrait on the left, FRANKLIN text block on the
96
+ * right. Portrait is 10 rows, text is 6 rows — portrait extends 4 rows below
97
+ * the text, so the 2-row tagline sits under the text and the last 2 rows
98
+ * below the portrait are just the bottom of the portrait.
99
+ *
100
+ * [portrait row 1] [empty]
101
+ * [portrait row 2] [empty]
102
+ * [portrait row 3] ███████╗██████╗ █████╗ ...
103
+ * [portrait row 4] ██╔════╝██╔══██╗██╔══██╗...
104
+ * [portrait row 5] █████╗ ██████╔╝███████║...
105
+ * [portrait row 6] ██╔══╝ ██╔══██╗██╔══██║...
106
+ * [portrait row 7] ██║ ██║ ██║██║ ██║...
107
+ * [portrait row 8] ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝...
108
+ * [portrait row 9] Franklin · The AI agent with a wallet · vX
109
+ * [portrait row 10] (empty)
110
+ *
111
+ * The text is vertically centered within the portrait — its top edge sits
112
+ * at portrait row 3 so there's a 2-row header padding above.
113
+ */
114
+ function printSideBySide(version) {
115
+ const TEXT_TOP_OFFSET = 2; // rows of portrait above the text
116
+ const PORTRAIT_WIDTH = 18; // columns (char width) of the portrait + 1 pad
117
+ const GAP = ' '; // gap between portrait and text
118
+ const portraitRows = BEN_PORTRAIT_ROWS;
119
+ const textRows = FRANKLIN_ART.length;
120
+ const totalRows = Math.max(portraitRows.length, TEXT_TOP_OFFSET + textRows + 2);
121
+ for (let i = 0; i < totalRows; i++) {
122
+ const portraitLine = i < portraitRows.length
123
+ ? padVisible(portraitRows[i], PORTRAIT_WIDTH)
124
+ : ' '.repeat(PORTRAIT_WIDTH);
125
+ // Text column content
126
+ let textCol = '';
127
+ const textIdx = i - TEXT_TOP_OFFSET;
128
+ if (textIdx >= 0 && textIdx < textRows) {
129
+ // FRANKLIN block letters with gradient colour
130
+ const t = textRows === 1 ? 0 : textIdx / (textRows - 1);
131
+ const color = interpolateHex(GOLD_START, EMERALD_END, t);
132
+ textCol = chalk.hex(color)(FRANKLIN_ART[textIdx]);
133
+ }
134
+ else if (textIdx === textRows) {
135
+ // Tagline row sits right under the FRANKLIN block
136
+ textCol =
137
+ chalk.bold.hex(GOLD_START)(' Franklin') +
138
+ chalk.dim(' · The AI agent with a wallet · v' + version);
139
+ }
140
+ // Write with a reset at the very start to prevent stray bg from the
141
+ // previous line bleeding into the current row's portrait column.
142
+ process.stdout.write('\x1b[0m' + portraitLine + GAP + textCol + '\x1b[0m\n');
143
+ }
144
+ // Trailing blank line for breathing room
145
+ process.stdout.write('\n');
146
+ }
147
+ /**
148
+ * Compact layout for narrow terminals: just the FRANKLIN text block with
149
+ * its gradient, no portrait. Matches the v3.1.0 banner exactly.
150
+ */
151
+ function printTextOnly(version) {
152
+ const textRows = FRANKLIN_ART.length;
153
+ for (let i = 0; i < textRows; i++) {
154
+ const t = textRows === 1 ? 0 : i / (textRows - 1);
39
155
  const color = interpolateHex(GOLD_START, EMERALD_END, t);
40
156
  console.log(chalk.hex(color)(FRANKLIN_ART[i]));
41
157
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * franklin social <action>
3
+ *
4
+ * Native X bot subsystem. No MCP, no plugin SDK, no external CLI deps.
5
+ * Ships as part of the core npm package; only runtime dep is playwright-core,
6
+ * which is lazy-imported so startup stays fast.
7
+ *
8
+ * Actions:
9
+ * setup — install chromium via playwright, write default config
10
+ * login x — open browser to x.com and wait for user to log in; save state
11
+ * run — search X, generate drafts, post (requires --live) or dry-run
12
+ * stats — show posted/skipped/drafted counts and total cost
13
+ * config — open ~/.blockrun/social-config.json for manual editing
14
+ */
15
+ export interface SocialCommandOptions {
16
+ dryRun?: boolean;
17
+ live?: boolean;
18
+ model?: string;
19
+ debug?: boolean;
20
+ }
21
+ /**
22
+ * Entry point wired from src/index.ts as `franklin social [action] [arg]`.
23
+ */
24
+ export declare function socialCommand(action: string | undefined, arg: string | undefined, options: SocialCommandOptions): Promise<void>;
@@ -0,0 +1,258 @@
1
+ /**
2
+ * franklin social <action>
3
+ *
4
+ * Native X bot subsystem. No MCP, no plugin SDK, no external CLI deps.
5
+ * Ships as part of the core npm package; only runtime dep is playwright-core,
6
+ * which is lazy-imported so startup stays fast.
7
+ *
8
+ * Actions:
9
+ * setup — install chromium via playwright, write default config
10
+ * login x — open browser to x.com and wait for user to log in; save state
11
+ * run — search X, generate drafts, post (requires --live) or dry-run
12
+ * stats — show posted/skipped/drafted counts and total cost
13
+ * config — open ~/.blockrun/social-config.json for manual editing
14
+ */
15
+ import chalk from 'chalk';
16
+ import fs from 'node:fs';
17
+ import { spawn } from 'node:child_process';
18
+ import { loadConfig as loadSocialConfig, saveConfig as saveSocialConfig, isConfigReady, CONFIG_PATH, } from '../social/config.js';
19
+ import { SocialBrowser, SOCIAL_PROFILE_DIR } from '../social/browser.js';
20
+ import { runX } from '../social/x.js';
21
+ import { getStats } from '../social/db.js';
22
+ import { loadChain, API_URLS } from '../config.js';
23
+ import { loadConfig as loadAppConfig } from './config.js';
24
+ /**
25
+ * Entry point wired from src/index.ts as `franklin social [action] [arg]`.
26
+ */
27
+ export async function socialCommand(action, arg, options) {
28
+ switch (action) {
29
+ case undefined:
30
+ case 'help':
31
+ printHelp();
32
+ return;
33
+ case 'setup':
34
+ await setupCommand();
35
+ return;
36
+ case 'login':
37
+ await loginCommand(arg);
38
+ return;
39
+ case 'run':
40
+ await runCommand(options);
41
+ return;
42
+ case 'stats':
43
+ statsCommand();
44
+ return;
45
+ case 'config':
46
+ configCommand(arg);
47
+ return;
48
+ default:
49
+ console.log(chalk.red(`Unknown social action: ${action}`));
50
+ printHelp();
51
+ process.exitCode = 1;
52
+ }
53
+ }
54
+ // ─── help ──────────────────────────────────────────────────────────────────
55
+ function printHelp() {
56
+ console.log('');
57
+ console.log(chalk.bold(' franklin social') + chalk.dim(' — native X bot (no MCP, no plugin deps)'));
58
+ console.log('');
59
+ console.log(' Actions:');
60
+ console.log(` ${chalk.cyan('setup')} Install chromium, create default config`);
61
+ console.log(` ${chalk.cyan('login x')} Open browser to x.com, save login state`);
62
+ console.log(` ${chalk.cyan('run')} Search X, generate + (optionally) post replies`);
63
+ console.log(` ${chalk.dim('--dry-run (default) generate drafts, do NOT post')}`);
64
+ console.log(` ${chalk.dim('--live actually post to X')}`);
65
+ console.log(` ${chalk.dim('-m <model> override the AI model')}`);
66
+ console.log(` ${chalk.cyan('stats')} Show posted / drafted / skipped totals`);
67
+ console.log(` ${chalk.cyan('config')} Print the path to the config file (or pass edit)`);
68
+ console.log('');
69
+ console.log(` Config: ${chalk.dim(CONFIG_PATH)}`);
70
+ console.log(` Profile: ${chalk.dim(SOCIAL_PROFILE_DIR)}`);
71
+ console.log('');
72
+ console.log(' Typical first-run flow:');
73
+ console.log(` ${chalk.cyan('$')} franklin social setup`);
74
+ console.log(` ${chalk.cyan('$')} franklin social config edit ${chalk.dim('# set handle, products, queries')}`);
75
+ console.log(` ${chalk.cyan('$')} franklin social login x ${chalk.dim('# log in once; cookies persist')}`);
76
+ console.log(` ${chalk.cyan('$')} franklin social run ${chalk.dim('# dry-run, preview drafts')}`);
77
+ console.log(` ${chalk.cyan('$')} franklin social run --live ${chalk.dim('# actually post')}`);
78
+ console.log('');
79
+ }
80
+ // ─── setup ────────────────────────────────────────────────────────────────
81
+ async function setupCommand() {
82
+ console.log(chalk.bold('\n Franklin social — setup\n'));
83
+ // 1. Install chromium via playwright CLI (ships with playwright-core)
84
+ console.log(chalk.dim(' Installing chromium for the social browser…'));
85
+ console.log(chalk.dim(' (~150MB, one-time download to ~/.cache/ms-playwright)\n'));
86
+ await runChild('npx', ['playwright', 'install', 'chromium']);
87
+ // 2. Ensure profile dir exists
88
+ if (!fs.existsSync(SOCIAL_PROFILE_DIR)) {
89
+ fs.mkdirSync(SOCIAL_PROFILE_DIR, { recursive: true });
90
+ console.log(chalk.green(` ✓ Created Chrome profile at ${SOCIAL_PROFILE_DIR}`));
91
+ }
92
+ // 3. Write default config if missing
93
+ const config = loadSocialConfig();
94
+ saveSocialConfig(config); // touches file so the user can edit
95
+ console.log(chalk.green(` ✓ Config ready at ${CONFIG_PATH}`));
96
+ console.log('');
97
+ console.log(chalk.bold(' Next steps:'));
98
+ console.log(` 1. ${chalk.cyan('franklin social config edit')} edit handle, products, search queries`);
99
+ console.log(` 2. ${chalk.cyan('franklin social login x')} log in to x.com (once — cookies persist)`);
100
+ console.log(` 3. ${chalk.cyan('franklin social run')} dry-run to preview drafts`);
101
+ console.log('');
102
+ }
103
+ // ─── login ─────────────────────────────────────────────────────────────────
104
+ async function loginCommand(platform) {
105
+ if (platform !== 'x') {
106
+ console.log(chalk.red(`Only "x" is supported. Usage: franklin social login x`));
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+ console.log(chalk.bold('\n Opening x.com for login…\n'));
111
+ console.log(chalk.dim(' A Chrome window will open. Log in to your X account,'));
112
+ console.log(chalk.dim(' then close the window when done. Cookies will persist'));
113
+ console.log(chalk.dim(` at ${SOCIAL_PROFILE_DIR}\n`));
114
+ const browser = new SocialBrowser({ headless: false });
115
+ try {
116
+ await browser.launch();
117
+ await browser.open('https://x.com/login');
118
+ console.log(chalk.yellow(' Waiting for you to log in and close the browser…'));
119
+ await browser.waitForClose();
120
+ console.log(chalk.green('\n ✓ Browser closed — session state saved.'));
121
+ console.log(chalk.dim(` Next: franklin social config edit (then: franklin social run)\n`));
122
+ }
123
+ catch (err) {
124
+ console.error(chalk.red(` ✗ ${err.message}`));
125
+ process.exitCode = 1;
126
+ }
127
+ finally {
128
+ await browser.close().catch(() => { });
129
+ }
130
+ }
131
+ // ─── run ───────────────────────────────────────────────────────────────────
132
+ async function runCommand(options) {
133
+ let config;
134
+ try {
135
+ config = loadSocialConfig();
136
+ }
137
+ catch (err) {
138
+ console.error(chalk.red(` ✗ Config error: ${err.message}`));
139
+ console.error(chalk.dim(` Run: franklin social setup`));
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ const ready = isConfigReady(config);
144
+ if (!ready.ready) {
145
+ console.error(chalk.red(` ✗ Config not ready: ${ready.reason}`));
146
+ console.error(chalk.dim(` Edit: ${CONFIG_PATH}`));
147
+ process.exitCode = 1;
148
+ return;
149
+ }
150
+ const dryRun = !options.live; // --live overrides default dry-run
151
+ const mode = dryRun ? 'DRY-RUN' : chalk.bold.red('LIVE');
152
+ console.log('');
153
+ console.log(chalk.bold(` franklin social run ${chalk.dim(`(${mode})`)}\n`));
154
+ console.log(` Handle: ${chalk.cyan(config.handle)}`);
155
+ console.log(` Products: ${config.products.map((p) => p.name).join(', ')}`);
156
+ console.log(` Queries: ${config.x.search_queries.length}`);
157
+ console.log(` Daily: ${config.x.daily_target} posts`);
158
+ console.log('');
159
+ const chain = loadChain();
160
+ const apiUrl = API_URLS[chain];
161
+ const appConfig = loadAppConfig();
162
+ const model = options.model || appConfig['default-model'] || 'nvidia/nemotron-ultra-253b';
163
+ console.log(chalk.dim(` Model: ${model}`));
164
+ console.log('');
165
+ let result;
166
+ try {
167
+ result = await runX({
168
+ config,
169
+ model,
170
+ apiUrl,
171
+ chain,
172
+ dryRun,
173
+ debug: options.debug,
174
+ onProgress: (msg) => process.stdout.write(msg + '\n'),
175
+ });
176
+ }
177
+ catch (err) {
178
+ console.error(chalk.red(`\n ✗ Run failed: ${err.message}`));
179
+ process.exitCode = 1;
180
+ return;
181
+ }
182
+ console.log('');
183
+ console.log(chalk.bold(' Run summary:'));
184
+ console.log(` Considered: ${result.considered}`);
185
+ console.log(` Dedup skips: ${chalk.dim(result.dedupSkipped)}`);
186
+ console.log(` AI SKIPs: ${chalk.dim(result.llmSkipped)}`);
187
+ console.log(` Drafted: ${chalk.green(result.drafted)}`);
188
+ if (!dryRun) {
189
+ console.log(` Posted: ${chalk.green.bold(result.posted)}`);
190
+ console.log(` Failed: ${result.failed > 0 ? chalk.red(result.failed) : 0}`);
191
+ }
192
+ console.log(` LLM cost: ${chalk.yellow('$' + result.totalCost.toFixed(4))}`);
193
+ console.log('');
194
+ }
195
+ // ─── stats ─────────────────────────────────────────────────────────────────
196
+ function statsCommand() {
197
+ const s = getStats('x');
198
+ console.log('');
199
+ console.log(chalk.bold(' franklin social stats — X'));
200
+ console.log('');
201
+ console.log(` Total events: ${s.total}`);
202
+ console.log(` ✓ Posted: ${chalk.green(s.posted)} ${s.today > 0 ? chalk.dim(`(${s.today} today)`) : ''}`);
203
+ console.log(` ≡ Drafted: ${s.drafted}`);
204
+ console.log(` · Skipped (AI): ${chalk.dim(s.skipped)}`);
205
+ console.log(` ✗ Failed: ${s.failed > 0 ? chalk.red(s.failed) : 0}`);
206
+ console.log(` Total LLM cost: ${chalk.yellow('$' + s.totalCost.toFixed(4))}`);
207
+ if (Object.keys(s.byProduct).length > 0) {
208
+ console.log('');
209
+ console.log(' By product:');
210
+ for (const [name, count] of Object.entries(s.byProduct)) {
211
+ console.log(` ${name.padEnd(20)} ${count}`);
212
+ }
213
+ }
214
+ console.log('');
215
+ }
216
+ // ─── config ────────────────────────────────────────────────────────────────
217
+ function configCommand(subAction) {
218
+ if (!subAction || subAction === 'path') {
219
+ console.log(CONFIG_PATH);
220
+ return;
221
+ }
222
+ if (subAction === 'show' || subAction === 'print') {
223
+ if (!fs.existsSync(CONFIG_PATH)) {
224
+ console.log(chalk.yellow(` Config not found at ${CONFIG_PATH}`));
225
+ console.log(chalk.dim(` Run: franklin social setup`));
226
+ return;
227
+ }
228
+ console.log(fs.readFileSync(CONFIG_PATH, 'utf8'));
229
+ return;
230
+ }
231
+ if (subAction === 'edit' || subAction === 'open') {
232
+ if (!fs.existsSync(CONFIG_PATH)) {
233
+ loadSocialConfig(); // writes the default file
234
+ }
235
+ const editor = process.env.EDITOR || (process.platform === 'darwin' ? 'open' : 'vi');
236
+ const args = editor === 'open' ? ['-t', CONFIG_PATH] : [CONFIG_PATH];
237
+ const child = spawn(editor, args, { stdio: 'inherit' });
238
+ child.on('close', () => {
239
+ console.log(chalk.dim(`\n Saved to ${CONFIG_PATH}`));
240
+ });
241
+ return;
242
+ }
243
+ console.log(chalk.red(` Unknown config subaction: ${subAction}`));
244
+ console.log(chalk.dim(` Try: path, show, edit`));
245
+ }
246
+ // ─── helpers ───────────────────────────────────────────────────────────────
247
+ function runChild(cmd, args) {
248
+ return new Promise((resolve, reject) => {
249
+ const child = spawn(cmd, args, { stdio: 'inherit' });
250
+ child.on('close', (code) => {
251
+ if (code === 0)
252
+ resolve();
253
+ else
254
+ reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
255
+ });
256
+ child.on('error', reject);
257
+ });
258
+ }
package/dist/index.js CHANGED
@@ -111,13 +111,33 @@ program
111
111
  const matches = searchSessions(query, { limit, model: opts.model });
112
112
  process.stdout.write(formatSearchResults(matches, query));
113
113
  });
114
+ // ─── franklin social (native X bot) ───────────────────────────────────────
115
+ // First-class subcommand. Handles setup / login / run / stats / config
116
+ // subactions. No plugin SDK, no MCP — everything lives in src/social/.
117
+ program
118
+ .command('social [action] [arg]')
119
+ .description('Native X bot — franklin social setup | login x | run | stats | config')
120
+ .option('--dry-run', 'Generate drafts without posting (default for run)')
121
+ .option('--live', 'Actually post to X (overrides dry-run default)')
122
+ .option('-m, --model <model>', 'Override the model used for reply generation')
123
+ .option('--debug', 'Enable debug logging')
124
+ .action(async (action, arg, opts) => {
125
+ const { socialCommand } = await import('./commands/social.js');
126
+ await socialCommand(action, arg, opts);
127
+ });
114
128
  // Plugin commands — dynamically registered from discovered plugins.
115
129
  // Core stays plugin-agnostic: this loop adds a command for each installed plugin.
130
+ // Note: `social` is now a first-class native command above and NOT loaded as a
131
+ // plugin (the bundled social plugin was retired in v3.2.0 in favour of the
132
+ // src/social/ subsystem).
116
133
  {
117
134
  const { loadAllPlugins, listWorkflowPlugins } = await import('./plugins/registry.js');
118
135
  await loadAllPlugins();
119
136
  for (const lp of listWorkflowPlugins()) {
120
137
  const { manifest } = lp;
138
+ // Skip any plugin whose id collides with a built-in command (e.g. social)
139
+ if (manifest.id === 'social')
140
+ continue;
121
141
  program
122
142
  .command(`${manifest.id} [action]`)
123
143
  .description(manifest.description)
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Helpers for finding elements in the flat [depth-idx] ref tree produced by
3
+ * SocialBrowser.snapshot(). Ported from social-bot's bot/browser.py regex
4
+ * model, where elements are located by role + label rather than CSS/XPath.
5
+ *
6
+ * The mental model: snapshot() returns a string like
7
+ *
8
+ * [0-0] main: Timeline
9
+ * [1-0] article: post by user
10
+ * [2-0] link: Mar 16
11
+ * [2-1] StaticText: hello world
12
+ * [1-1] button: Reply
13
+ * [1-2] textbox: Post text
14
+ *
15
+ * …and these helpers find the refs via regex on that string.
16
+ */
17
+ /**
18
+ * Find all refs matching a role and a label pattern.
19
+ *
20
+ * @param tree The snapshot output string
21
+ * @param role AX role, e.g. "button", "link", "textbox", "article"
22
+ * @param label Regex source for the label (default `.*` — any). Substring matches count.
23
+ * @returns Array of ref ids like ["0-0", "1-3"] in document order
24
+ */
25
+ export declare function findRefs(tree: string, role: string, label?: string): string[];
26
+ /**
27
+ * Find refs AND their labels. Useful when you want both the click target
28
+ * (ref) and the visible text (label) in one pass.
29
+ */
30
+ export declare function findRefsWithLabels(tree: string, role: string, label?: string): Array<{
31
+ ref: string;
32
+ label: string;
33
+ }>;
34
+ /**
35
+ * Find text content inside the tree (not a ref — just the visible string).
36
+ * Useful for reading static text like tweet snippets.
37
+ */
38
+ export declare function findStaticText(tree: string): string[];
39
+ /**
40
+ * Split an X timeline/search snapshot into per-article blocks so we can
41
+ * process each tweet independently. Returns the text slice for each article,
42
+ * starting at the `[N-M] article:` line and ending at the next article or
43
+ * end-of-tree.
44
+ */
45
+ export declare function extractArticleBlocks(tree: string): Array<{
46
+ ref: string;
47
+ text: string;
48
+ }>;
49
+ /**
50
+ * Regex pattern for X's "time-link" text: "Mar 16", "5h", "just now", "2d", etc.
51
+ * This doubles as the "this is a tweet" signal in social-bot — the only link
52
+ * inside an article block with this label shape is the permalink to the tweet.
53
+ */
54
+ export declare const X_TIME_LINK_PATTERN = "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+|\\d+[smhd]|just now|now";
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Helpers for finding elements in the flat [depth-idx] ref tree produced by
3
+ * SocialBrowser.snapshot(). Ported from social-bot's bot/browser.py regex
4
+ * model, where elements are located by role + label rather than CSS/XPath.
5
+ *
6
+ * The mental model: snapshot() returns a string like
7
+ *
8
+ * [0-0] main: Timeline
9
+ * [1-0] article: post by user
10
+ * [2-0] link: Mar 16
11
+ * [2-1] StaticText: hello world
12
+ * [1-1] button: Reply
13
+ * [1-2] textbox: Post text
14
+ *
15
+ * …and these helpers find the refs via regex on that string.
16
+ */
17
+ /**
18
+ * Find all refs matching a role and a label pattern.
19
+ *
20
+ * @param tree The snapshot output string
21
+ * @param role AX role, e.g. "button", "link", "textbox", "article"
22
+ * @param label Regex source for the label (default `.*` — any). Substring matches count.
23
+ * @returns Array of ref ids like ["0-0", "1-3"] in document order
24
+ */
25
+ export function findRefs(tree, role, label = '.*') {
26
+ const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*${label}`, 'g');
27
+ const out = [];
28
+ let m;
29
+ while ((m = re.exec(tree)) !== null) {
30
+ out.push(m[1]);
31
+ }
32
+ return out;
33
+ }
34
+ /**
35
+ * Find refs AND their labels. Useful when you want both the click target
36
+ * (ref) and the visible text (label) in one pass.
37
+ */
38
+ export function findRefsWithLabels(tree, role, label = '.*') {
39
+ const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*(${label})`, 'g');
40
+ const out = [];
41
+ let m;
42
+ while ((m = re.exec(tree)) !== null) {
43
+ out.push({ ref: m[1], label: m[2].trim() });
44
+ }
45
+ return out;
46
+ }
47
+ /**
48
+ * Find text content inside the tree (not a ref — just the visible string).
49
+ * Useful for reading static text like tweet snippets.
50
+ */
51
+ export function findStaticText(tree) {
52
+ const re = /\[\d+-\d+\]\s+StaticText:\s*(.+)/g;
53
+ const out = [];
54
+ let m;
55
+ while ((m = re.exec(tree)) !== null) {
56
+ out.push(m[1].trim());
57
+ }
58
+ return out;
59
+ }
60
+ /**
61
+ * Split an X timeline/search snapshot into per-article blocks so we can
62
+ * process each tweet independently. Returns the text slice for each article,
63
+ * starting at the `[N-M] article:` line and ending at the next article or
64
+ * end-of-tree.
65
+ */
66
+ export function extractArticleBlocks(tree) {
67
+ const articleStarts = [];
68
+ const re = /\[(\d+-\d+)\]\s+article:/g;
69
+ let m;
70
+ while ((m = re.exec(tree)) !== null) {
71
+ articleStarts.push({ ref: m[1], pos: m.index });
72
+ }
73
+ const out = [];
74
+ for (let i = 0; i < articleStarts.length; i++) {
75
+ const start = articleStarts[i].pos;
76
+ const end = i + 1 < articleStarts.length ? articleStarts[i + 1].pos : tree.length;
77
+ out.push({ ref: articleStarts[i].ref, text: tree.slice(start, end) });
78
+ }
79
+ return out;
80
+ }
81
+ /**
82
+ * Regex pattern for X's "time-link" text: "Mar 16", "5h", "just now", "2d", etc.
83
+ * This doubles as the "this is a tweet" signal in social-bot — the only link
84
+ * inside an article block with this label shape is the permalink to the tweet.
85
+ */
86
+ export const X_TIME_LINK_PATTERN = '(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+|\\d+[smhd]|just now|now';
87
+ function escapeRegex(s) {
88
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
89
+ }