@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 +125 -9
- package/dist/commands/social.d.ts +24 -0
- package/dist/commands/social.js +258 -0
- package/dist/index.js +20 -0
- package/dist/social/a11y.d.ts +54 -0
- package/dist/social/a11y.js +89 -0
- package/dist/social/ai.d.ts +61 -0
- package/dist/social/ai.js +103 -0
- package/dist/social/browser.d.ts +97 -0
- package/dist/social/browser.js +219 -0
- package/dist/social/config.d.ts +43 -0
- package/dist/social/config.js +83 -0
- package/dist/social/db.d.ts +102 -0
- package/dist/social/db.js +248 -0
- package/dist/social/x.d.ts +46 -0
- package/dist/social/x.js +284 -0
- package/package.json +2 -1
package/dist/banner.js
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
//
|
|
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
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
}
|